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

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 (396) hide show
  1. package/README.md +13 -10
  2. package/dist/agent/agent-definition-registry.d.ts +3 -0
  3. package/dist/agent/agent-definition-registry.js +3 -1
  4. package/dist/agent/agent-definition.d.ts +15 -0
  5. package/dist/agent/agent-definition.js +27 -3
  6. package/dist/arena/context/context-tools.js +47 -4
  7. package/dist/arena/ledger.js +9 -1
  8. package/dist/arena/strategies/utils.d.ts +1 -4
  9. package/dist/arena/strategies/utils.js +7 -84
  10. package/dist/automation/cron-expr.d.ts +10 -0
  11. package/dist/automation/cron-expr.js +23 -5
  12. package/dist/automation/runner.d.ts +21 -4
  13. package/dist/automation/runner.js +48 -12
  14. package/dist/automation/scheduler.d.ts +38 -0
  15. package/dist/automation/scheduler.js +89 -12
  16. package/dist/automation/store.js +5 -2
  17. package/dist/capability-control/disabled-lists.d.ts +25 -0
  18. package/dist/capability-control/disabled-lists.js +57 -0
  19. package/dist/capability-control/overlay.d.ts +15 -0
  20. package/dist/capability-control/overlay.js +27 -0
  21. package/dist/cc-orchestrator/agent-adapter.d.ts +50 -0
  22. package/dist/cc-orchestrator/agent-adapter.js +133 -0
  23. package/dist/cc-orchestrator/cc-capability.d.ts +19 -0
  24. package/dist/cc-orchestrator/cc-capability.js +53 -0
  25. package/dist/cc-orchestrator/codex-session-discovery.d.ts +24 -0
  26. package/dist/cc-orchestrator/codex-session-discovery.js +191 -0
  27. package/dist/cc-orchestrator/codex-session-history.d.ts +25 -0
  28. package/dist/cc-orchestrator/codex-session-history.js +187 -0
  29. package/dist/cc-orchestrator/external-agent-changes.d.ts +19 -0
  30. package/dist/cc-orchestrator/external-agent-changes.js +214 -0
  31. package/dist/cc-orchestrator/external-agent-driver.d.ts +18 -0
  32. package/dist/cc-orchestrator/external-agent-driver.js +69 -0
  33. package/dist/cc-orchestrator/index.d.ts +8 -0
  34. package/dist/cc-orchestrator/index.js +8 -0
  35. package/dist/cc-orchestrator/relevance-judge.d.ts +15 -0
  36. package/dist/cc-orchestrator/relevance-judge.js +29 -0
  37. package/dist/cc-orchestrator/session-discovery.d.ts +46 -0
  38. package/dist/cc-orchestrator/session-discovery.js +125 -0
  39. package/dist/cc-orchestrator/session-history.d.ts +19 -0
  40. package/dist/cc-orchestrator/session-history.js +67 -0
  41. package/dist/cli/agent-server-stdio.d.ts +15 -1
  42. package/dist/cli/agent-server-stdio.js +115 -17
  43. package/dist/cli/agent-server-tcp.js +40 -26
  44. package/dist/context/compaction.d.ts +86 -0
  45. package/dist/context/compaction.js +279 -0
  46. package/dist/context/manager.d.ts +18 -0
  47. package/dist/context/manager.js +181 -48
  48. package/dist/context/token-counter.js +13 -0
  49. package/dist/cost-tracker.js +5 -61
  50. package/dist/credentials/cipher.d.ts +49 -0
  51. package/dist/credentials/cipher.js +45 -0
  52. package/dist/credentials/cookie-jar.d.ts +24 -0
  53. package/dist/credentials/cookie-jar.js +40 -0
  54. package/dist/credentials/index.d.ts +7 -0
  55. package/dist/credentials/index.js +5 -0
  56. package/dist/credentials/inject-credential-tool.d.ts +20 -0
  57. package/dist/credentials/inject-credential-tool.js +130 -0
  58. package/dist/credentials/store.d.ts +72 -0
  59. package/dist/credentials/store.js +184 -0
  60. package/dist/credentials/types.d.ts +56 -0
  61. package/dist/credentials/use-credential-tool.d.ts +29 -0
  62. package/dist/credentials/use-credential-tool.js +205 -0
  63. package/dist/credentials/use-gate.d.ts +56 -0
  64. package/dist/credentials/use-gate.js +52 -0
  65. package/dist/data/model-metadata.d.ts +77 -0
  66. package/dist/data/model-metadata.js +56 -0
  67. package/dist/data/model-metadata.json +216 -0
  68. package/dist/data/openrouter-models.d.ts +18 -7
  69. package/dist/data/openrouter-models.js +35 -8
  70. package/dist/engine/aux-key.d.ts +10 -0
  71. package/dist/engine/aux-key.js +11 -0
  72. package/dist/engine/dynamic-tool-defs.d.ts +19 -0
  73. package/dist/engine/dynamic-tool-defs.js +36 -0
  74. package/dist/engine/engine.d.ts +280 -141
  75. package/dist/engine/engine.js +1193 -245
  76. package/dist/engine/friendly-error.d.ts +18 -0
  77. package/dist/engine/friendly-error.js +63 -0
  78. package/dist/engine/goal.d.ts +145 -0
  79. package/dist/engine/goal.js +144 -0
  80. package/dist/engine/image-policy.d.ts +13 -0
  81. package/dist/engine/image-policy.js +24 -0
  82. package/dist/engine/model-connections-pool.d.ts +17 -0
  83. package/dist/engine/model-connections-pool.js +67 -0
  84. package/dist/engine/model-facade.d.ts +10 -0
  85. package/dist/engine/model-facade.js +15 -0
  86. package/dist/engine/patch-orphaned-tools.js +3 -0
  87. package/dist/engine/query.js +2 -0
  88. package/dist/engine/resolve-llm-config.d.ts +16 -0
  89. package/dist/engine/resolve-llm-config.js +44 -0
  90. package/dist/engine/runtime.d.ts +2 -0
  91. package/dist/engine/runtime.js +27 -1
  92. package/dist/engine/sandbox-cache-key.d.ts +10 -0
  93. package/dist/engine/sandbox-cache-key.js +9 -0
  94. package/dist/engine/sandbox-config.d.ts +31 -0
  95. package/dist/engine/sandbox-config.js +38 -0
  96. package/dist/engine/session-usage.d.ts +31 -0
  97. package/dist/engine/session-usage.js +81 -0
  98. package/dist/engine/steer-queue.d.ts +33 -0
  99. package/dist/engine/steer-queue.js +26 -0
  100. package/dist/engine/streaming-tool-queue.d.ts +12 -0
  101. package/dist/engine/streaming-tool-queue.js +50 -10
  102. package/dist/engine/turn-loop.d.ts +94 -4
  103. package/dist/engine/turn-loop.js +432 -54
  104. package/dist/engine/types.d.ts +175 -0
  105. package/dist/engine/types.js +13 -0
  106. package/dist/external-agents/config.d.ts +2 -0
  107. package/dist/external-agents/config.js +15 -0
  108. package/dist/external-agents/types.d.ts +31 -0
  109. package/dist/external-agents/types.js +1 -0
  110. package/dist/git/utils.d.ts +12 -0
  111. package/dist/git/utils.js +38 -6
  112. package/dist/git/worktree.d.ts +48 -0
  113. package/dist/git/worktree.js +86 -10
  114. package/dist/hooks/goal-stop-hook.d.ts +28 -0
  115. package/dist/hooks/goal-stop-hook.js +186 -9
  116. package/dist/hooks/registry.d.ts +8 -0
  117. package/dist/hooks/registry.js +19 -0
  118. package/dist/hooks/shell-runner.js +10 -21
  119. package/dist/index.d.ts +51 -7
  120. package/dist/index.js +62 -5
  121. package/dist/llm/capabilities/param-specs.d.ts +14 -0
  122. package/dist/llm/capabilities/param-specs.js +62 -0
  123. package/dist/llm/capabilities/rules.js +5 -1
  124. package/dist/llm/capabilities/types.d.ts +10 -0
  125. package/dist/llm/client-base.d.ts +28 -1
  126. package/dist/llm/client-base.js +119 -13
  127. package/dist/llm/model-cache.js +4 -2
  128. package/dist/llm/model-pool.d.ts +21 -0
  129. package/dist/llm/model-pool.js +21 -1
  130. package/dist/llm/provider-auth.d.ts +41 -0
  131. package/dist/llm/provider-auth.js +76 -0
  132. package/dist/llm/provider-catalog.d.ts +4 -0
  133. package/dist/llm/providers/anthropic.js +64 -14
  134. package/dist/llm/providers/openai.d.ts +36 -0
  135. package/dist/llm/providers/openai.js +257 -51
  136. package/dist/llm/reasoning-setting.d.ts +3 -3
  137. package/dist/llm/reasoning-setting.js +9 -1
  138. package/dist/llm/stream-watchdog.js +5 -1
  139. package/dist/llm/token-counter.js +9 -2
  140. package/dist/llm/types.d.ts +4 -0
  141. package/dist/logging/sanitize-messages.js +22 -0
  142. package/dist/lsp/manager.d.ts +1 -1
  143. package/dist/lsp/manager.js +40 -10
  144. package/dist/model-catalog/builtin.d.ts +12 -0
  145. package/dist/model-catalog/builtin.js +412 -0
  146. package/dist/model-catalog/gen-connections.d.ts +20 -0
  147. package/dist/model-catalog/gen-connections.js +28 -0
  148. package/dist/model-catalog/index.d.ts +41 -0
  149. package/dist/model-catalog/index.js +90 -0
  150. package/dist/model-catalog/params.d.ts +20 -0
  151. package/dist/model-catalog/params.js +45 -0
  152. package/dist/model-catalog/resolve.d.ts +48 -0
  153. package/dist/model-catalog/resolve.js +33 -0
  154. package/dist/model-catalog/save-entry.d.ts +32 -0
  155. package/dist/model-catalog/save-entry.js +104 -0
  156. package/dist/model-catalog/types.d.ts +561 -0
  157. package/dist/model-catalog/types.js +93 -0
  158. package/dist/model-catalog/upsert.d.ts +9 -0
  159. package/dist/model-catalog/upsert.js +8 -0
  160. package/dist/onboarding.d.ts +12 -82
  161. package/dist/onboarding.js +61 -326
  162. package/dist/plugins/gitOps.d.ts +19 -0
  163. package/dist/plugins/gitOps.js +73 -4
  164. package/dist/plugins/installer/checkUpdate.d.ts +16 -0
  165. package/dist/plugins/installer/checkUpdate.js +32 -0
  166. package/dist/plugins/installer/codex/convertCommands.d.ts +19 -0
  167. package/dist/plugins/installer/codex/convertCommands.js +46 -0
  168. package/dist/plugins/installer/codex/convertMcp.d.ts +5 -2
  169. package/dist/plugins/installer/codex/convertMcp.js +45 -5
  170. package/dist/plugins/installer/install.js +25 -1
  171. package/dist/plugins/installer/installFromArchive.d.ts +43 -0
  172. package/dist/plugins/installer/installFromArchive.js +134 -0
  173. package/dist/plugins/installer/installFromSource.js +8 -2
  174. package/dist/plugins/installer/loadPluginAgents.js +6 -2
  175. package/dist/plugins/installer/loadPluginMcp.d.ts +9 -2
  176. package/dist/plugins/installer/loadPluginMcp.js +35 -2
  177. package/dist/plugins/installer/pruneDisabled.d.ts +24 -0
  178. package/dist/plugins/installer/pruneDisabled.js +73 -0
  179. package/dist/plugins/installer/types.d.ts +5 -2
  180. package/dist/plugins/installer/types.js +1 -0
  181. package/dist/plugins/installer/uninstall.js +4 -0
  182. package/dist/plugins/installer/unzip.d.ts +14 -0
  183. package/dist/plugins/installer/unzip.js +82 -0
  184. package/dist/plugins/installer/update.d.ts +14 -0
  185. package/dist/plugins/installer/update.js +53 -21
  186. package/dist/plugins/loadPluginHooks.d.ts +47 -1
  187. package/dist/plugins/loadPluginHooks.js +73 -1
  188. package/dist/plugins/marketplaceManager.d.ts +7 -0
  189. package/dist/plugins/marketplaceManager.js +20 -0
  190. package/dist/plugins/pluginCommandHook.js +4 -18
  191. package/dist/plugins/pluginContent.d.ts +30 -0
  192. package/dist/plugins/pluginContent.js +83 -0
  193. package/dist/plugins/pluginInstaller.d.ts +13 -0
  194. package/dist/plugins/pluginInstaller.js +42 -8
  195. package/dist/plugins/schemas.js +1 -0
  196. package/dist/plugins/types.d.ts +6 -0
  197. package/dist/preset/index.d.ts +11 -1
  198. package/dist/preset/index.js +115 -6
  199. package/dist/product/types.d.ts +1 -1
  200. package/dist/prompt/composer.d.ts +32 -0
  201. package/dist/prompt/composer.js +75 -21
  202. package/dist/prompt/instruction-scanner.js +5 -3
  203. package/dist/prompt/section-loader.js +1 -0
  204. package/dist/prompt/sections/base.md +2 -0
  205. package/dist/prompt/sections/browser.md +10 -0
  206. package/dist/prompt/sections/coding.md +4 -0
  207. package/dist/protocol/chat-session-manager.d.ts +9 -2
  208. package/dist/protocol/chat-session-manager.js +38 -0
  209. package/dist/protocol/chat-session.d.ts +69 -1
  210. package/dist/protocol/chat-session.js +102 -4
  211. package/dist/protocol/client.d.ts +31 -0
  212. package/dist/protocol/client.js +44 -0
  213. package/dist/protocol/server.d.ts +120 -7
  214. package/dist/protocol/server.js +753 -51
  215. package/dist/protocol/transport.js +3 -2
  216. package/dist/protocol/types.d.ts +60 -0
  217. package/dist/protocol/types.js +16 -0
  218. package/dist/quota/credentials.d.ts +3 -0
  219. package/dist/quota/credentials.js +80 -0
  220. package/dist/quota/index.d.ts +36 -0
  221. package/dist/quota/index.js +155 -0
  222. package/dist/quota/types.d.ts +48 -0
  223. package/dist/quota/types.js +13 -0
  224. package/dist/review/review-prompt.d.ts +28 -0
  225. package/dist/review/review-prompt.js +81 -0
  226. package/dist/run/FileRunStore.js +8 -3
  227. package/dist/run/RunApprovalBackend.js +25 -5
  228. package/dist/run/RunManager.d.ts +12 -0
  229. package/dist/run/RunManager.js +35 -0
  230. package/dist/run/factory.d.ts +1 -1
  231. package/dist/runtime/background-shell.d.ts +139 -0
  232. package/dist/runtime/background-shell.js +509 -0
  233. package/dist/runtime/output-clean.d.ts +24 -0
  234. package/dist/runtime/output-clean.js +41 -0
  235. package/dist/runtime/ring-file.d.ts +64 -0
  236. package/dist/runtime/ring-file.js +174 -0
  237. package/dist/runtime/safe-spawn.js +98 -39
  238. package/dist/runtime/spawn-common.d.ts +159 -0
  239. package/dist/runtime/spawn-common.js +404 -0
  240. package/dist/runtime/truncate-output.d.ts +22 -0
  241. package/dist/runtime/truncate-output.js +49 -0
  242. package/dist/runtime/utf8-cut.d.ts +11 -0
  243. package/dist/runtime/utf8-cut.js +23 -0
  244. package/dist/services/auto-dream.d.ts +4 -0
  245. package/dist/services/auto-dream.js +26 -26
  246. package/dist/services/diagnostics.d.ts +1 -2
  247. package/dist/services/diagnostics.js +12 -7
  248. package/dist/services/extract-memories.d.ts +14 -1
  249. package/dist/services/extract-memories.js +45 -6
  250. package/dist/services/memory-orchestrator.d.ts +21 -0
  251. package/dist/services/memory-orchestrator.js +125 -50
  252. package/dist/services/session-memory.js +24 -12
  253. package/dist/session/file-history.d.ts +124 -1
  254. package/dist/session/file-history.js +222 -6
  255. package/dist/session/memory.d.ts +116 -2
  256. package/dist/session/memory.js +250 -28
  257. package/dist/session/session-manager.d.ts +40 -0
  258. package/dist/session/session-manager.js +119 -3
  259. package/dist/session/simple-diff.d.ts +23 -0
  260. package/dist/session/simple-diff.js +84 -0
  261. package/dist/session/transcript.d.ts +29 -1
  262. package/dist/session/transcript.js +56 -2
  263. package/dist/session/undo-target.d.ts +67 -0
  264. package/dist/session/undo-target.js +144 -0
  265. package/dist/settings/disk-defaults.d.ts +9 -2
  266. package/dist/settings/disk-defaults.js +11 -2
  267. package/dist/settings/feature-flags.d.ts +64 -0
  268. package/dist/settings/feature-flags.js +61 -0
  269. package/dist/settings/manager.d.ts +68 -1
  270. package/dist/settings/manager.js +266 -29
  271. package/dist/settings/migrate-config.d.ts +45 -0
  272. package/dist/settings/migrate-config.js +125 -0
  273. package/dist/settings/schema-export.d.ts +25 -0
  274. package/dist/settings/schema-export.js +38 -0
  275. package/dist/settings/schema.d.ts +1186 -771
  276. package/dist/settings/schema.js +268 -97
  277. package/dist/skills/scanner.d.ts +9 -0
  278. package/dist/skills/scanner.js +30 -2
  279. package/dist/stt/resolve-transcribe.d.ts +31 -0
  280. package/dist/stt/resolve-transcribe.js +108 -0
  281. package/dist/stt/transcribe.d.ts +51 -0
  282. package/dist/stt/transcribe.js +65 -0
  283. package/dist/tool-system/browser-bridge.d.ts +226 -0
  284. package/dist/tool-system/browser-bridge.js +163 -0
  285. package/dist/tool-system/builtin/agent-heartbeat.d.ts +49 -0
  286. package/dist/tool-system/builtin/agent-heartbeat.js +89 -0
  287. package/dist/tool-system/builtin/agent-notifications.d.ts +12 -3
  288. package/dist/tool-system/builtin/agent-notifications.js +9 -3
  289. package/dist/tool-system/builtin/agent-output-file.d.ts +38 -0
  290. package/dist/tool-system/builtin/agent-output-file.js +72 -0
  291. package/dist/tool-system/builtin/agent-registry.d.ts +12 -0
  292. package/dist/tool-system/builtin/agent-registry.js +8 -0
  293. package/dist/tool-system/builtin/agent.d.ts +21 -1
  294. package/dist/tool-system/builtin/agent.js +489 -42
  295. package/dist/tool-system/builtin/apply-patch/applier.js +66 -8
  296. package/dist/tool-system/builtin/apply-patch/backup-targets.d.ts +10 -0
  297. package/dist/tool-system/builtin/apply-patch/backup-targets.js +30 -0
  298. package/dist/tool-system/builtin/apply-patch/index.js +0 -15
  299. package/dist/tool-system/builtin/background-jobs.d.ts +76 -0
  300. package/dist/tool-system/builtin/background-jobs.js +124 -0
  301. package/dist/tool-system/builtin/background-shell-tools.d.ts +20 -0
  302. package/dist/tool-system/builtin/background-shell-tools.js +108 -0
  303. package/dist/tool-system/builtin/background-work.d.ts +67 -0
  304. package/dist/tool-system/builtin/background-work.js +86 -0
  305. package/dist/tool-system/builtin/bash-output-style.d.ts +32 -0
  306. package/dist/tool-system/builtin/bash-output-style.js +40 -0
  307. package/dist/tool-system/builtin/bash.d.ts +5 -2
  308. package/dist/tool-system/builtin/bash.js +101 -64
  309. package/dist/tool-system/builtin/browser-tools.d.ts +33 -0
  310. package/dist/tool-system/builtin/browser-tools.js +312 -0
  311. package/dist/tool-system/builtin/cancel-goal.d.ts +31 -0
  312. package/dist/tool-system/builtin/cancel-goal.js +64 -0
  313. package/dist/tool-system/builtin/check-quota.d.ts +15 -0
  314. package/dist/tool-system/builtin/check-quota.js +34 -0
  315. package/dist/tool-system/builtin/config.js +7 -0
  316. package/dist/tool-system/builtin/cron.d.ts +7 -0
  317. package/dist/tool-system/builtin/cron.js +64 -4
  318. package/dist/tool-system/builtin/drive-claude-code.d.ts +30 -0
  319. package/dist/tool-system/builtin/drive-claude-code.js +157 -0
  320. package/dist/tool-system/builtin/edit-model-catalog.d.ts +3 -0
  321. package/dist/tool-system/builtin/edit-model-catalog.js +104 -0
  322. package/dist/tool-system/builtin/edit.js +20 -16
  323. package/dist/tool-system/builtin/eol.d.ts +29 -0
  324. package/dist/tool-system/builtin/eol.js +37 -0
  325. package/dist/tool-system/builtin/file-cache.d.ts +6 -0
  326. package/dist/tool-system/builtin/file-cache.js +8 -0
  327. package/dist/tool-system/builtin/generate-image.d.ts +35 -0
  328. package/dist/tool-system/builtin/generate-image.js +278 -50
  329. package/dist/tool-system/builtin/generate-video.d.ts +55 -0
  330. package/dist/tool-system/builtin/generate-video.js +364 -0
  331. package/dist/tool-system/builtin/glob.js +0 -7
  332. package/dist/tool-system/builtin/grep.d.ts +9 -0
  333. package/dist/tool-system/builtin/grep.js +106 -11
  334. package/dist/tool-system/builtin/image-providers.d.ts +86 -0
  335. package/dist/tool-system/builtin/image-providers.js +190 -0
  336. package/dist/tool-system/builtin/image-uploader.d.ts +33 -0
  337. package/dist/tool-system/builtin/image-uploader.js +74 -0
  338. package/dist/tool-system/builtin/index.d.ts +10 -2
  339. package/dist/tool-system/builtin/index.js +251 -27
  340. package/dist/tool-system/builtin/mcp-tools.js +23 -3
  341. package/dist/tool-system/builtin/memory.js +45 -7
  342. package/dist/tool-system/builtin/notebook-edit.js +0 -7
  343. package/dist/tool-system/builtin/powershell.js +8 -2
  344. package/dist/tool-system/builtin/read.js +10 -10
  345. package/dist/tool-system/builtin/repl.js +4 -1
  346. package/dist/tool-system/builtin/skill.js +9 -0
  347. package/dist/tool-system/builtin/sleep.js +8 -2
  348. package/dist/tool-system/builtin/tool-search.js +25 -7
  349. package/dist/tool-system/builtin/video-providers.d.ts +154 -0
  350. package/dist/tool-system/builtin/video-providers.js +235 -0
  351. package/dist/tool-system/builtin/web-fetch.js +12 -2
  352. package/dist/tool-system/builtin/web-search.js +21 -7
  353. package/dist/tool-system/builtin/worktree.d.ts +2 -1
  354. package/dist/tool-system/builtin/worktree.js +31 -4
  355. package/dist/tool-system/builtin/write.js +0 -6
  356. package/dist/tool-system/context.d.ts +164 -6
  357. package/dist/tool-system/executor.d.ts +3 -1
  358. package/dist/tool-system/executor.js +197 -90
  359. package/dist/tool-system/investigation-guard.js +1 -1
  360. package/dist/tool-system/mcp-manager.d.ts +42 -4
  361. package/dist/tool-system/mcp-manager.js +203 -20
  362. package/dist/tool-system/mcp-stdio-diagnostics.d.ts +9 -0
  363. package/dist/tool-system/mcp-stdio-diagnostics.js +93 -0
  364. package/dist/tool-system/path-policy.d.ts +5 -0
  365. package/dist/tool-system/path-policy.js +307 -8
  366. package/dist/tool-system/permission.d.ts +39 -2
  367. package/dist/tool-system/permission.js +283 -70
  368. package/dist/tool-system/plan-mode-allowlist.d.ts +13 -2
  369. package/dist/tool-system/plan-mode-allowlist.js +24 -2
  370. package/dist/tool-system/registry.d.ts +1 -0
  371. package/dist/tool-system/registry.js +16 -2
  372. package/dist/tool-system/sandbox/index.d.ts +8 -0
  373. package/dist/tool-system/sandbox/index.js +7 -2
  374. package/dist/tool-system/sandbox/off.js +7 -1
  375. package/dist/tool-system/validate-tool-metadata.d.ts +36 -0
  376. package/dist/tool-system/validate-tool-metadata.js +63 -0
  377. package/dist/types.d.ts +252 -7
  378. package/dist/updater.js +20 -9
  379. package/dist/utils/envUtils.d.ts +0 -9
  380. package/dist/utils/envUtils.js +3 -28
  381. package/dist/utils/exec.d.ts +48 -0
  382. package/dist/utils/exec.js +154 -0
  383. package/dist/utils/json.d.ts +12 -0
  384. package/dist/utils/json.js +92 -0
  385. package/dist/utils/theme.d.ts +1 -1
  386. package/dist/utils/theme.js +1 -1
  387. package/dist/utils/toolDisplay.js +0 -1
  388. package/package.json +13 -7
  389. package/dist/agent/coordinator.d.ts +0 -49
  390. package/dist/agent/coordinator.js +0 -77
  391. package/dist/settings/manager.test.js +0 -73
  392. package/dist/tool-system/builtin/remote-trigger.d.ts +0 -6
  393. package/dist/tool-system/builtin/remote-trigger.js +0 -54
  394. package/dist/tool-system/builtin/send-message.d.ts +0 -6
  395. package/dist/tool-system/builtin/send-message.js +0 -47
  396. /package/dist/{settings/manager.test.d.ts → credentials/types.js} +0 -0
@@ -7,21 +7,29 @@ import { ToolExecutor } from "../tool-system/executor.js";
7
7
  import { InvestigationGuard } from "../tool-system/investigation-guard.js";
8
8
  import { TaskGuard } from "../tool-system/task-guard.js";
9
9
  import { readLastTodoSnapshot } from "../tool-system/builtin/task.js";
10
- import { agentToolDefWithTypes } from "../tool-system/builtin/agent.js";
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 { addCumulativeUsage, cumulativeCacheHitRate, foldRunUsage, normalizeCumulativeUsageCounters, } 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";
11
18
  import { BUILTIN_TOOL_GUARDS } from "../tool-system/builtin/index.js";
12
19
  import { asyncAgentRegistry } from "../tool-system/builtin/agent-registry.js";
20
+ import { backgroundShellManager } from "../runtime/background-shell.js";
13
21
  import { notificationQueue, buildNotificationMessage, } from "../tool-system/builtin/agent-notifications.js";
14
22
  import { PermissionClassifier, HeadlessApprovalBackend, AutoApprovalBackend, InteractiveApprovalBackend, getInteractiveApprovalBackend, } from "../tool-system/permission.js";
15
23
  import { HookRegistry } from "../hooks/registry.js";
16
24
  import { wrapHookMessages } from "../hooks/inject.js";
17
25
  import { createGoalStopHook } from "../hooks/goal-stop-hook.js";
18
- import { normalizeGoal } from "./goal.js";
26
+ import { normalizeGoal, resolveGoalSetAt, resolveMaxTurns, resolveMaxStopBlocks, } from "./goal.js";
19
27
  import { loadPluginHooks } from "../plugins/loadPluginHooks.js";
20
28
  import { pluginAgentDirs } from "../plugins/installer/loadPluginAgents.js";
21
29
  import { patchOrphanedToolUses } from "./patch-orphaned-tools.js";
22
30
  import { runShellHook, shellHookMatches } from "../hooks/shell-runner.js";
23
31
  import { ContextManager } from "../context/manager.js";
24
- import { estimateTokens } from "../context/compaction.js";
32
+ import { estimateTokens, clampContextRatios as clampContextRatiosImpl, } from "../context/compaction.js";
25
33
  import { PLAN_MODE_ALLOWED_TOOLS } from "../tool-system/plan-mode-allowlist.js";
26
34
  import { PromptComposer } from "../prompt/composer.js";
27
35
  import { SessionManager } from "../session/session-manager.js";
@@ -32,26 +40,28 @@ import { sanitizeContent, sanitizeTaskString } from "../logging/sanitize-message
32
40
  import { TurnLoop } from "./turn-loop.js";
33
41
  import { MCPManager } from "../tool-system/mcp-manager.js";
34
42
  import { SettingsManager, userHome } from "../settings/manager.js";
35
- import { effectiveDisabledList, effectiveBuiltinLists } from "../capability-control/overlay.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";
36
47
  import { FileHistory } from "../session/file-history.js";
37
- 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";
38
50
  import { resolveAgentPreset, resolveBuiltinToolNames, } from "../preset/index.js";
39
51
  import { ModelPool } from "../llm/model-pool.js";
40
52
  import { AgentDefinitionRegistry } from "../agent/agent-definition-registry.js";
41
- import { ProviderCatalog } from "../llm/provider-catalog.js";
42
53
  import { defaultCacheDir } from "../llm/model-cache.js";
43
- import { detectProviderFromApiKey, buildModelPool, } from "../onboarding.js";
54
+ import { detectProviderFromApiKey, buildModelPool } from "../onboarding.js";
44
55
  import { detectPastedNoise } from "../utils/task-sanitizer.js";
45
- import { parseTaskWithImages, } from "./parse-task.js";
46
- import { enforceImagePolicy, byteLengthFromBase64, dropOversizedImages, } from "./image-policy.js";
56
+ import { parseTaskWithImages } from "./parse-task.js";
57
+ import { enforceImagePolicy, byteLengthFromBase64, dropOversizedImages, collectAttachedImagePaths, } from "./image-policy.js";
47
58
  import { tryCompressImages } from "./image-compression.js";
48
59
  import { buildSessionTitle } from "./session-title.js";
49
60
  import { capabilitiesFor } from "../llm/capabilities/index.js";
50
61
  import { MemoryOrchestrator } from "../services/memory-orchestrator.js";
51
62
  import { runDreamConsolidation } from "../services/dream-consolidation.js";
52
- import { join } from "node:path";
53
- import { homedir } from "node:os";
54
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
63
+ import { join, isAbsolute } from "node:path";
64
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
55
65
  /**
56
66
  * Build ScanOptions.compatFileNames from the user's instruction compat toggles.
57
67
  * Primary file name stays hard-wired to CODESHELL.md (not exposed). Turning a
@@ -118,8 +128,25 @@ export function resolveChildLlm(modelKey, pool, parentLlm) {
118
128
  * 2. user-level ~/.code-shell/agents/*.md (user wins on name)
119
129
  * Names in `disabledAgents` are filtered out so the LLM never sees them.
120
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
+ }
121
145
  export function loadAgentDefinitionsForCwd(cwd, disabledAgents = [], disabledPlugins = []) {
122
- const home = homedir();
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();
123
150
  // Increasing priority; loadFromDirs is last-dir-wins. ORDER ENCODES POLICY:
124
151
  // user (cross-project personal default, lowest) → plugins (reusable baseline)
125
152
  // → project (highest). A repo's in-tree agent therefore overrides a same-named
@@ -135,7 +162,7 @@ export function loadAgentDefinitionsForCwd(cwd, disabledAgents = [], disabledPlu
135
162
  ...(cwd ? [{ dir: `${cwd}/.code-shell/agents`, source: "project" }] : []),
136
163
  ], disabledAgents);
137
164
  }
138
- const NESTED_AGENT_TOOLS = ["Agent", "AgentStatus", "AgentCancel"];
165
+ const NESTED_AGENT_TOOLS = ["Agent", "AgentStatus", "AgentCancel", "AgentSendInput"];
139
166
  /**
140
167
  * #7: apply a project's per-turn builtin capability override to a tool list.
141
168
  * A builtin marked `off` for the current cwd is HIDDEN from the turn's tool
@@ -238,7 +265,39 @@ export class Engine {
238
265
  * LLM response arrives.
239
266
  */
240
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();
241
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;
242
301
  /** Public accessor so UI/clients can read the resolved per-model window. */
243
302
  get maxContextTokens() {
244
303
  return this.resolveMaxContextTokens();
@@ -247,6 +306,27 @@ export class Engine {
247
306
  const modelEntry = this.modelPool.get();
248
307
  return modelEntry?.maxContextTokens ?? this.config.maxContextTokens ?? 200_000;
249
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
+ }
250
330
  /**
251
331
  * Emit a lifecycle hook with isSubAgent auto-merged into data so handlers
252
332
  * can skip noisy injections for spawned children. All Engine-side hook
@@ -278,6 +358,10 @@ export class Engine {
278
358
  }
279
359
  const entries = settings.hooks ?? [];
280
360
  for (const entry of entries) {
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;
281
365
  const event = entry.event;
282
366
  const handler = async (ctx) => {
283
367
  if (!shellHookMatches(entry, ctx))
@@ -310,6 +394,19 @@ export class Engine {
310
394
  this.hooks.unregister(event, handler);
311
395
  }
312
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
+ }
313
410
  // Force the next get() to re-read disk so reloaded hooks reflect the
314
411
  // newest settings.json, not a stale merged cache.
315
412
  try {
@@ -343,13 +440,15 @@ export class Engine {
343
440
  // per-turn path can only hide, not add, so a freshly-`on`'d builtin not in
344
441
  // the set needs a session restart to appear.
345
442
  const builtinLists = effectiveBuiltinLists(config.enabledBuiltinTools ?? [], config.disabledBuiltinTools ?? [], this.readBuiltinOverride(config.cwd));
346
- this.toolRegistry = config.runtime?.toolRegistry ?? new ToolRegistry({
347
- builtinTools: resolveBuiltinToolNames({
348
- preset: this.preset.name,
349
- enabledBuiltinTools: builtinLists.enabledBuiltinTools,
350
- disabledBuiltinTools: builtinLists.disabledBuiltinTools,
351
- }),
352
- });
443
+ this.toolRegistry =
444
+ config.runtime?.toolRegistry ??
445
+ new ToolRegistry({
446
+ builtinTools: resolveBuiltinToolNames({
447
+ preset: this.preset.name,
448
+ enabledBuiltinTools: builtinLists.enabledBuiltinTools,
449
+ disabledBuiltinTools: builtinLists.disabledBuiltinTools,
450
+ }),
451
+ });
353
452
  this.hooks = new HookRegistry();
354
453
  // Installed-plugin hooks — declared in each plugin's hooks/hooks.json.
355
454
  // Registered first (priority 80) so user-authored hooks at lower
@@ -362,7 +461,12 @@ export class Engine {
362
461
  // disabledPlugins suppresses a plugin's hooks too (not just its
363
462
  // Skill-tool entries) — see loadPluginHooks. readDisabledLists reads
364
463
  // the same settings the prompt composer / tool context use.
365
- loadPluginHooks(this.hooks, this.readDisabledLists().disabledPlugins);
464
+ // disabledPluginHooks is the per-hook overlay
465
+ // (capabilityOverrides.pluginHooks); applied at construction, so a
466
+ // toggle takes effect for NEW sessions (same semantics as
467
+ // disabledPlugins itself).
468
+ const { disabledPlugins, disabledPluginHooks } = this.readDisabledLists();
469
+ loadPluginHooks(this.hooks, disabledPlugins, disabledPluginHooks);
366
470
  }
367
471
  // settings.hooks → shell-command wrappers. Chain order:
368
472
  // plugin (80) → shell (50) → code (default 0).
@@ -378,7 +482,7 @@ export class Engine {
378
482
  }
379
483
  }
380
484
  /**
381
- * Load models[] / providers[] from settings into the active ModelPool and
485
+ * Load modelConnections[] from settings into the active ModelPool and
382
486
  * resync this.config.llm with the matching entry. Called from the ctor and
383
487
  * from reloadModelPool() (e.g. after onboarding writes new entries to disk).
384
488
  */
@@ -387,54 +491,45 @@ export class Engine {
387
491
  const sm = this.getSettingsManager();
388
492
  sm.invalidate();
389
493
  const settings = sm.get();
390
- if (settings.models?.length) {
391
- for (const m of settings.models) {
392
- this.modelPool.register({
393
- key: m.key,
394
- label: m.label,
395
- provider: m.provider ?? "",
396
- model: m.model,
397
- baseUrl: m.baseUrl,
398
- apiKey: m.apiKey,
399
- maxOutputTokens: m.maxOutputTokens,
400
- maxContextTokens: m.maxContextTokens,
401
- providerKey: m.providerKey,
402
- });
403
- }
404
- // Build catalog from settings.providers[] and attach to the pool
405
- // so model entries can resolve baseUrl/apiKey from their provider.
406
- if (settings.providers?.length) {
407
- this.modelPool.setProviderCatalog(new ProviderCatalog(settings.providers));
494
+ // Unified model catalog (统一模型接入方案 §6): register text
495
+ // connections from settings.modelConnections[] into the pool — the
496
+ // catalog-driven instance store is the sole source of model selection.
497
+ // A connection's instance id becomes its pool key. See
498
+ // docs/superpowers/specs/2026-06-15-unified-model-catalog-design.md.
499
+ const connections = settings.modelConnections;
500
+ if (Array.isArray(connections) && connections.length) {
501
+ const catalog = getMergedCatalog();
502
+ const credentials = settings.credentials;
503
+ for (const entry of modelEntriesFromConnections(connections, (Array.isArray(credentials) ? credentials : []), catalog)) {
504
+ this.modelPool.register(entry);
408
505
  }
506
+ }
507
+ const hasConnections = Array.isArray(connections) && connections.length > 0;
508
+ if (hasConnections) {
409
509
  this.modelPool.setCacheDir(defaultCacheDir());
410
510
  this.modelPool.reloadCachedContextWindows();
411
- // Resolve active entry. Priority:
412
- // 1. settings.activeKey primary source of truth (new shape).
413
- // 2. Match settings.model.name against models[].model legacy
414
- // pre-activeKey configs and the migration path.
415
- // We then switch the pool and write the resolved entry's credentials
416
- // into config.llm, so the first run() uses the right endpoint instead
417
- // of whatever env-derived fallback repl.ts seeded earlier.
418
- // Sub-agents skip the activeKey resync: their llm is chosen by the
419
- // parent's resolveChildLlm (per-role model routing). activeKey is the
420
- // *user's* current UI model selection and must not clobber a child's
421
- // routed model — without this guard a role's `model: flash` is silently
422
- // overridden back to whatever the user has active in the foreground.
511
+ // Resolve the active entry from settings.defaults.text, then switch the
512
+ // pool and write the resolved entry's credentials into config.llm, so the
513
+ // first run() uses the right endpoint instead of whatever env-derived
514
+ // fallback repl.ts seeded earlier.
515
+ // Sub-agents skip this resync: their llm is chosen by the parent's
516
+ // resolveChildLlm (per-role model routing). defaults.text is the *user's*
517
+ // current UI model selection and must not clobber a child's routed model —
518
+ // without this guard a role's `model: flash` is silently overridden back
519
+ // to whatever the user has active in the foreground.
423
520
  if (this.config.isSubAgent !== true) {
424
- const activeKey = settings.activeKey;
425
- let match;
426
- if (activeKey) {
427
- match = settings.models.find((m) => m.key === activeKey);
521
+ const defaultText = settings.defaults?.text;
522
+ // 统一 catalog only:defaults.text 命中则用;否则回退首个已注册连接,
523
+ // 避免选未配置模型时静默沿用空种子(旧 bug:抛误导性 OPENAI_API_KEY missing)
524
+ let matchKey;
525
+ if (defaultText && this.modelPool.list().some((e) => e.key === defaultText)) {
526
+ matchKey = defaultText;
428
527
  }
429
- if (!match) {
430
- const currentModel = this.config.llm.model;
431
- // OpenRouter stores entries as "provider/model-name"; the top-level
432
- // settings.model.name is just "model-name". Match either form.
433
- match = settings.models.find((m) => m.model === currentModel ||
434
- (currentModel && m.model?.endsWith(`/${currentModel}`)));
528
+ else {
529
+ matchKey = this.modelPool.list()[0]?.key;
435
530
  }
436
- if (match) {
437
- const entry = this.modelPool.switch(match.key);
531
+ if (matchKey) {
532
+ const entry = this.modelPool.switch(matchKey);
438
533
  this.config = {
439
534
  ...this.config,
440
535
  llm: this.modelPool.toLLMConfig(entry),
@@ -443,9 +538,10 @@ export class Engine {
443
538
  }
444
539
  }
445
540
  else if (this.config.llm.apiKey) {
446
- // Auto-populate pool from the configured API key when models[] is empty.
447
- // This lets users who only set model.apiKey (without models[]) still
448
- // use /model to switch between the provider's available models.
541
+ // Auto-populate pool from the configured API key when no
542
+ // modelConnections[] are configured. This lets users who only have an
543
+ // env/seed API key still use /model to switch between the provider's
544
+ // available models.
449
545
  this.autoPopulatePool(this.config.llm.apiKey, this.config.llm.baseUrl);
450
546
  }
451
547
  // Carry image-attachment settings + sampling temperature into
@@ -457,8 +553,10 @@ export class Engine {
457
553
  const modelBlock = settings.model;
458
554
  const nextDefaults = { ...(this.config.clientDefaults ?? {}) };
459
555
  let defaultsChanged = false;
460
- if (imageSettings?.detail && nextDefaults.imageDetail !== imageSettings.detail) {
461
- nextDefaults.imageDetail = imageSettings.detail;
556
+ // Migrate legacy "original" → "high" (raw settings may bypass schema).
557
+ const detail = imageSettings?.detail === "original" ? "high" : imageSettings?.detail;
558
+ if (detail && nextDefaults.imageDetail !== detail) {
559
+ nextDefaults.imageDetail = detail;
462
560
  defaultsChanged = true;
463
561
  }
464
562
  if (typeof modelBlock?.temperature === "number" &&
@@ -476,7 +574,7 @@ export class Engine {
476
574
  }
477
575
  /**
478
576
  * Re-read settings and refresh the model pool. Used after onboarding /login
479
- * writes new providers[] / models[] to disk so the running engine picks them
577
+ * writes new modelConnections[] to disk so the running engine picks them
480
578
  * up without a process restart. Existing pool entries are kept (re-registering
481
579
  * the same key overwrites them), so callers don't need to clear first.
482
580
  */
@@ -490,7 +588,7 @@ export class Engine {
490
588
  }
491
589
  }
492
590
  /**
493
- * Auto-populate the model pool when settings.models[] is empty but
591
+ * Auto-populate the model pool when no modelConnections[] are configured but
494
592
  * the user has configured an API key. Detects the provider from the
495
593
  * key prefix / baseUrl and registers all its known models.
496
594
  */
@@ -528,6 +626,94 @@ export class Engine {
528
626
  setAskUser(fn) {
529
627
  this.config.askUser = fn;
530
628
  }
629
+ /**
630
+ * Inject the browser automation bridge after construction (same chicken-and-egg
631
+ * as setAskUser: the desktop host builds the bridge — which drives a webview —
632
+ * after the Engine exists). Undefined → the browser_* tools degrade with a
633
+ * clear "no browser panel" error.
634
+ */
635
+ setBrowserBridge(bridge) {
636
+ this.config.browserBridge = bridge;
637
+ }
638
+ /**
639
+ * Queue a user message to be spliced into the in-flight run for `sessionId`
640
+ * at the next turn-loop step boundary — the 不打断 steering path (vs cancel +
641
+ * resend). General-purpose: any host path (UI 引导, future agent coordination,
642
+ * external triggers) can call it. If no run is active for this session, reject
643
+ * without queueing so the host can downgrade to a normal run immediately.
644
+ * No-op on blank text.
645
+ *
646
+ * `id` is the host's stable queue-entry id. It rides through to the
647
+ * `steer_injected` event (so the host can match the injected bubble back to
648
+ * the queued draft) and is the handle `unsteer` uses to revoke a still-pending
649
+ * entry. A blank id is tolerated but means the entry can't be revoked.
650
+ */
651
+ enqueueSteer(sessionId, text, id = "", clientMessageId) {
652
+ const q = this.steerQueueBySid.get(sessionId) ?? [];
653
+ const entryId = id || `steer-${q.length}`;
654
+ if (!sessionId)
655
+ return { accepted: false, id: entryId };
656
+ const activeRunSessionId = this.activeRunSession?.state.sessionId;
657
+ const active = this.activeTurnLoop !== null && activeRunSessionId === sessionId;
658
+ if (!active) {
659
+ logger.info("steer.enqueue.idle_rejected", {
660
+ sessionId,
661
+ id: entryId,
662
+ clientMessageId,
663
+ activeRunSessionId: activeRunSessionId ?? null,
664
+ queueLength: q.length,
665
+ });
666
+ return { accepted: false, id: entryId };
667
+ }
668
+ const next = enqueueSteerItem(q, entryId, text, clientMessageId);
669
+ if (next === q)
670
+ return { accepted: false, id: entryId }; // blank text dropped
671
+ this.steerQueueBySid.set(sessionId, next);
672
+ logger.info("steer.enqueue.accepted", {
673
+ sessionId,
674
+ id: entryId,
675
+ clientMessageId,
676
+ activeRunSessionId,
677
+ queueLength: next.length,
678
+ });
679
+ return { accepted: true, id: entryId };
680
+ }
681
+ /**
682
+ * Revoke a still-pending steer entry (the 撤回 path). Returns true if it was
683
+ * removed, false if it was already consumed by the turn loop (can't take it
684
+ * back — it has been spliced into the run).
685
+ */
686
+ unsteer(sessionId, id) {
687
+ const q = this.steerQueueBySid.get(sessionId);
688
+ if (!q || q.length === 0)
689
+ return false;
690
+ const { list, removed } = removeSteerItem(q, id);
691
+ if (removed)
692
+ this.steerQueueBySid.set(sessionId, list);
693
+ return removed;
694
+ }
695
+ /** Drain + clear the steer queue for a session (turn loop consumes per step). */
696
+ consumeSteer(sessionId, source = "normal_step") {
697
+ const q = this.steerQueueBySid.get(sessionId);
698
+ if (!q || q.length === 0)
699
+ return [];
700
+ const { drained, rest } = consumeSteerItems(q);
701
+ this.steerQueueBySid.set(sessionId, rest);
702
+ logger.info("steer.consume.drained", {
703
+ sessionId,
704
+ source,
705
+ count: drained.length,
706
+ ids: drained.map((item) => item.id),
707
+ clientMessageIds: drained.flatMap((item) => item.clientMessageId ? [item.clientMessageId] : []),
708
+ queueLength: rest.length,
709
+ });
710
+ return drained;
711
+ }
712
+ /** Wire the cookie→browser injection callback (InjectCredential tool). Same
713
+ * post-construction injection model as setBrowserBridge. */
714
+ setInjectCredential(fn) {
715
+ this.config.injectCredentialToBrowser = fn;
716
+ }
531
717
  /**
532
718
  * Whether this engine runs unattended (no interactive human). Used by the
533
719
  * in-process AgentServer to decide whether to wire an interactive askUser.
@@ -535,11 +721,34 @@ export class Engine {
535
721
  isHeadless() {
536
722
  return this.config.headless === true;
537
723
  }
724
+ /**
725
+ * Probe whether a session already exists on disk (its state/transcript dir is
726
+ * present). Used by the protocol server to distinguish "resume an existing
727
+ * session" from "silently create a fresh empty one" — e.g. a cron resume job
728
+ * whose target session the user deleted must fail loudly, not run its prompt
729
+ * against a blank session. A stat probe, not a load.
730
+ */
731
+ sessionExistsOnDisk(sessionId) {
732
+ return this.sessionManager.exists(sessionId);
733
+ }
538
734
  /**
539
735
  * Run a task from start to finish.
540
736
  */
541
737
  async run(task, options) {
542
- const cwd = options?.cwd ?? this.config.cwd ?? process.cwd();
738
+ // When the caller omits cwd but is resuming an existing session, recover
739
+ // that session's bound cwd from disk so a project-bound session keeps
740
+ // loading its own agents/settings/memory even if the host's UI repo
741
+ // selection has drifted to null. Only probe on omission — an explicit cwd
742
+ // always wins, and a fresh session has nothing to recover.
743
+ const sessionCwd = options?.cwd === undefined && options?.sessionId
744
+ ? this.sessionManager.readCwd(options.sessionId)
745
+ : undefined;
746
+ const cwd = resolveRunCwd({
747
+ optionCwd: options?.cwd,
748
+ sessionCwd,
749
+ configCwd: this.config.cwd,
750
+ processCwd: process.cwd(),
751
+ });
543
752
  // Wrap the caller's onStream so we can intercept `task_update`
544
753
  // events emitted by TodoWrite and keep an in-engine snapshot.
545
754
  // TaskGuard reads this snapshot at turn end to decide whether to
@@ -701,6 +910,22 @@ export class Engine {
701
910
  permissionMode: this.config.permissionMode ?? "acceptEdits",
702
911
  }),
703
912
  spawn: async (req) => {
913
+ // Anchor this sub-agent in the PARENT transcript at spawn time — before
914
+ // it runs, so it's recorded whether it later completes, is interrupted,
915
+ // or still runs. Replay reads these anchors to rebuild sub-agent cards
916
+ // from sessions/<agentId>/ (agentId === childSid); without it a
917
+ // backgrounded sub-agent leaves no parent-transcript trace and vanishes
918
+ // on reopen. Only on a fresh spawn (not a resume/continuation, which
919
+ // already has its anchor). Guarded so a transcript hiccup never breaks
920
+ // the spawn.
921
+ if (!req.resumeSessionId) {
922
+ try {
923
+ session.transcript.appendSubagent(req.agentId, undefined, req.description);
924
+ }
925
+ catch {
926
+ /* anchor is best-effort; never block the spawn */
927
+ }
928
+ }
704
929
  // No nested agents. Strip Agent / AgentStatus / AgentCancel from the
705
930
  // child's tool pool so the LLM can't spawn grandchildren — matches
706
931
  // Claude Code's ALL_AGENT_DISALLOWED_TOOLS approach. Without this
@@ -722,9 +947,8 @@ export class Engine {
722
947
  enabledBuiltinTools: childEnabled,
723
948
  disabledBuiltinTools: childDisabled,
724
949
  customSystemPrompt: this.config.customSystemPrompt,
725
- appendSystemPrompt: [this.config.appendSystemPrompt, req.appendSystemPrompt]
726
- .filter(Boolean)
727
- .join("\n\n") || undefined,
950
+ appendSystemPrompt: [this.config.appendSystemPrompt, req.appendSystemPrompt].filter(Boolean).join("\n\n") ||
951
+ undefined,
728
952
  responseLanguage: this.config.responseLanguage,
729
953
  userProfile: this.config.userProfile,
730
954
  instructions: this.config.instructions,
@@ -733,6 +957,7 @@ export class Engine {
733
957
  sessionStorageDir: this.config.sessionStorageDir,
734
958
  headless: this.config.headless,
735
959
  readOnlySession: req.readOnlySession,
960
+ skillAllowlist: req.skillAllowlist,
736
961
  sandbox: this.config.sandbox,
737
962
  // Subagents inherit the parent's scope: a child runs in the same
738
963
  // cwd/session, so it should see the same config layers the parent did.
@@ -770,12 +995,41 @@ export class Engine {
770
995
  // child.run() establishes its own runWithSid scope internally, so
771
996
  // child log lines route to the child's sid and parent's ALS
772
997
  // binding is unaffected when control returns here.
773
- const result = await child.run(req.prompt, { signal: req.signal, onStream: childStream });
774
- return result.text;
998
+ //
999
+ // agent_id === childSid: cold-start the child UNDER its agentId as the
1000
+ // session id (run() shape (2): a fresh sid the host wants materialized),
1001
+ // so the session persists at sessions/<agentId>/ and AgentSendInput can
1002
+ // later resume it by agentId with no extra id→sid mapping. When
1003
+ // resumeSessionId is set we resume that existing session instead —
1004
+ // run() detects the on-disk session and replays its full transcript
1005
+ // (the CC continuation model; see AgentSendInput).
1006
+ const childSessionId = req.resumeSessionId ?? req.agentId;
1007
+ const result = await child.run(req.prompt, {
1008
+ signal: req.signal,
1009
+ onStream: childStream,
1010
+ sessionId: childSessionId,
1011
+ });
1012
+ return { text: result.text, sessionId: result.sessionId };
775
1013
  },
1014
+ sessionExists: (sessionId) => this.sessionManager.exists(sessionId),
776
1015
  };
777
- const sandboxConfig = this.config.sandbox ??
778
- defaultSandboxConfig(this.config.headless ? "auto" : "off");
1016
+ // Priority: config.sandbox → project settings.sandbox → global → per-run
1017
+ // default. Read UNMERGED per-scope (getForScope) so a project that wrote no
1018
+ // sandbox genuinely follows global, rather than inheriting global's mode and
1019
+ // looking like it set one. Fixes "项目级配了不生效" + the scope model.
1020
+ let projectSandbox;
1021
+ let globalSandbox;
1022
+ try {
1023
+ const sm = this.getSettingsManager();
1024
+ if (this.config.isSubAgent !== true) {
1025
+ projectSandbox = sm.getForScope("project", cwd).sandbox;
1026
+ }
1027
+ globalSandbox = sm.getForScope("user").sandbox;
1028
+ }
1029
+ catch {
1030
+ // settings unavailable → fall through to per-run default
1031
+ }
1032
+ const sandboxConfig = resolveSandboxConfig(this.config.sandbox, projectSandbox, globalSandbox, this.config.headless === true);
779
1033
  // A2: explicit sandbox modes (seatbelt, bwrap) must fail closed
780
1034
  // per standard §S4. resolveSandboxBackend throws when an explicit
781
1035
  // mode is unavailable on this host; we let it propagate. The
@@ -789,6 +1043,17 @@ export class Engine {
789
1043
  const sandboxBackend = this.runtime
790
1044
  ? await this.runtime.resolveSandbox(sandboxConfig, cwd)
791
1045
  : await this.resolveSandboxWithoutRuntime(sandboxConfig, cwd);
1046
+ // Observability: surface what sandbox actually applied this run — the
1047
+ // configured mode vs the resolved backend (auto may downgrade to off when
1048
+ // no OS backend is available) + the network policy. Without this you can't
1049
+ // tell whether shell commands were isolated /网络放没放. One line per run.
1050
+ logger.info("sandbox.resolved", {
1051
+ mode: sandboxConfig.mode,
1052
+ backend: sandboxBackend.name,
1053
+ isolated: sandboxBackend.name !== "off",
1054
+ network: sandboxConfig.network,
1055
+ cwd,
1056
+ });
792
1057
  // sessionId is filled in after the session bundle is resolved below
793
1058
  // (the session may be cold-started or resumed). Until then this is
794
1059
  // intentionally shaped as a mutable local; we treat it as immutable
@@ -797,7 +1062,13 @@ export class Engine {
797
1062
  ...this.buildToolContext(),
798
1063
  subAgentSpawner,
799
1064
  agentDefinitions: this.getAgentDefinitions(cwd),
800
- sandbox: sandboxBackend,
1065
+ // Stamp the resolved network policy onto the backend the tools see so
1066
+ // Bash can surface "网络 deny" on its result. Shallow-copy (don't mutate
1067
+ // the cached backend) — `wrap`/`hintForBlockedOutput` are plain function
1068
+ // properties and survive the spread. Off keeps network undefined.
1069
+ sandbox: sandboxBackend.name === "off"
1070
+ ? sandboxBackend
1071
+ : { ...sandboxBackend, network: sandboxConfig.network },
801
1072
  cwd,
802
1073
  // TodoWrite reads this to push task_update events independently
803
1074
  // of its return value, so the UI's pinned task panel refreshes
@@ -818,10 +1089,24 @@ export class Engine {
818
1089
  // text block (when prose is present) followed by one image block per
819
1090
  // attachment — the provider-specific clients translate this to OpenAI
820
1091
  // `image_url` or Anthropic `{type:image, source:base64}` downstream.
1092
+ // When an attached image came from a workspace FILE (the desktop composer's
1093
+ // path-attach flow sets ParsedImage.name = the absolute path), surface that
1094
+ // path to the model as text. The image bytes still ride along for vision,
1095
+ // but tools that operate on files — GenerateImage(referenceImages),
1096
+ // Read, etc. — need the on-disk path, not just the pixels. Without this the
1097
+ // path the composer already knew was silently dropped, and the model would
1098
+ // answer "图片没落到项目文件夹,找不到路径" (the seedance 图生图 dead-end).
1099
+ // Only names that resolve to an existing file qualify; a pasted screenshot
1100
+ // whose name is just "screenshot.png" is not a path and is left out.
1101
+ const attachedPaths = collectAttachedImagePaths(parsedTask.images, (name) => (isAbsolute(name) ? name : join(cwd, name)), existsSync);
1102
+ const pathHint = attachedPaths.length > 0
1103
+ ? `\n\n<attached-image-paths>\n${attachedPaths.join("\n")}\n</attached-image-paths>\n` +
1104
+ `(上面附带的图片在工作区的真实路径,如需把它们作为工具输入(例如 GenerateImage 的 referenceImages、图生图参考图),直接使用这些路径。)`
1105
+ : "";
821
1106
  const userMessageContent = parsedTask.hasImages
822
1107
  ? [
823
- ...(parsedTask.text
824
- ? [{ type: "text", text: parsedTask.text }]
1108
+ ...(parsedTask.text || pathHint
1109
+ ? [{ type: "text", text: `${parsedTask.text}${pathHint}` }]
825
1110
  : []),
826
1111
  ...parsedTask.images.map((img) => ({
827
1112
  type: "image",
@@ -848,6 +1133,23 @@ export class Engine {
848
1133
  // call) instead of a try/catch on resume.
849
1134
  let session;
850
1135
  let messages;
1136
+ let freshImageMessage;
1137
+ const claimedClientMessageIds = new Set();
1138
+ const claimClientMessageId = (bundle, clientMessageId, source) => {
1139
+ if (!clientMessageId)
1140
+ return true;
1141
+ if (claimedClientMessageIds.has(clientMessageId) ||
1142
+ bundle.transcript.hasClientMessageId(clientMessageId)) {
1143
+ logger.info("engine.client_message.duplicate_ignored", {
1144
+ sessionId: bundle.state.sessionId,
1145
+ clientMessageId,
1146
+ source,
1147
+ });
1148
+ return false;
1149
+ }
1150
+ claimedClientMessageIds.add(clientMessageId);
1151
+ return true;
1152
+ };
851
1153
  if (options?.sessionId && this.sessionManager.exists(options.sessionId)) {
852
1154
  session = this.sessionManager.resume(options.sessionId);
853
1155
  const cachedCompacted = this.compactedMessagesBySession.get(options.sessionId);
@@ -871,8 +1173,31 @@ export class Engine {
871
1173
  }
872
1174
  // Append new user message
873
1175
  const userMsg = { role: "user", content: userMessageContent };
1176
+ if (!claimClientMessageId(session, options?.clientMessageId, "submit")) {
1177
+ const usage = session.state.tokenUsage ?? {
1178
+ promptTokens: 0,
1179
+ completionTokens: 0,
1180
+ totalTokens: 0,
1181
+ };
1182
+ return {
1183
+ text: "",
1184
+ reason: "completed",
1185
+ sessionId: session.state.sessionId,
1186
+ turnCount: session.state.turnCount ?? 0,
1187
+ usage: {
1188
+ promptTokens: usage.promptTokens ?? 0,
1189
+ completionTokens: usage.completionTokens ?? 0,
1190
+ totalTokens: usage.totalTokens ?? 0,
1191
+ },
1192
+ };
1193
+ }
1194
+ if (parsedTask.hasImages)
1195
+ freshImageMessage = userMsg;
874
1196
  messages.push(userMsg);
875
- session.transcript.appendMessage("user", userMessageContent);
1197
+ session.transcript.appendMessage("user", userMessageContent, {
1198
+ injected: options?.injected === true,
1199
+ clientMessageId: options?.clientMessageId,
1200
+ });
876
1201
  // Flush "active" status to disk immediately. resume() set it in memory
877
1202
  // (session-manager.ts), but without this write the on-disk state.json
878
1203
  // still shows the previous run's terminal reason — so any external
@@ -884,17 +1209,30 @@ export class Engine {
884
1209
  // Cold start: shape (2) reuses the host-supplied sid; shape (3)
885
1210
  // lets sessionManager generate one with nanoid.
886
1211
  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);
887
- messages = [{ role: "user", content: userMessageContent }];
888
- session.transcript.appendMessage("user", userMessageContent);
1212
+ const userMsg = { role: "user", content: userMessageContent };
1213
+ claimClientMessageId(session, options?.clientMessageId, "submit");
1214
+ if (parsedTask.hasImages)
1215
+ freshImageMessage = userMsg;
1216
+ messages = [userMsg];
1217
+ session.transcript.appendMessage("user", userMessageContent, {
1218
+ clientMessageId: options?.clientMessageId,
1219
+ });
889
1220
  // Save first user message as session summary — text only. The summary
890
1221
  // shows up in the session list; "[image]" is more informative than a
891
1222
  // truncated `[object Object]` when the prompt was purely visual.
892
1223
  const summarySrc = parsedTask.hasImages
893
- ? parsedTask.text || `[image${parsedTask.images.length > 1 ? `s × ${parsedTask.images.length}` : ""}]`
1224
+ ? parsedTask.text ||
1225
+ `[image${parsedTask.images.length > 1 ? `s × ${parsedTask.images.length}` : ""}]`
894
1226
  : taskText;
895
1227
  session.state.summary = summarySrc.slice(0, 80).replace(/\n/g, " ");
896
1228
  this.sessionManager.saveState(session.state);
897
1229
  }
1230
+ // Bump the conversation-turn counter: this user message starts a new turn.
1231
+ // One user message = one turn, regardless of how many turn-loop iterations
1232
+ // or tool calls it spans. File-history snapshots taken below are tagged
1233
+ // with this value so `/undo` reverts exactly this turn's file changes.
1234
+ // (Both resume and cold-start paths converge here.)
1235
+ session.state.turnSeq = (session.state.turnSeq ?? 0) + 1;
898
1236
  // Stamp the resolved session id for downstream logging.
899
1237
  //
900
1238
  // `setCurrentSid` updates the module-level fallback so any code path
@@ -1043,6 +1381,9 @@ export class Engine {
1043
1381
  toolExecutor.setContext(toolCtx);
1044
1382
  const contextManager = new ContextManager({
1045
1383
  maxTokens: this.resolveMaxContextTokens(),
1384
+ // Drop undefined fields so they don't clobber ContextManager defaults
1385
+ // (spread of `{x: undefined}` would override the default with undefined).
1386
+ ...Object.fromEntries(Object.entries(this.resolveContextRatios()).filter(([, v]) => v !== undefined)),
1046
1387
  });
1047
1388
  this.lastContextManager = contextManager;
1048
1389
  const { disabledSkills, disabledPlugins } = this.readDisabledLists();
@@ -1057,6 +1398,14 @@ export class Engine {
1057
1398
  instructionOptions: { compatFileNames: compatFileNamesFrom(this.config.instructions) },
1058
1399
  disabledSkills,
1059
1400
  disabledPlugins,
1401
+ skillAllowlist: this.config.skillAllowlist,
1402
+ memoriesMaxAgeDays: this.readMemoriesConfig()?.maxAge,
1403
+ goalToolState: {
1404
+ hasGoal: this.config.isSubAgent !== true &&
1405
+ (normalizeGoal(options?.goal) !== undefined ||
1406
+ session.state.activeGoal !== undefined ||
1407
+ normalizeGoal(this.config.goal) !== undefined),
1408
+ },
1060
1409
  });
1061
1410
  // Connect MCP servers (if configured and not already connected).
1062
1411
  // B1: prefer the Runtime-owned MCPManager so all sessions in a
@@ -1071,7 +1420,7 @@ export class Engine {
1071
1420
  else {
1072
1421
  this.mcpManager = new MCPManager(this.toolRegistry);
1073
1422
  }
1074
- await this.mcpManager.connectAll(mcpServers);
1423
+ await this.mcpManager.connectAll(mcpServers, this);
1075
1424
  }
1076
1425
  // Parallelize slow initialization:
1077
1426
  // 1. createLLMClient — network handshake (started earlier)
@@ -1092,6 +1441,14 @@ export class Engine {
1092
1441
  // cwd. Recomputed every message, so configuring a key takes effect on the
1093
1442
  // NEXT message without a restart. Tools with no guard entry are always kept.
1094
1443
  const guardCwd = toolCtx.cwd;
1444
+ const toolVisibility = {
1445
+ cwd: guardCwd,
1446
+ hasGoal: this.config.isSubAgent !== true &&
1447
+ (normalizeGoal(options?.goal) !== undefined ||
1448
+ session.state.activeGoal !== undefined ||
1449
+ normalizeGoal(this.config.goal) !== undefined),
1450
+ };
1451
+ toolCtx.toolVisibility = toolVisibility;
1095
1452
  // #7: per-turn project builtin override. The toolRegistry's builtin tool
1096
1453
  // SET is ctor-frozen (and may be shared via runtime), so a mid-session
1097
1454
  // project override of a builtin can't rebuild the registry. But the tool
@@ -1114,14 +1471,45 @@ export class Engine {
1114
1471
  const disabledBuiltins = new Set(Object.keys(builtinOverride).filter((name) => builtinOverride[name] === "off" && registryNames.has(name)));
1115
1472
  toolCtx.disabledBuiltins = disabledBuiltins;
1116
1473
  }
1474
+ // MCP tool exposure is per-SESSION even though the pool/registry are
1475
+ // worker-shared (B1): a server connected by another project's session
1476
+ // registers its tools into the SHARED registry, and without this filter
1477
+ // they leaked into every session (e.g. chrome-devtools tools showing up
1478
+ // in a project that never enabled the plugin). Keep an MCP tool only when
1479
+ // its server is in THIS session's merged config.mcpServers — which
1480
+ // already folds the project's capabilityOverrides. Gated on the config
1481
+ // being present: engines without one (sub-agents, bare tests) have no
1482
+ // MCP tools in their private registries anyway.
1483
+ const allowedMcpServers = new Set(Object.entries(this.config.mcpServers ?? {})
1484
+ .filter(([, c]) => c.enabled !== false)
1485
+ .map(([n]) => n));
1486
+ toolCtx.allowedMcpServers = allowedMcpServers;
1487
+ const mcpVisible = (toolName) => {
1488
+ const reg = this.toolRegistry.getTool(toolName);
1489
+ return reg?.source !== "mcp" || allowedMcpServers.has(reg?.serverName ?? "");
1490
+ };
1491
+ // Feature-flag visibility: a builtin mapped in TOOL_FEATURE_FLAGS is
1492
+ // hidden when its flag resolves to false (default-on flags only hide when
1493
+ // explicitly disabled, so zero regression out of the box). Read once per
1494
+ // turn so flipping a flag in settings takes effect on the NEXT message,
1495
+ // like the other capability kinds.
1496
+ const featureFlags = this.readFeatureFlags();
1117
1497
  const allToolDefs = applyBuiltinOverrideVisibility(this.toolRegistry.getToolDefinitions(), builtinOverride)
1498
+ .filter((t) => mcpVisible(t.name))
1118
1499
  .filter((t) => {
1119
1500
  const guard = BUILTIN_TOOL_GUARDS.get(t.name);
1120
- return guard ? guard(guardCwd) : true;
1501
+ return guard ? guard(toolVisibility) : true;
1502
+ })
1503
+ .filter((t) => {
1504
+ const flag = TOOL_FEATURE_FLAGS.get(t.name);
1505
+ return flag ? isFeatureEnabled(featureFlags, flag) : true;
1121
1506
  })
1122
- .map((t) => t.name === "Agent"
1123
- ? { ...t, description: agentToolDefWithTypes(toolCtx.agentDefinitions).description }
1124
- : t);
1507
+ // Dynamic per-engine bits the static defs can't carry: the Agent tool's
1508
+ // agent_type enum + listing, and the image/video provider names. See
1509
+ // applyDynamicToolDef — forwarding only the Agent description (dropping
1510
+ // its rebuilt inputSchema) used to strip the agent_type enum, so the
1511
+ // model omitted agent_type and configured roles never applied.
1512
+ .map((t) => applyDynamicToolDef(t, toolCtx.agentDefinitions, guardCwd));
1125
1513
  // In plan mode, only expose read-only/planning tools so the model won't
1126
1514
  // attempt writes. Shared with executor.ts's execution gate via
1127
1515
  // PLAN_MODE_ALLOWED_TOOLS so what the model SEES and what the executor
@@ -1130,12 +1518,13 @@ export class Engine {
1130
1518
  const toolDefs = this.planMode
1131
1519
  ? allToolDefs.filter((t) => PLAN_MODE_ALLOWED_TOOLS.has(t.name))
1132
1520
  : allToolDefs;
1133
- const [llmClient, systemPrompt, systemContext] = await Promise.all([
1521
+ const [llmClient, fullSystemPrompt, dynamicContextMsg] = await Promise.all([
1134
1522
  llmClientPromise,
1523
+ // System prompt is now the STABLE prefix only — skills + git status moved
1524
+ // out to a trailing per-turn message so they no longer bust the cache.
1135
1525
  promptComposer.buildSystemPrompt(toolDefs),
1136
- promptComposer.buildSystemContext(),
1526
+ promptComposer.buildDynamicContextMessage(),
1137
1527
  ]);
1138
- const fullSystemPrompt = [systemPrompt, systemContext].filter(Boolean).join("\n\n");
1139
1528
  // Prepend userContext (CLAUDE.md) as first message (sync, fast)
1140
1529
  const userContextMsg = promptComposer.buildUserContextMessage();
1141
1530
  if (userContextMsg) {
@@ -1154,6 +1543,12 @@ export class Engine {
1154
1543
  // reminder → user request.
1155
1544
  messages.splice(messages.length - 1, 0, lifecycleReminder);
1156
1545
  }
1546
+ // Volatile context (skills + git status) goes at the very END — after the
1547
+ // user task — so it sits past the conversation's cache breakpoint. A change
1548
+ // here (new skill, edited file) never invalidates the cached history prefix.
1549
+ if (dynamicContextMsg) {
1550
+ messages.push(dynamicContextMsg);
1551
+ }
1157
1552
  this.lastSessionId = session.state.sessionId;
1158
1553
  this.lastMessages = messages;
1159
1554
  // Wire up LLM summarization for context compaction
@@ -1165,28 +1560,34 @@ export class Engine {
1165
1560
  // run would be evaluated fresh and might get a different replacement
1166
1561
  // string than the one already in the message, breaking idempotency.
1167
1562
  contextManager.initReplacementStateFromMessages(messages);
1168
- // Summarization (context-compaction + tool-result summaries) are auxiliary
1169
- // calls — route them to the configured aux model so they don't burn the
1170
- // expensive primary model every turn (same rationale as runMemoryPipeline).
1171
- // Resolved once here (not per-call) so the magnetic-disk settings re-read
1172
- // in resolveAuxClient stays off the compaction hot path. Falls back to the
1173
- // primary client when no aux model is configured.
1563
+ // Two summarizers with DIFFERENT quality needs:
1564
+ //
1565
+ // 1. Context-compaction summary (setSummarizeFn) PRIMARY model. This
1566
+ // condenses many rounds into the running summary that REPLACES the real
1567
+ // history; a dropped decision makes the conversation "forget" and poisons
1568
+ // every subsequent turn. It fires only near the compact ratio (~0.85), so
1569
+ // it's infrequent — quality far outweighs the occasional extra cost of a
1570
+ // primary-model call. (Manual /compact uses the primary for the same
1571
+ // reason; see forceCompact.)
1572
+ //
1573
+ // 2. Tool-use one-liner summaries (modelFacade.summarize below) → AUX model.
1574
+ // These are tiny throwaway outputs ("Wrote design doc") fired every turn;
1575
+ // that high-frequency, low-stakes chore is exactly what aux is for.
1174
1576
  const auxSummaryClient = await this.resolveAuxClient(llmClient);
1175
- contextManager.setSummarizeFn(async (prompt) => {
1176
- const summaryResponse = await auxSummaryClient.createMessage({
1177
- systemPrompt: "You are a conversation summarizer. Be concise and factual.",
1178
- messages: [{ role: "user", content: prompt }],
1179
- tools: [],
1180
- maxTokens: 1024,
1181
- // Auxiliary call — no need to burn reasoning tokens. On DeepSeek V4
1182
- // this flips thinking off (~3x faster, fewer tokens); on every other
1183
- // OpenAI-compatible provider the field is ignored.
1184
- reasoning: { mode: "off" },
1185
- });
1186
- return summaryResponse.text;
1187
- });
1188
- // Create components (requires resolved llmClient)
1577
+ Object.assign(session.state, normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage));
1578
+ const recordCumulativeUsage = (usage) => {
1579
+ const next = addCumulativeUsage(session.state, usage);
1580
+ Object.assign(session.state, next);
1581
+ return next;
1582
+ };
1583
+ contextManager.setSummarizeFn(this.buildSummarizeFn(llmClient, recordCumulativeUsage));
1584
+ // Create components (requires resolved llmClient).
1189
1585
  const modelFacade = new ModelFacade(llmClient, session.transcript);
1586
+ // Session-cumulative usage baseline: the LLM client is recreated per run
1587
+ // (its getUsage() counts only THIS run), so to accumulate across runs we
1588
+ // capture the persisted total at run start and fold this run's usage onto
1589
+ // it (see foldRunUsage). Snapshot now, before any turn boundary fires.
1590
+ const usageBaseline = { ...session.state.tokenUsage };
1190
1591
  // Wire getOutputTokens for token budget tracking
1191
1592
  modelFacade.getOutputTokens = () => {
1192
1593
  const usage = llmClient.getUsage();
@@ -1221,14 +1622,39 @@ export class Engine {
1221
1622
  // File history: auto-backup before Write/Edit
1222
1623
  const sessionDir = join(this.config.sessionStorageDir ?? join(userHome(), ".code-shell", "sessions"), session.state.sessionId);
1223
1624
  const fileHistory = FileHistory.loadFromDir(sessionDir);
1224
- this.hooks.register("on_tool_start", async (context) => {
1625
+ // Keep a reference so we can unregister in the finally below. Registering an
1626
+ // anonymous handler every run() leaks: unregister matches by handler
1627
+ // identity, so without a stored reference each run stacks another identical
1628
+ // on_tool_start handler that fires (and re-snapshots) on every tool forever.
1629
+ const fileHistoryHandler = async (context) => {
1225
1630
  const toolName = context.data?.toolName;
1226
1631
  const args = context.data?.args;
1632
+ // Tag snapshots with the current turn (stamped above before any tool
1633
+ // runs) so turn-level /undo can revert just this user message's edits.
1634
+ const turnSeq = session.state.turnSeq;
1227
1635
  if ((toolName === "Write" || toolName === "Edit") && args?.file_path) {
1228
- fileHistory.saveSnapshot(args.file_path);
1636
+ const path = args.file_path;
1637
+ // saveSnapshot returns null when the file does not exist yet — this
1638
+ // hook runs BEFORE the tool, so a null here means the turn is CREATING
1639
+ // the file. Record it (idempotent per turn) so /undo can delete it and
1640
+ // /redo can recreate it.
1641
+ if (fileHistory.saveSnapshot(path, turnSeq) === null && turnSeq !== undefined) {
1642
+ fileHistory.recordCreated(path, turnSeq);
1643
+ }
1644
+ }
1645
+ else if (toolName === "ApplyPatch" && typeof args?.patch === "string") {
1646
+ // ApplyPatch mutates files too, so /undo must see them. Snapshot every
1647
+ // existing file the patch updates or deletes (adds have no prior
1648
+ // content). Resolve relative patch paths against the engine cwd, the
1649
+ // same base ApplyPatch itself uses.
1650
+ const cwd = this.config.cwd ?? process.cwd();
1651
+ for (const target of patchBackupTargets(args.patch, cwd)) {
1652
+ fileHistory.saveSnapshot(target, turnSeq);
1653
+ }
1229
1654
  }
1230
1655
  return {};
1231
- }, 100, "file_history_backup");
1656
+ };
1657
+ this.hooks.register("on_tool_start", fileHistoryHandler, 100, "file_history_backup");
1232
1658
  // Hook: agent start
1233
1659
  await this.emitHook("on_agent_start", {
1234
1660
  sessionId: session.state.sessionId,
@@ -1238,20 +1664,70 @@ export class Engine {
1238
1664
  // Goal mode: register a GoalStopHook for the lifetime of THIS run so the
1239
1665
  // turn loop keeps going until the session model judges the goal met.
1240
1666
  // Registered per-run (and cleared in `finally`) so a later goal-less
1241
- // send doesn't inherit a stale goal. The judge reuses `llmClient` — the
1242
- // same model this session is talking to (per design).
1667
+ // send doesn't inherit a stale goal. The judge runs on `auxSummaryClient`
1668
+ // — the same cheap aux model used for summarize/compaction not the
1669
+ // (potentially expensive) session model: "is this goal met?" is a classic
1670
+ // aux-tier task, and a goal run can invoke the judge up to maxStopBlocks
1671
+ // times.
1243
1672
  // Normalize the raw goal (string | GoalConfig) once at the run boundary;
1244
1673
  // everything inward uses the GoalConfig. normalizeGoal() returns undefined
1245
1674
  // when there's effectively no goal (empty objective).
1246
- const normalizedGoal = normalizeGoal(options?.goal ?? this.config.goal);
1675
+ //
1676
+ // PERSISTENT GOAL (CC /goal style): a goal set on one send survives across
1677
+ // later sends and manual interrupts until met or cleared. Resolution:
1678
+ // 1. options.goal — this send explicitly sets/replaces the goal.
1679
+ // 2. session.state.activeGoal — a goal set on an earlier send.
1680
+ // 3. config.goal — engine-level default (rare; e.g. headless).
1681
+ // When (1) supplies a goal that differs from the stored one we REPLACE the
1682
+ // persisted active goal (one active goal per session) and announce it. A
1683
+ // bare send with no options.goal inherits the stored active goal so the
1684
+ // model keeps working toward it — that's what makes it persistent.
1685
+ const explicitGoal = normalizeGoal(options?.goal);
1686
+ const storedGoal = this.config.isSubAgent !== true ? session.state.activeGoal : undefined;
1687
+ if (explicitGoal && this.config.isSubAgent !== true) {
1688
+ const replaced = !!storedGoal && storedGoal.objective !== explicitGoal.objective;
1689
+ // Stamp WHEN this goal was set so the judge can anchor relative deadlines
1690
+ // ("做到3点") to the set time, not "now" — else once the clock passes the
1691
+ // deadline the judge could read "3点" as tomorrow's and never stop. A new
1692
+ // or changed objective gets a fresh stamp; re-sending the SAME objective
1693
+ // keeps the original anchor (the goal continues, the user didn't restate a
1694
+ // new deadline). User input never carries setAtMs, so we set it here.
1695
+ explicitGoal.setAtMs = resolveGoalSetAt(explicitGoal.objective, storedGoal, Date.now());
1696
+ session.state.activeGoal = explicitGoal;
1697
+ this.sessionManager.saveState(session.state);
1698
+ options?.onStream?.({
1699
+ type: "goal_set",
1700
+ objective: explicitGoal.objective,
1701
+ replaced,
1702
+ });
1703
+ }
1704
+ const normalizedGoal = explicitGoal ?? storedGoal ?? normalizeGoal(this.config.goal);
1247
1705
  let goalHookHandler = null;
1248
1706
  if (normalizedGoal && this.config.isSubAgent !== true) {
1249
1707
  goalHookHandler = createGoalStopHook({
1250
1708
  goal: normalizedGoal,
1251
- llm: llmClient,
1709
+ llm: auxSummaryClient,
1252
1710
  log: logger,
1711
+ // Clear the persisted active goal the moment the judge says it's met,
1712
+ // so a later bare send doesn't re-inherit a satisfied goal. The hook
1713
+ // calls this from inside its met branch (single source of truth for
1714
+ // "goal achieved"); engine owns the persistence side-effect.
1715
+ onMet: () => {
1716
+ if (session.state.activeGoal) {
1717
+ session.state.activeGoal = undefined;
1718
+ this.sessionManager.saveState(session.state);
1719
+ }
1720
+ },
1721
+ // Re-read the persisted goal each turn so a mid-run 清除 (clearGoal
1722
+ // wrote state.json but this hook's frozen goal copy + the closure's
1723
+ // in-RAM session are untouched) actually stops the judge. Reads disk
1724
+ // via readActiveGoal — authoritative and independent of which session
1725
+ // instance the run closure holds.
1726
+ isGoalActive: (sid) => this.sessionManager.readActiveGoal(sid) !== undefined,
1253
1727
  });
1254
1728
  this.hooks.register("on_stop", goalHookHandler, 0, "goal-stop");
1729
+ // Expose for clearGoal() mid-run. Already guarded by isSubAgent above.
1730
+ this.activeGoalHook = goalHookHandler;
1255
1731
  }
1256
1732
  // Surface compaction events to the UI so the user knows when context was trimmed.
1257
1733
  // Buffer the most recent event so TurnLoop can drain it and emit the
@@ -1278,6 +1754,24 @@ export class Engine {
1278
1754
  pendingCompactInfo = null;
1279
1755
  return info;
1280
1756
  },
1757
+ consumeSteer: (source) => this.consumeSteer(sid, source),
1758
+ claimClientMessageId: (clientMessageId, source) => claimClientMessageId(session, clientMessageId, source),
1759
+ recordCumulativeUsage,
1760
+ // Clear the persisted goal for a self-reported completion / confirmed
1761
+ // cancel. Clears the in-RAM session's activeGoal (so THIS run's later
1762
+ // turns don't re-arm) AND persists it, and drops the in-flight stop
1763
+ // hook so nothing re-blocks the stop we're about to return.
1764
+ clearPersistedGoal: () => {
1765
+ if (session.state.activeGoal !== undefined) {
1766
+ session.state.activeGoal = undefined;
1767
+ this.sessionManager.saveState(session.state);
1768
+ }
1769
+ if (goalHookHandler) {
1770
+ this.hooks.unregister("on_stop", goalHookHandler);
1771
+ if (this.activeGoalHook === goalHookHandler)
1772
+ this.activeGoalHook = null;
1773
+ }
1774
+ },
1281
1775
  ctxOverheadStore: {
1282
1776
  get: (s) => this.ctxOverheadBySid.get(s) ?? 0,
1283
1777
  set: (s, n) => {
@@ -1285,10 +1779,23 @@ export class Engine {
1285
1779
  },
1286
1780
  },
1287
1781
  }, {
1288
- maxTurns: this.config.maxTurns ?? 100,
1289
- maxToolCallsPerTurn: this.config.maxToolCallsPerTurn ?? 10,
1782
+ // Goal mode raises the turn ceiling: an unattended goal run keeps
1783
+ // getting re-blocked by the stop-hook until it's done, and the 100
1784
+ // interactive default would silently truncate a long objective. The
1785
+ // real backstops are the goal token/time budgets + maxStopBlocks.
1786
+ maxTurns: resolveMaxTurns(this.config.maxTurns, normalizedGoal),
1787
+ // Consecutive stop-block cap: config override > goal.maxStopBlocks >
1788
+ // GOAL_DEFAULT_MAX_STOP_BLOCKS(25). The old hardcoded 8 was too tight
1789
+ // for complex goals that legitimately get re-blocked while advancing.
1790
+ maxStopBlocks: resolveMaxStopBlocks(this.config.maxStopBlocks, normalizedGoal),
1791
+ // 25 (was 10): modern models routinely batch >10 parallel tool calls
1792
+ // (e.g. reading a dozen files at once). At 10 the excess was silently
1793
+ // dropped; the turn loop now also warns the model when it caps, but a
1794
+ // higher ceiling avoids the round-trip in the common case. (B-3)
1795
+ maxToolCallsPerTurn: this.config.maxToolCallsPerTurn ?? 25,
1290
1796
  onStream: options?.onStream,
1291
1797
  signal: options?.signal,
1798
+ freshImageMessages: freshImageMessage ? [freshImageMessage] : undefined,
1292
1799
  // Goal mode: the active goal is surfaced to the on_stop handler via
1293
1800
  // ctx.data.goal; the GoalStopHook (registered above) judges it.
1294
1801
  goal: normalizedGoal,
@@ -1298,62 +1805,106 @@ export class Engine {
1298
1805
  // completed run.
1299
1806
  onTurnBoundary: (turnCount) => {
1300
1807
  session.state.turnCount = turnCount;
1301
- const u = modelFacade.getUsage();
1302
- session.state.tokenUsage = {
1303
- promptTokens: u.totalPromptTokens,
1304
- completionTokens: u.totalCompletionTokens,
1305
- totalTokens: u.totalTokens,
1306
- };
1808
+ // baseline + this run's running total (idempotent per boundary,
1809
+ // accumulates across runs; carries cacheRead/cacheCreation too).
1810
+ session.state.tokenUsage = foldRunUsage(usageBaseline, modelFacade.getUsage());
1811
+ // Surface the whole-session monotonic cache counts to the UI.
1812
+ // Separate from turn-loop's authoritative per-response emit (which
1813
+ // drives the live context reading and single-turn metric).
1814
+ const cumulative = normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage);
1815
+ const cumulativeHitRate = cumulativeCacheHitRate(cumulative);
1816
+ options?.onStream?.({
1817
+ type: "usage_update",
1818
+ promptTokens: cumulative.cumulativePromptTokens,
1819
+ cumulativePromptTokens: cumulative.cumulativePromptTokens,
1820
+ cumulativeCacheReadTokens: cumulative.cumulativeCacheReadTokens,
1821
+ cumulativeCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
1822
+ ...(cumulativeHitRate !== undefined
1823
+ ? { cumulativeCacheHitRate: cumulativeHitRate }
1824
+ : {}),
1825
+ sessionPromptTokens: cumulative.cumulativePromptTokens,
1826
+ sessionCacheReadTokens: cumulative.cumulativeCacheReadTokens,
1827
+ sessionCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
1828
+ });
1307
1829
  if (this.config.costStore) {
1308
1830
  session.state.costState = this.config.costStore.serialize();
1309
1831
  }
1310
1832
  this.sessionManager.saveState(session.state);
1311
1833
  },
1312
1834
  });
1835
+ // Expose this run's loop for mid-run extension (TODO 3.1). Top-level only —
1836
+ // a sub-agent's loop is its own concern and isn't user-extendable.
1837
+ if (this.config.isSubAgent !== true)
1838
+ this.activeTurnLoop = turnLoop;
1839
+ // Expose this run's session bundle so a mid-run clearGoal() wipes the goal
1840
+ // on the very instance this loop keeps saving (see field doc). Top-level
1841
+ // only — sub-agents don't carry user-clearable persistent goals.
1842
+ if (this.config.isSubAgent !== true)
1843
+ this.activeRunSession = session;
1313
1844
  let result;
1314
1845
  try {
1315
1846
  result = await turnLoop.run(messages);
1316
- // ── Wait for background sub-agents, then summarize ───────────────
1317
- // run_in_background sub-agents outlive the turn that spawned them. The
1318
- // main agent must not resolve while ITS OWN background agents are still
1319
- // working otherwise their results land in the notification queue with
1320
- // nobody to drain them and the run looks "done" while work is in flight
1321
- // (the s-mpvf4rsj-bb6e4639 bug). We block here until none of this
1322
- // session's background agents are running, then drain ALL their results
1323
- // and feed them back as one more turn so the agent summarizes.
1847
+ // ── Headless: drain background sub-agents before resolving ───────
1848
+ // Unified background-work model (2026-06-17): the engine NO LONGER parks
1849
+ // every run waiting on background work. Background work (sub-agents,
1850
+ // video polls, shells) ends the turn, yields, and is picked up later by
1851
+ // the server's notification-wakeup path (maybeWakeIdleSession). The
1852
+ // INTERACTIVE path relies on that wakeup + a run-boundary re-check.
1324
1853
  //
1325
- // Top-level only: a sub-agent must never wait on grandchildren (and
1326
- // nested agents are disabled anyway). `signal` aborts the wait.
1854
+ // HEADLESS is the exception: a one-shot `engine.run` whose caller takes
1855
+ // `result.text` as THE answer (automation / SDK) has no later turn to
1856
+ // pick up a wakeup — so it must wait, before resolving, until its own
1857
+ // background SUB-AGENTS finish and summarize. Only sub-agents (their
1858
+ // summary IS part of this run's result), NOT shells (a dev server never
1859
+ // exits → would hang headless forever) and NOT video (a long render the
1860
+ // one-shot run shouldn't block on). This replaces the old for(;;) park
1861
+ // (s-mpvf4rsj-bb6e4639 invariant) for the headless case only.
1327
1862
  const sid = session.state.sessionId;
1328
1863
  const isTopLevel = this.config.isSubAgent !== true;
1329
- if (isTopLevel) {
1864
+ if (isTopLevel && this.isHeadless()) {
1330
1865
  let aborted = options?.signal?.aborted === true;
1331
- while (!aborted && asyncAgentRegistry.hasRunningForSession(sid)) {
1332
- aborted = await this.waitForBackgroundAgentChange(sid, options?.signal);
1333
- }
1334
- // Drain everything that came back — including partial results when the
1335
- // user aborted with one agent still stuck. Nothing already returned is
1336
- // lost: it's injected into the transcript either way.
1337
- const pending = notificationQueue.drainAll(sid);
1338
- if (pending.length > 0) {
1866
+ // Loop: a summarize turn can spawn a NEW background sub-agent; keep
1867
+ // draining + summarizing until none remain. turnCount accumulates, so
1868
+ // the turn-loop's maxTurns still bounds runaway re-summarization.
1869
+ for (;;) {
1870
+ while (!aborted && asyncAgentRegistry.hasRunningForSession(sid)) {
1871
+ aborted = await this.waitForBackgroundAgentChange(sid, options?.signal);
1872
+ }
1873
+ let pending = notificationQueue.drainAll(sid);
1874
+ if (aborted && pending.length === 0) {
1875
+ // Abort race: an agent calls markCompleted (registry notify) and only
1876
+ // THEN enqueue (queue notify) as two separate statements. If the abort
1877
+ // fired before that agent's completion `.then` ran, the while above
1878
+ // exited on `aborted`, this drainAll caught nothing, and a naive
1879
+ // `break` here would drop the agent's output. Give still-settling
1880
+ // agents a bounded window to finish enqueuing, then drain once more.
1881
+ // Each wait is timeout-bounded so a genuinely stuck (never-completing)
1882
+ // agent can't hang abort cleanup forever — we'd rather lose nothing in
1883
+ // the common case and not hang in the pathological one.
1884
+ for (let i = 0; i < 20 && asyncAgentRegistry.hasRunningForSession(sid); i++) {
1885
+ const changed = await this.waitForBackgroundAgentChangeOrTimeout(sid, 25);
1886
+ if (!changed)
1887
+ break; // timed out with no state change → stop waiting
1888
+ }
1889
+ pending = notificationQueue.drainAll(sid);
1890
+ if (pending.length === 0)
1891
+ break;
1892
+ }
1893
+ else if (pending.length === 0) {
1894
+ break;
1895
+ }
1339
1896
  const injected = {
1340
1897
  role: "user",
1341
1898
  content: `<system-reminder>\n${buildNotificationMessage(pending)}\n</system-reminder>`,
1342
1899
  };
1343
1900
  if (aborted) {
1344
- // Aborted: preserve the results in context (transcript + messages)
1345
- // but do NOT spin up another LLM turn the user cancelled, and a
1346
- // fresh turn would just be killed by the same signal. The next
1347
- // user message in this session will see these results in history.
1348
- session.transcript.appendMessage(injected.role, injected.content);
1901
+ // Mark injected: a synthetic notification, not the user's own input —
1902
+ // the disk reader drops it on replay so no phantom user bubble.
1903
+ session.transcript.appendMessage(injected.role, injected.content, { injected: true });
1349
1904
  result = { ...result, messages: [...result.messages, injected] };
1905
+ break;
1350
1906
  }
1351
- else {
1352
- // All background agents finished: one more turn so the agent reads
1353
- // every result and summarizes. turnCount keeps accumulating, so
1354
- // maxTurns still bounds runaway re-summarization.
1355
- result = await turnLoop.run([...result.messages, injected]);
1356
- }
1907
+ result = await turnLoop.run([...result.messages, injected]);
1357
1908
  }
1358
1909
  }
1359
1910
  }
@@ -1362,6 +1913,15 @@ export class Engine {
1362
1913
  // long-lived engine doesn't keep blocking stops.
1363
1914
  if (goalHookHandler)
1364
1915
  this.hooks.unregister("on_stop", goalHookHandler);
1916
+ if (this.activeGoalHook === goalHookHandler)
1917
+ this.activeGoalHook = null;
1918
+ if (this.activeTurnLoop === turnLoop)
1919
+ this.activeTurnLoop = null;
1920
+ if (this.activeRunSession === session)
1921
+ this.activeRunSession = null;
1922
+ // Run-scoped too: this handler is re-registered every run(), so it must be
1923
+ // dropped here or it stacks duplicates that re-snapshot on every tool.
1924
+ this.hooks.unregister("on_tool_start", fileHistoryHandler);
1365
1925
  }
1366
1926
  this.lastMessages = result.messages;
1367
1927
  this.compactedMessagesBySession.set(session.state.sessionId, this.stripUserContextMessage(result.messages, userContextMsg));
@@ -1404,6 +1964,12 @@ export class Engine {
1404
1964
  void buildSessionTitle(auxSummaryClient, firstUserText, result.text)
1405
1965
  .then((title) => {
1406
1966
  if (title) {
1967
+ // Persist the title so it survives a localStorage wipe / disk
1968
+ // rebuild — it used to live only in the renderer's localStorage
1969
+ // index. This .then resolves AFTER the saveState below (:1892), so
1970
+ // it must save again itself rather than rely on that write.
1971
+ session.state.title = title;
1972
+ this.sessionManager.saveState(session.state);
1407
1973
  onStream({
1408
1974
  type: "session_title",
1409
1975
  sessionId: session.state.sessionId,
@@ -1421,12 +1987,9 @@ export class Engine {
1421
1987
  // distinction and misled anyone reading state.json.
1422
1988
  session.state.turnCount = turnLoop.currentTurn;
1423
1989
  session.state.status = result.reason;
1990
+ // Session-cumulative (baseline + this run) for persistence...
1424
1991
  const usage = modelFacade.getUsage();
1425
- session.state.tokenUsage = {
1426
- promptTokens: usage.totalPromptTokens,
1427
- completionTokens: usage.totalCompletionTokens,
1428
- totalTokens: usage.totalTokens,
1429
- };
1992
+ session.state.tokenUsage = foldRunUsage(usageBaseline, usage);
1430
1993
  if (this.config.costStore) {
1431
1994
  session.state.costState = this.config.costStore.serialize();
1432
1995
  }
@@ -1459,12 +2022,34 @@ export class Engine {
1459
2022
  */
1460
2023
  /**
1461
2024
  * Resolve the LLM client for background/auxiliary work (memory extraction,
1462
- * auto-dream). When settings.auxModelKey names a valid pool model, build (and
1463
- * cache) a dedicated client for it so per-turn book-keeping runs on a cheap
2025
+ * auto-dream). When settings.defaults.auxText names a valid pool model, build
2026
+ * (and cache) a dedicated client for it so per-turn book-keeping runs on a cheap
1464
2027
  * fast model instead of the expensive primary. Falls back to `fallback` (the
1465
2028
  * active run's client) when unset, unknown, or on any build failure — aux
1466
2029
  * work is best-effort and must never break a run.
1467
2030
  */
2031
+ /**
2032
+ * Build the SummarizeFn used for context compaction. Extracted so both the
2033
+ * run path and forceCompact share one definition of the summarization call.
2034
+ */
2035
+ buildSummarizeFn(auxSummaryClient, recordCumulativeUsage) {
2036
+ return async (prompt) => {
2037
+ const summaryResponse = await auxSummaryClient.createMessage({
2038
+ systemPrompt: "You are a conversation summarizer. Be concise and factual.",
2039
+ messages: [{ role: "user", content: prompt }],
2040
+ tools: [],
2041
+ maxTokens: 1024,
2042
+ // Auxiliary call — no need to burn reasoning tokens. On DeepSeek V4
2043
+ // this flips thinking off (~3x faster, fewer tokens); on every other
2044
+ // OpenAI-compatible provider the field is ignored.
2045
+ reasoning: { mode: "off" },
2046
+ });
2047
+ if (summaryResponse.usage) {
2048
+ recordCumulativeUsage?.(summaryResponse.usage);
2049
+ }
2050
+ return summaryResponse.text;
2051
+ };
2052
+ }
1468
2053
  async resolveAuxClient(fallback) {
1469
2054
  let auxKey;
1470
2055
  try {
@@ -1473,7 +2058,9 @@ export class Engine {
1473
2058
  // once per run on the post-run background path, so the cost is fine.
1474
2059
  const sm = this.getSettingsManager();
1475
2060
  sm.invalidate();
1476
- auxKey = sm.get().auxModelKey;
2061
+ // Unified store's defaults.auxText (a connection id = pool key) selects
2062
+ // the aux model; resolveAuxKey returns it (or undefined).
2063
+ auxKey = resolveAuxKey(sm.get());
1477
2064
  }
1478
2065
  catch {
1479
2066
  return fallback;
@@ -1519,14 +2106,18 @@ export class Engine {
1519
2106
  try {
1520
2107
  // Background calls run on the auxiliary model when configured, so memory
1521
2108
  // book-keeping doesn't burn the expensive primary model every turn.
1522
- const llmClient = await this.resolveAuxClient(primaryClient);
2109
+ // settings.memories.extractionModel (if set + valid) overrides the aux
2110
+ // model specifically for memory extraction (TODO 8.1).
2111
+ const llmClient = await this.resolveExtractionClient(primaryClient);
1523
2112
  // Only run memory extraction for substantive sessions. The previous
1524
2113
  // threshold of 4 user+assistant messages was low enough that two-line
1525
2114
  // exchanges ("what's the time?" / "noon") triggered a full LLM
1526
2115
  // extraction, which then padded the memory store with low-signal
1527
2116
  // entries. 8 messages is roughly "more than a single back-and-forth"
1528
2117
  // — substantive enough to be worth a durable note.
1529
- const messages = transcript.toMessages().filter((m) => m.role === "user" || m.role === "assistant");
2118
+ const messages = transcript
2119
+ .toMessages()
2120
+ .filter((m) => m.role === "user" || m.role === "assistant");
1530
2121
  if (messages.length < 8)
1531
2122
  return;
1532
2123
  // Memory orchestrator + dream-loop calls are auxiliary LLM calls
@@ -1558,6 +2149,10 @@ export class Engine {
1558
2149
  },
1559
2150
  runDream: async ({ systemPrompt, userPrompt, projectDir }) => this.runDreamLoop({ systemPrompt, userPrompt, projectDir, llmClient, sessionId }),
1560
2151
  projectDir: cwd,
2152
+ // settings.memories.maxCount caps memories accepted per extraction;
2153
+ // autoExtract=false turns the extractor off (summaries/dream stay).
2154
+ maxCount: this.readMemoriesConfig()?.maxCount,
2155
+ autoExtract: this.readMemoriesConfig()?.autoExtract,
1561
2156
  });
1562
2157
  await orchestrator.run(plainMessages, sessionId);
1563
2158
  }
@@ -1608,10 +2203,10 @@ export class Engine {
1608
2203
  * Switch the active model by pool key. Takes effect on the next run() call.
1609
2204
  * Returns the new model entry.
1610
2205
  *
1611
- * Persists settings.activeKey (and a legacy settings.model.* mirror) so the
2206
+ * Persists settings.defaults.text (= the connection id / pool key) so the
1612
2207
  * next process startup defaults to the same model — without this, switches
1613
2208
  * only live in memory and every restart reverts to the previously persisted
1614
- * activeKey.
2209
+ * defaults.text.
1615
2210
  */
1616
2211
  switchModel(key) {
1617
2212
  const entry = this.modelPool.switch(key);
@@ -1620,19 +2215,42 @@ export class Engine {
1620
2215
  // this.config.clientDefaults and survive the switch untouched.
1621
2216
  const nextLlm = this.modelPool.toLLMConfig(entry);
1622
2217
  this.config = { ...this.config, llm: nextLlm };
1623
- this.persistActiveModel(entry, nextLlm);
2218
+ this.persistActiveModel(entry);
1624
2219
  return entry;
1625
2220
  }
2221
+ /**
2222
+ * Zero the legacy/model-scoped token/cache usage window on disk. The
2223
+ * whole-session cumulative counters are intentionally left alone.
2224
+ */
2225
+ resetSessionUsage(sessionId) {
2226
+ const zero = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
2227
+ // If the session is mid-run right now, update its live state so the next
2228
+ // turn-boundary write doesn't re-fold a stale baseline.
2229
+ if (this.activeRunSession?.state.sessionId === sessionId) {
2230
+ this.activeRunSession.state.tokenUsage = { ...zero };
2231
+ }
2232
+ // Persist to disk so a reload / next run picks up the reset.
2233
+ if (this.sessionManager.exists(sessionId)) {
2234
+ try {
2235
+ const bundle = this.sessionManager.resume(sessionId);
2236
+ bundle.state.tokenUsage = { ...zero };
2237
+ this.sessionManager.saveState(bundle.state);
2238
+ }
2239
+ catch {
2240
+ // Session not resumable (never persisted yet) — the in-memory reset
2241
+ // above covers the live case; nothing else to do.
2242
+ }
2243
+ }
2244
+ }
1626
2245
  /**
1627
2246
  * Write the active model selection to ~/.code-shell/settings.json.
1628
2247
  *
1629
- * We mirror into the legacy settings.model.* block (provider/name/apiKey/
1630
- * baseUrl) because boot paths in cli/main.ts, repl.ts, run.ts still read it.
1631
- * The mirror uses resolved llm values (not raw entry.*) so credentials that
1632
- * live on settings.providers[] flow through correctly — entry.apiKey is
1633
- * undefined when the entry resolves via providerCatalog.
2248
+ * Persists settings.defaults.text = entry.key (the connection id == pool
2249
+ * key). That is the single field the boot path reads to restore the active
2250
+ * text model on the next startup (see ctor: settings.defaults.text pool
2251
+ * switch). No legacy activeKey/model.* mirror is written.
1634
2252
  */
1635
- persistActiveModel(entry, llm) {
2253
+ persistActiveModel(entry) {
1636
2254
  try {
1637
2255
  // userHome() (not raw homedir()) so a test that sets process.env.HOME to
1638
2256
  // a tmpdir gets its writes isolated too — the SettingsManager reader
@@ -1651,28 +2269,31 @@ export class Engine {
1651
2269
  return;
1652
2270
  }
1653
2271
  }
1654
- const prevModel = typeof existing.model === "object" && existing.model
1655
- ? existing.model
2272
+ const prevDefaults = typeof existing.defaults === "object" && existing.defaults
2273
+ ? existing.defaults
1656
2274
  : {};
1657
2275
  const updated = {
1658
2276
  ...existing,
1659
- activeKey: entry.key,
1660
- model: {
1661
- ...prevModel,
1662
- provider: llm.provider,
1663
- name: entry.model,
1664
- apiKey: llm.apiKey,
1665
- baseUrl: llm.baseUrl,
1666
- },
2277
+ defaults: { ...prevDefaults, text: entry.key },
1667
2278
  };
2279
+ // mode 0o600: this writes model.apiKey (plaintext) into settings.json, so
2280
+ // it must be owner-only — same R-1 hardening as SettingsManager/onboarding.
2281
+ // (Third settings.json writer; the R-1 sweep initially missed this one.)
1668
2282
  const tmp = `${file}.${process.pid}.tmp`;
1669
2283
  const payload = JSON.stringify(updated, null, 2) + "\n";
1670
- writeFileSync(tmp, payload, "utf-8");
2284
+ writeFileSync(tmp, payload, { encoding: "utf-8", mode: 0o600 });
1671
2285
  try {
1672
2286
  renameSync(tmp, file);
1673
2287
  }
1674
2288
  catch {
1675
- writeFileSync(file, payload, "utf-8");
2289
+ writeFileSync(file, payload, { encoding: "utf-8", mode: 0o600 });
2290
+ }
2291
+ // mode arg only applies on create; tighten an already-existing file too.
2292
+ try {
2293
+ chmodSync(file, 0o600);
2294
+ }
2295
+ catch {
2296
+ /* best-effort */
1676
2297
  }
1677
2298
  }
1678
2299
  catch (err) {
@@ -1708,12 +2329,10 @@ export class Engine {
1708
2329
  * payloads are dropped so out-of-order reload deliveries can't let an older
1709
2330
  * config clobber a newer one (Q5).
1710
2331
  *
1711
- * MCP: only connects (idempotent already-connected servers are skipped);
1712
- * never disconnects, so an in-flight tool call on an existing server is
1713
- * never severed (Q3). Removed servers are deferred to the next session
1714
- * rebuild. If mcpManager isn't built yet (no MCP run has happened), the
1715
- * new servers will be connected on the next run via the existing per-run
1716
- * connectAll path — so we skip the connect here.
2332
+ * MCP: reconciles the shared MCP pool against the new disk-default server
2333
+ * set. Added servers connect idempotently; removed/disabled servers are
2334
+ * disconnected and their registered MCP tools are unregistered so plugin
2335
+ * disable takes effect without an Electron restart.
1717
2336
  *
1718
2337
  * Preset (#2): a preset hot-reload re-resolves `this.preset` so the next-turn
1719
2338
  * PromptComposer picks up the new preset's system prompt / behavior — that's
@@ -1747,8 +2366,14 @@ export class Engine {
1747
2366
  // The builtin tool SET is ctor-frozen and may be shared via runtime — we
1748
2367
  // do NOT rebuild it here. If the new preset implies a different builtin
1749
2368
  // tool set, that part of the change only lands on session restart.
1750
- const prevTools = resolveBuiltinToolNames({ preset: prevPresetName }).slice().sort().join(",");
1751
- const nextTools = resolveBuiltinToolNames({ preset: nextPreset.name }).slice().sort().join(",");
2369
+ const prevTools = resolveBuiltinToolNames({ preset: prevPresetName })
2370
+ .slice()
2371
+ .sort()
2372
+ .join(",");
2373
+ const nextTools = resolveBuiltinToolNames({ preset: nextPreset.name })
2374
+ .slice()
2375
+ .sort()
2376
+ .join(",");
1752
2377
  if (prevTools !== nextTools) {
1753
2378
  logger.warn("engine.preset_reload.tool_set_change_needs_restart", {
1754
2379
  from: prevPresetName,
@@ -1760,16 +2385,17 @@ export class Engine {
1760
2385
  }
1761
2386
  this.reloadHooks();
1762
2387
  if (patch.mcpServers && this.mcpManager) {
1763
- const added = {};
1764
- for (const [name, cfg] of Object.entries(patch.mcpServers)) {
1765
- if (!(name in prevServers))
1766
- added[name] = cfg;
1767
- }
1768
- if (Object.keys(added).length > 0) {
1769
- // connectAll is idempotent (skips already-connected); fire-and-forget
1770
- // so a slow server handshake never blocks the reload call.
1771
- void this.mcpManager.connectAll(added);
1772
- }
2388
+ // Fire-and-forget reconcile (connect added / disconnect removed servers).
2389
+ // It must NOT surface as an unhandled rejection: a single flaky server
2390
+ // that fails to connect/disconnect during hot-reload would otherwise
2391
+ // crash the host process (or be silently swallowed). Catch + log so the
2392
+ // reconcile is best-effort and the next reload can retry.
2393
+ void this.mcpManager.reconcile(patch.mcpServers, this).catch((err) => {
2394
+ logger.error("engine.mcp_reconcile_failed", {
2395
+ error: err instanceof Error ? err.message : String(err),
2396
+ version,
2397
+ });
2398
+ });
1773
2399
  }
1774
2400
  this.lastAppliedConfigVersion = version;
1775
2401
  }
@@ -1782,6 +2408,55 @@ export class Engine {
1782
2408
  * engine.run() call for this session picks up the injected content
1783
2409
  * instead of a stale snapshot from the previous run.
1784
2410
  */
2411
+ /**
2412
+ * Read a session's persisted active goal WITHOUT resuming it (cheap — reads
2413
+ * only state.json via SessionManager.readActiveGoal). The desktop host calls
2414
+ * this on session load to re-surface the goal block + its Cancel button: a
2415
+ * persistent goal lives only in state.activeGoal and is never replayed from
2416
+ * the transcript, so after a reload of an aborted goal run the UI would
2417
+ * otherwise show nothing (the "goal 还在但页面不显示、取消不了" bug). Returns
2418
+ * undefined when the session is unknown or has no active goal.
2419
+ */
2420
+ getGoal(sessionId) {
2421
+ return this.sessionManager.readActiveGoal(sessionId);
2422
+ }
2423
+ /**
2424
+ * Clear a session's persisted active goal (CC `/goal clear`). Works whether
2425
+ * the session is idle or its goal run is in flight: it wipes
2426
+ * `state.activeGoal` (so the next bare send won't re-inherit it) and, if a
2427
+ * goal hook is currently registered for this engine, unregisters it so an
2428
+ * in-flight run can stop instead of being re-blocked by the now-cleared goal.
2429
+ * Returns true if a goal was actually cleared. Idempotent — clearing a
2430
+ * session with no active goal is a no-op returning false.
2431
+ */
2432
+ clearGoal(sessionId) {
2433
+ if (!this.sessionManager.exists(sessionId))
2434
+ return false;
2435
+ // Prefer the LIVE run's bundle when it's this session: clearing its
2436
+ // in-RAM state.activeGoal is what stops the run loop from writing the goal
2437
+ // back on its next saveState. A fresh resume() copy would be cleared and
2438
+ // persisted, but the running loop's own detached bundle still holds the
2439
+ // goal and resurrects it — the stale-write-back race. Falls back to a
2440
+ // resumed copy when no run of this session is currently in flight.
2441
+ const live = this.activeRunSession && this.activeRunSession.state.sessionId === sessionId
2442
+ ? this.activeRunSession
2443
+ : null;
2444
+ const session = live ?? this.sessionManager.resume(sessionId);
2445
+ const had = session.state.activeGoal !== undefined;
2446
+ if (had) {
2447
+ session.state.activeGoal = undefined;
2448
+ this.sessionManager.saveState(session.state);
2449
+ }
2450
+ // If THIS session's goal run is in flight, drop its stop hook so the
2451
+ // current run can terminate (the closure-held goal would otherwise keep
2452
+ // re-blocking). The run's own `finally` also unregisters; double-unregister
2453
+ // is safe (set delete is idempotent).
2454
+ if (this.activeGoalHook && this.lastSessionId === sessionId) {
2455
+ this.hooks.unregister("on_stop", this.activeGoalHook);
2456
+ this.activeGoalHook = null;
2457
+ }
2458
+ return had;
2459
+ }
1785
2460
  injectContext(sessionId, content) {
1786
2461
  const session = this.sessionManager.resume(sessionId);
1787
2462
  session.transcript.appendMessage("assistant", content);
@@ -1794,25 +2469,74 @@ export class Engine {
1794
2469
  }
1795
2470
  }
1796
2471
  /**
1797
- * Force context compaction on the current session.
2472
+ * Force context compaction on a session.
1798
2473
  * Returns token stats before/after.
1799
2474
  */
1800
- forceCompact() {
1801
- const sessionId = this.lastSessionId;
1802
- if (!this.lastContextManager || !sessionId) {
2475
+ async forceCompact(sessionId) {
2476
+ const effectiveSessionId = sessionId ?? this.lastSessionId;
2477
+ if (!effectiveSessionId) {
1803
2478
  return { before: 0, after: 0, strategy: "none (no active session)" };
1804
2479
  }
1805
- const sourceMessages = this.compactedMessagesBySession.get(sessionId) ??
1806
- this.sessionManager.resume(sessionId).transcript.toMessages();
2480
+ const session = this.sessionManager.resume(effectiveSessionId);
2481
+ const sourceMessages = this.compactedMessagesBySession.get(effectiveSessionId) ?? session.transcript.toMessages();
1807
2482
  const before = estimateTokens(sourceMessages);
1808
- const compacted = this.lastContextManager.manage(sourceMessages);
2483
+ let contextManager = this.lastContextManager;
2484
+ if (!contextManager || this.lastSessionId !== effectiveSessionId) {
2485
+ contextManager = new ContextManager({
2486
+ maxTokens: this.resolveMaxContextTokens(),
2487
+ ...Object.fromEntries(Object.entries(this.resolveContextRatios()).filter(([, v]) => v !== undefined)),
2488
+ });
2489
+ contextManager.setTranscriptPath(session.transcript.getFilePath());
2490
+ contextManager.initReplacementStateFromMessages(sourceMessages);
2491
+ this.lastContextManager = contextManager;
2492
+ }
2493
+ // Manual /compact emits its UI boundary at the protocol layer from the
2494
+ // final before/after result. Capture the tier here, but avoid reusing a
2495
+ // stale run callback retained on lastContextManager, which could otherwise
2496
+ // double-emit.
2497
+ let compactStrategy;
2498
+ contextManager.setOnCompact((info) => {
2499
+ if (info.after < info.before)
2500
+ compactStrategy = info.strategy;
2501
+ });
2502
+ // Manual /compact = maximum compaction NOW. The automatic ladder waits for
2503
+ // compactAtRatio (0.85 * window), so on a 1M-window model an 800k text-only
2504
+ // conversation sits under the gate and manage() only runs a no-op micro.
2505
+ // Wire a summarizeFn (the run path does this per-run; a cold forceCompact on
2506
+ // a resumed-but-never-run session has none) and call forceSummarize, which
2507
+ // ignores the ratio gate and always summarizes (falling back to snip/window).
2508
+ //
2509
+ // Use the PRIMARY model, not the aux model. Automatic background compaction
2510
+ // routes to aux to keep the high-frequency path cheap, but summarization is
2511
+ // a high-fidelity task (drop a decision and the conversation "forgets"), and
2512
+ // a manual /compact is a low-frequency, user-initiated request for quality.
2513
+ // The aux model is sized for tiny outputs (titles, memory extraction), so
2514
+ // downgrading the one compaction the user explicitly asked for is backwards.
2515
+ try {
2516
+ const primaryClient = await createLLMClient(this.config.llm, this.config.clientDefaults);
2517
+ Object.assign(session.state, normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage));
2518
+ const recordCompactUsage = (usage) => {
2519
+ const next = addCumulativeUsage(session.state, usage);
2520
+ Object.assign(session.state, next);
2521
+ this.sessionManager.saveState(session.state);
2522
+ return next;
2523
+ };
2524
+ contextManager.setSummarizeFn(this.buildSummarizeFn(primaryClient, recordCompactUsage));
2525
+ }
2526
+ catch (err) {
2527
+ logger.warn("engine.force_compact_client_failed", {
2528
+ error: err.message,
2529
+ });
2530
+ }
2531
+ const compacted = await contextManager.forceSummarize(sourceMessages);
1809
2532
  const after = estimateTokens(compacted);
1810
- this.compactedMessagesBySession.set(sessionId, compacted);
2533
+ this.compactedMessagesBySession.set(effectiveSessionId, compacted);
2534
+ this.lastSessionId = effectiveSessionId;
1811
2535
  this.lastMessages = compacted;
1812
2536
  return {
1813
2537
  before,
1814
2538
  after,
1815
- strategy: before === after ? "no compaction needed" : "compacted",
2539
+ strategy: after >= before ? "no compaction needed" : (compactStrategy ?? "compacted"),
1816
2540
  };
1817
2541
  }
1818
2542
  stripUserContextMessage(messages, userContextMsg) {
@@ -1823,7 +2547,7 @@ export class Engine {
1823
2547
  }
1824
2548
  getSettingsManager() {
1825
2549
  if (!this.settingsManager) {
1826
- this.settingsManager = new SettingsManager(this.config.cwd, this.config.settingsScope ?? "project");
2550
+ this.settingsManager = new SettingsManager(this.config.cwd, this.config.settingsScope ?? "project", this.config.projectTrusted !== false);
1827
2551
  }
1828
2552
  return this.settingsManager;
1829
2553
  }
@@ -1875,7 +2599,7 @@ export class Engine {
1875
2599
  rules.push({ tool: "Bash", decision: "allow" });
1876
2600
  }
1877
2601
  try {
1878
- const settingsManager = new SettingsManager(cwd, this.config.settingsScope ?? "project");
2602
+ const settingsManager = new SettingsManager(cwd, this.config.settingsScope ?? "project", this.config.projectTrusted !== false);
1879
2603
  const settings = settingsManager.get();
1880
2604
  if (settings.permissions?.rules?.length) {
1881
2605
  rules.unshift(...settings.permissions.rules);
@@ -1906,7 +2630,11 @@ export class Engine {
1906
2630
  backend = interactive;
1907
2631
  }
1908
2632
  else {
1909
- backend = new HeadlessApprovalBackend(mode === "bypassPermissions" ? "approve-all" : mode === "dontAsk" ? "deny-all" : "deny-all");
2633
+ backend = new HeadlessApprovalBackend(mode === "bypassPermissions"
2634
+ ? "approve-all"
2635
+ : mode === "dontAsk"
2636
+ ? "deny-all"
2637
+ : "deny-all");
1910
2638
  }
1911
2639
  }
1912
2640
  return { rules, backend };
@@ -1930,6 +2658,27 @@ export class Engine {
1930
2658
  getPermissionMode() {
1931
2659
  return this.config.permissionMode ?? "acceptEdits";
1932
2660
  }
2661
+ /**
2662
+ * Extend the in-flight run's turn ceiling and/or goal budgets (TODO 3.1 —
2663
+ * 运行中续轮/加预算). No-op (returns null) when no run is active. Lets a user
2664
+ * keep an unattended goal going past its original cap instead of restarting.
2665
+ */
2666
+ extendGoalRun(opts) {
2667
+ if (!this.activeTurnLoop)
2668
+ return null;
2669
+ return this.activeTurnLoop.extend(opts);
2670
+ }
2671
+ /**
2672
+ * The effective permission rules for the current mode + cwd (TODO 5.1) —
2673
+ * preset defaults + mode-derived + settings.permissions.rules, in the same
2674
+ * order the classifier evaluates them. Exposed read-only so `/permissions`
2675
+ * (and any UI) can list what's actually in force. Pure read; builds the same
2676
+ * rule set buildPermissionConfig does, without constructing a backend.
2677
+ */
2678
+ getPermissionRules() {
2679
+ return this.buildPermissionConfig(this.getPermissionMode(), this.config.cwd ?? process.cwd())
2680
+ .rules;
2681
+ }
1933
2682
  /**
1934
2683
  * Toggle plan mode directly. Called by the Plan tool (Task 7) via ToolContext.engine.
1935
2684
  * Also syncs permissionMode to keep both fields consistent.
@@ -1983,6 +2732,30 @@ export class Engine {
1983
2732
  signal?.addEventListener("abort", onAbort, { once: true });
1984
2733
  });
1985
2734
  }
2735
+ /**
2736
+ * Like waitForBackgroundAgentChange but with no abort signal and a hard
2737
+ * timeout. Resolves `true` on a registry/queue change, `false` if `timeoutMs`
2738
+ * elapses first. Used only by the headless abort-drain cleanup, where we want
2739
+ * to catch a completing agent's just-about-to-enqueue notification without
2740
+ * risking a permanent hang on an agent that never completes.
2741
+ */
2742
+ waitForBackgroundAgentChangeOrTimeout(_sessionId, timeoutMs) {
2743
+ return new Promise((resolve) => {
2744
+ let settled = false;
2745
+ const finish = (changed) => {
2746
+ if (settled)
2747
+ return;
2748
+ settled = true;
2749
+ clearTimeout(timer);
2750
+ unsubRegistry();
2751
+ unsubQueue();
2752
+ resolve(changed);
2753
+ };
2754
+ const timer = setTimeout(() => finish(false), timeoutMs);
2755
+ const unsubRegistry = asyncAgentRegistry.subscribe(() => finish(true));
2756
+ const unsubQueue = notificationQueue.subscribe(() => finish(true));
2757
+ });
2758
+ }
1986
2759
  /**
1987
2760
  * Sub-agent role registry for the given cwd, memoized per-cwd so the
1988
2761
  * directory is read once rather than every turn. A new cwd (e.g. via
@@ -1991,12 +2764,8 @@ export class Engine {
1991
2764
  getAgentDefinitions(cwd) {
1992
2765
  const disabledAgents = this.readDisabledAgents(cwd);
1993
2766
  const disabledPlugins = this.readDisabledLists().disabledPlugins;
1994
- const disabledKey = [...disabledAgents, "::", ...disabledPlugins]
1995
- .slice()
1996
- .sort()
1997
- .join(" ");
1998
- if (this.agentDefsCache?.cwd !== cwd ||
1999
- this.agentDefsCache.disabledKey !== disabledKey) {
2767
+ const disabledKey = [...disabledAgents, "::", ...disabledPlugins].slice().sort().join(" ");
2768
+ if (this.agentDefsCache?.cwd !== cwd || this.agentDefsCache.disabledKey !== disabledKey) {
2000
2769
  this.agentDefsCache = {
2001
2770
  cwd,
2002
2771
  disabledKey,
@@ -2053,7 +2822,7 @@ export class Engine {
2053
2822
  * by tests that want a ToolContext without a full run() cycle.
2054
2823
  */
2055
2824
  resolveSandboxWithoutRuntime(config, cwd) {
2056
- const key = `${config.mode}:${cwd}`;
2825
+ const key = sandboxCacheKey(config, cwd);
2057
2826
  let cached = this.sandboxCache.get(key);
2058
2827
  if (!cached) {
2059
2828
  cached = resolveSandboxBackend(config, cwd);
@@ -2068,20 +2837,129 @@ export class Engine {
2068
2837
  }
2069
2838
  return cached;
2070
2839
  }
2840
+ /**
2841
+ * Build the shell env layered onto the Bash tool / background shells (see
2842
+ * mergeShellEnv). Three user-configured sources, merged lowest → highest:
2843
+ *
2844
+ * 1. project `localEnvironment.env` — the per-project "local environment"
2845
+ * panel (DATABASE_URL etc.); the floor, so a project's own panel values
2846
+ * can be overridden by an explicit top-level `env`.
2847
+ * 2. global top-level `env` — ~/.code-shell/settings.json; the
2848
+ * canonical home for API keys (OPENAI_API_KEY) a skill script reads —
2849
+ * configure once, every project's skills get it.
2850
+ * 3. project top-level `env` — .code-shell/settings.json; a project
2851
+ * that wants to override a global key wins.
2852
+ *
2853
+ * Each scope is read UNMERGED so the layering here is the single source of
2854
+ * precedence (getForScope merges nothing). Returns undefined when no layer
2855
+ * contributes a key, so the caller passes it through unchanged for projects
2856
+ * that configure none.
2857
+ *
2858
+ * Sub-agents: a sub-agent is the user's OWN agent doing the user's work
2859
+ * (mirrors Claude Code, where sub-agents inherit the parent environment), so
2860
+ * it now reads the SAME env as the parent. The sub-agent branch is kept as an
2861
+ * explicit seam (`filterSubagentEnv`) rather than removed — a future policy
2862
+ * could narrow what a sub-agent sees (e.g. drop credential secrets) by
2863
+ * changing that one hook; today it passes everything through unchanged.
2864
+ * A no-cwd context still gets nothing (there is genuinely no project to read).
2865
+ *
2866
+ * None of these is filtered through the deny regex (mergeShellEnv): the user
2867
+ * put them there deliberately. The allowlist/deny machinery only guards the
2868
+ * host's process.env from a tainted model exfiltrating it via `env | curl`.
2869
+ */
2870
+ readShellEnv(cwd) {
2871
+ if (!cwd)
2872
+ return undefined;
2873
+ const merged = {};
2874
+ const layer = (env) => {
2875
+ if (!env)
2876
+ return;
2877
+ for (const [k, v] of Object.entries(env)) {
2878
+ if (typeof v === "string")
2879
+ merged[k] = v;
2880
+ }
2881
+ };
2882
+ try {
2883
+ // The fully-merged settings already apply the scope guard (a 'project'
2884
+ // scope never reads the host ~/.code-shell) and the user < project <
2885
+ // local precedence — so the top-level `env` map read from here is global
2886
+ // values overridden by project values, exactly as specified. We layer
2887
+ // localEnvironment.env *under* it as the floor.
2888
+ const settings = this.getSettingsManager().get();
2889
+ layer(settings.localEnvironment?.env); // floor
2890
+ // Credentials flagged "expose as env var" (Credential.exposeAsEnv). This
2891
+ // is the wiring that was missing — the UI/store recorded the flag but no
2892
+ // code ever injected the secret, so `$FIGMA_TOKEN` was always empty.
2893
+ // Scope mirrors settingsScope so a project-scoped engine never surfaces
2894
+ // the host user's credentials (same isolation contract as top-level env).
2895
+ // Placed below settings.env so an explicit `env` entry can still override.
2896
+ const credScope = (this.config.settingsScope ?? "project") === "full" ? "full" : "project";
2897
+ layer(new CredentialStore(cwd).envExposures(credScope));
2898
+ layer(settings.env); // top-level env (global ⊕ project) wins
2899
+ }
2900
+ catch {
2901
+ return undefined;
2902
+ }
2903
+ const result = this.config.isSubAgent === true ? this.filterSubagentEnv(merged) : merged;
2904
+ return Object.keys(result).length > 0 ? result : undefined;
2905
+ }
2906
+ /**
2907
+ * Policy seam for what a sub-agent's shell sees. A sub-agent inherits the
2908
+ * parent environment by default (mirrors Claude Code), so this is the
2909
+ * identity function today. It exists so a future policy can narrow the set
2910
+ * (e.g. strip credential `exposeAsEnv` secrets, or allowlist by name) in ONE
2911
+ * place instead of scattering `isSubAgent` checks through readShellEnv.
2912
+ */
2913
+ filterSubagentEnv(env) {
2914
+ return env;
2915
+ }
2916
+ /**
2917
+ * Read the project's `localEnvironment.setupScripts` for this cwd (the raw
2918
+ * per-platform map). Used by EnterWorktree to run setup once in a freshly
2919
+ * created worktree. Returns undefined for sub-agents / no cwd (same minimal
2920
+ * surface as readShellEnv). The platform selection + run live in
2921
+ * git/worktree.ts; this only fetches the configured scripts.
2922
+ */
2923
+ readWorktreeSetupScripts(cwd) {
2924
+ if (this.config.isSubAgent === true || !cwd)
2925
+ return undefined;
2926
+ try {
2927
+ const scoped = this.getSettingsManager().getForScope("project", cwd);
2928
+ return scoped.localEnvironment?.setupScripts;
2929
+ }
2930
+ catch {
2931
+ return undefined;
2932
+ }
2933
+ }
2071
2934
  buildToolContext() {
2072
2935
  const { disabledSkills, disabledPlugins } = this.readDisabledLists();
2073
2936
  return {
2937
+ shellEnv: this.readShellEnv(this.config.cwd),
2074
2938
  cwd: this.config.cwd ?? process.cwd(),
2075
2939
  llmConfig: this.config.llm,
2076
2940
  modelPool: this.modelPool,
2077
2941
  toolRegistry: this.toolRegistry,
2078
2942
  askUser: this.config.askUser,
2943
+ browser: this.config.browserBridge,
2944
+ injectCredentialToBrowser: this.config.injectCredentialToBrowser,
2079
2945
  isSubAgent: this.config.isSubAgent === true,
2946
+ // Credential tools narrow their disk reads to this scope: a project/
2947
+ // isolated engine (SDK-embedded) must not surface the host user's
2948
+ // ~/.code-shell credentials or credentialUse.autoApprove. "full" (the
2949
+ // host-application default) merges user + project as before.
2950
+ settingsScope: this.config.settingsScope ?? "project",
2080
2951
  hooks: this.hooks,
2081
2952
  planMode: this.planMode,
2953
+ permissionMode: this.permissionMode,
2082
2954
  engine: this,
2083
2955
  disabledSkills,
2084
2956
  disabledPlugins,
2957
+ skillAllowlist: this.config.skillAllowlist,
2958
+ backgroundShells: backgroundShellManager,
2959
+ // Sub-agents never start background shells (they're short-lived and
2960
+ // their lifecycle ends with the parent turn); unattended automation
2961
+ // opts out via config. Otherwise allowed.
2962
+ allowBackgroundShells: this.config.isSubAgent === true ? false : this.config.allowBackgroundShells !== false,
2085
2963
  };
2086
2964
  }
2087
2965
  /**
@@ -2097,27 +2975,97 @@ export class Engine {
2097
2975
  */
2098
2976
  readDisabledLists() {
2099
2977
  if (this.config.isSubAgent === true) {
2100
- return { disabledSkills: [], disabledPlugins: [] };
2978
+ return { disabledSkills: [], disabledPlugins: [], disabledPluginHooks: [] };
2979
+ }
2980
+ // Shared folding (capability-control/disabled-lists.ts): project
2981
+ // capabilityOverrides over the global baseline + the no-repo whitelist
2982
+ // inversion. Extracted so the MCP merge consumers (engineFactory /
2983
+ // diskDefaultsFrom) fold identically — see that module's doc.
2984
+ return computeEffectiveDisabledLists(this.getSettingsManager(), this.config.cwd);
2985
+ }
2986
+ /**
2987
+ * Public view of the folded disabled lists, for hosts that need the
2988
+ * EFFECTIVE state (e.g. the protocol server's settings hot-reload rebuilds
2989
+ * the plugin-MCP merge per session — a project-level "on" must override the
2990
+ * global disabledPlugins there too).
2991
+ */
2992
+ getEffectiveDisabledLists() {
2993
+ return this.readDisabledLists();
2994
+ }
2995
+ /**
2996
+ * Public: resolve every known feature flag to its effective boolean (the
2997
+ * settings overlay merged over the compiled-in defaults). Used by the
2998
+ * `config` protocol query so the `/features` command can list flag state.
2999
+ */
3000
+ getFeatureFlags() {
3001
+ return resolveFeatureFlags(this.readFeatureFlags());
3002
+ }
3003
+ /**
3004
+ * Read the merged `settings.featureFlags` overlay for this cwd. Project
3005
+ * settings override user settings via the normal SettingsManager merge.
3006
+ * Returns undefined (→ all defaults) on any read error or for sub-agents,
3007
+ * so a flag check never throws and a child runs with default behavior.
3008
+ */
3009
+ readFeatureFlags() {
3010
+ if (this.config.isSubAgent === true)
3011
+ return undefined;
3012
+ try {
3013
+ const settings = this.getSettingsManager().get();
3014
+ return settings.featureFlags;
3015
+ }
3016
+ catch {
3017
+ return undefined;
2101
3018
  }
3019
+ }
3020
+ /**
3021
+ * Read settings.memories ({ maxCount, maxAge, extractionModel, autoExtract }).
3022
+ * Returns undefined on any error or when absent, so the memory pipeline
3023
+ * falls back to its built-in defaults.
3024
+ */
3025
+ readMemoriesConfig() {
2102
3026
  try {
2103
- const sm = this.getSettingsManager();
2104
- const settings = sm.get();
2105
- // Fold the project capabilityOverrides over the global baseline so a
2106
- // project can force-enable a globally-disabled skill/plugin or vice
2107
- // versa. Read the project overlay UNMERGED (getForScope), not the merged
2108
- // get(), so tri-state inheritance survives. No cwd / no overlay → the
2109
- // baseline is returned unchanged (zero regression).
2110
- const cwd = this.config.cwd;
2111
- const overrides = cwd
2112
- ? sm.getForScope("project", cwd).capabilityOverrides
2113
- : undefined;
2114
- return {
2115
- disabledSkills: effectiveDisabledList(settings.disabledSkills ?? [], overrides?.skills),
2116
- disabledPlugins: effectiveDisabledList(settings.disabledPlugins ?? [], overrides?.plugins),
2117
- };
3027
+ const settings = this.getSettingsManager().get();
3028
+ return settings.memories;
2118
3029
  }
2119
3030
  catch {
2120
- return { disabledSkills: [], disabledPlugins: [] };
3031
+ return undefined;
2121
3032
  }
2122
3033
  }
3034
+ /**
3035
+ * LLM client for memory extraction (TODO 8.1). Prefers
3036
+ * settings.memories.extractionModel when it names a valid pool model;
3037
+ * otherwise falls back to the aux client (which itself falls back to the
3038
+ * passed primary). Build failures fall back too — extraction is best-effort.
3039
+ */
3040
+ async resolveExtractionClient(primaryClient) {
3041
+ const key = this.readMemoriesConfig()?.extractionModel;
3042
+ if (key) {
3043
+ const entry = this.modelPool.get(key);
3044
+ if (entry) {
3045
+ try {
3046
+ return await createLLMClient(this.modelPool.toLLMConfig(entry), this.config.clientDefaults);
3047
+ }
3048
+ catch (err) {
3049
+ logger.warn("engine.extraction_model_build_failed", {
3050
+ extractionModel: key,
3051
+ error: err.message,
3052
+ });
3053
+ }
3054
+ }
3055
+ else {
3056
+ logger.warn("engine.extraction_model_missing", { extractionModel: key });
3057
+ }
3058
+ }
3059
+ return this.resolveAuxClient(primaryClient);
3060
+ }
2123
3061
  }
3062
+ /**
3063
+ * Builtin tool name → the feature flag that gates its visibility. A tool here
3064
+ * is hidden from the LLM when its flag resolves to false. Tools not listed are
3065
+ * unaffected. Kept beside the engine (not in builtin/index) because the flag
3066
+ * read needs the engine's scoped SettingsManager.
3067
+ */
3068
+ const TOOL_FEATURE_FLAGS = new Map([
3069
+ ["WebSearch", "web_search"],
3070
+ ["Bash", "shell_tool"],
3071
+ ]);