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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (387) 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 +56 -0
  45. package/dist/context/compaction.js +186 -0
  46. package/dist/context/manager.js +25 -4
  47. package/dist/cost-tracker.js +5 -61
  48. package/dist/credentials/cipher.d.ts +49 -0
  49. package/dist/credentials/cipher.js +45 -0
  50. package/dist/credentials/cookie-jar.d.ts +24 -0
  51. package/dist/credentials/cookie-jar.js +40 -0
  52. package/dist/credentials/index.d.ts +7 -0
  53. package/dist/credentials/index.js +5 -0
  54. package/dist/credentials/inject-credential-tool.d.ts +20 -0
  55. package/dist/credentials/inject-credential-tool.js +130 -0
  56. package/dist/credentials/store.d.ts +72 -0
  57. package/dist/credentials/store.js +184 -0
  58. package/dist/credentials/types.d.ts +56 -0
  59. package/dist/credentials/use-credential-tool.d.ts +29 -0
  60. package/dist/credentials/use-credential-tool.js +205 -0
  61. package/dist/credentials/use-gate.d.ts +56 -0
  62. package/dist/credentials/use-gate.js +52 -0
  63. package/dist/data/model-metadata.d.ts +77 -0
  64. package/dist/data/model-metadata.js +56 -0
  65. package/dist/data/model-metadata.json +216 -0
  66. package/dist/data/openrouter-models.d.ts +18 -7
  67. package/dist/data/openrouter-models.js +35 -8
  68. package/dist/engine/aux-key.d.ts +10 -0
  69. package/dist/engine/aux-key.js +11 -0
  70. package/dist/engine/dynamic-tool-defs.d.ts +19 -0
  71. package/dist/engine/dynamic-tool-defs.js +36 -0
  72. package/dist/engine/engine.d.ts +267 -138
  73. package/dist/engine/engine.js +957 -191
  74. package/dist/engine/friendly-error.d.ts +18 -0
  75. package/dist/engine/friendly-error.js +63 -0
  76. package/dist/engine/goal.d.ts +145 -0
  77. package/dist/engine/goal.js +144 -0
  78. package/dist/engine/image-policy.d.ts +13 -0
  79. package/dist/engine/image-policy.js +24 -0
  80. package/dist/engine/model-connections-pool.d.ts +17 -0
  81. package/dist/engine/model-connections-pool.js +66 -0
  82. package/dist/engine/model-facade.d.ts +10 -0
  83. package/dist/engine/model-facade.js +25 -0
  84. package/dist/engine/patch-orphaned-tools.js +3 -0
  85. package/dist/engine/resolve-llm-config.d.ts +16 -0
  86. package/dist/engine/resolve-llm-config.js +44 -0
  87. package/dist/engine/runtime.js +2 -1
  88. package/dist/engine/sandbox-cache-key.d.ts +10 -0
  89. package/dist/engine/sandbox-cache-key.js +9 -0
  90. package/dist/engine/sandbox-config.d.ts +31 -0
  91. package/dist/engine/sandbox-config.js +38 -0
  92. package/dist/engine/session-usage.d.ts +19 -0
  93. package/dist/engine/session-usage.js +25 -0
  94. package/dist/engine/steer-queue.d.ts +32 -0
  95. package/dist/engine/steer-queue.js +26 -0
  96. package/dist/engine/streaming-tool-queue.d.ts +12 -0
  97. package/dist/engine/streaming-tool-queue.js +50 -10
  98. package/dist/engine/turn-loop.d.ts +67 -3
  99. package/dist/engine/turn-loop.js +292 -41
  100. package/dist/engine/types.d.ts +175 -0
  101. package/dist/engine/types.js +13 -0
  102. package/dist/external-agents/config.d.ts +2 -0
  103. package/dist/external-agents/config.js +15 -0
  104. package/dist/external-agents/types.d.ts +31 -0
  105. package/dist/external-agents/types.js +1 -0
  106. package/dist/git/utils.js +11 -6
  107. package/dist/git/worktree.d.ts +48 -0
  108. package/dist/git/worktree.js +86 -10
  109. package/dist/hooks/goal-stop-hook.d.ts +28 -0
  110. package/dist/hooks/goal-stop-hook.js +186 -9
  111. package/dist/hooks/registry.d.ts +8 -0
  112. package/dist/hooks/registry.js +19 -0
  113. package/dist/hooks/shell-runner.js +10 -21
  114. package/dist/index.d.ts +49 -6
  115. package/dist/index.js +60 -4
  116. package/dist/llm/capabilities/param-specs.d.ts +14 -0
  117. package/dist/llm/capabilities/param-specs.js +62 -0
  118. package/dist/llm/capabilities/rules.js +4 -0
  119. package/dist/llm/capabilities/types.d.ts +10 -0
  120. package/dist/llm/client-base.d.ts +28 -1
  121. package/dist/llm/client-base.js +119 -13
  122. package/dist/llm/model-cache.js +4 -2
  123. package/dist/llm/model-pool.d.ts +14 -0
  124. package/dist/llm/model-pool.js +13 -0
  125. package/dist/llm/provider-auth.d.ts +41 -0
  126. package/dist/llm/provider-auth.js +76 -0
  127. package/dist/llm/provider-catalog.d.ts +4 -0
  128. package/dist/llm/providers/anthropic.js +64 -14
  129. package/dist/llm/providers/openai.d.ts +36 -0
  130. package/dist/llm/providers/openai.js +257 -51
  131. package/dist/llm/reasoning-setting.d.ts +3 -3
  132. package/dist/llm/reasoning-setting.js +9 -1
  133. package/dist/llm/stream-watchdog.js +5 -1
  134. package/dist/llm/token-counter.js +9 -2
  135. package/dist/llm/types.d.ts +4 -0
  136. package/dist/logging/sanitize-messages.js +22 -0
  137. package/dist/lsp/manager.d.ts +1 -1
  138. package/dist/lsp/manager.js +40 -10
  139. package/dist/model-catalog/builtin.d.ts +12 -0
  140. package/dist/model-catalog/builtin.js +407 -0
  141. package/dist/model-catalog/gen-connections.d.ts +20 -0
  142. package/dist/model-catalog/gen-connections.js +28 -0
  143. package/dist/model-catalog/index.d.ts +41 -0
  144. package/dist/model-catalog/index.js +90 -0
  145. package/dist/model-catalog/params.d.ts +20 -0
  146. package/dist/model-catalog/params.js +45 -0
  147. package/dist/model-catalog/resolve.d.ts +48 -0
  148. package/dist/model-catalog/resolve.js +33 -0
  149. package/dist/model-catalog/save-entry.d.ts +32 -0
  150. package/dist/model-catalog/save-entry.js +104 -0
  151. package/dist/model-catalog/types.d.ts +561 -0
  152. package/dist/model-catalog/types.js +93 -0
  153. package/dist/model-catalog/upsert.d.ts +9 -0
  154. package/dist/model-catalog/upsert.js +8 -0
  155. package/dist/onboarding.d.ts +12 -82
  156. package/dist/onboarding.js +61 -326
  157. package/dist/plugins/gitOps.d.ts +19 -0
  158. package/dist/plugins/gitOps.js +73 -4
  159. package/dist/plugins/installer/checkUpdate.d.ts +16 -0
  160. package/dist/plugins/installer/checkUpdate.js +32 -0
  161. package/dist/plugins/installer/codex/convertCommands.d.ts +19 -0
  162. package/dist/plugins/installer/codex/convertCommands.js +46 -0
  163. package/dist/plugins/installer/codex/convertMcp.d.ts +5 -2
  164. package/dist/plugins/installer/codex/convertMcp.js +45 -5
  165. package/dist/plugins/installer/install.js +25 -1
  166. package/dist/plugins/installer/installFromArchive.d.ts +43 -0
  167. package/dist/plugins/installer/installFromArchive.js +134 -0
  168. package/dist/plugins/installer/installFromSource.js +8 -2
  169. package/dist/plugins/installer/loadPluginAgents.js +6 -2
  170. package/dist/plugins/installer/loadPluginMcp.d.ts +9 -2
  171. package/dist/plugins/installer/loadPluginMcp.js +35 -2
  172. package/dist/plugins/installer/pruneDisabled.d.ts +24 -0
  173. package/dist/plugins/installer/pruneDisabled.js +73 -0
  174. package/dist/plugins/installer/types.d.ts +5 -2
  175. package/dist/plugins/installer/types.js +1 -0
  176. package/dist/plugins/installer/uninstall.js +4 -0
  177. package/dist/plugins/installer/unzip.d.ts +14 -0
  178. package/dist/plugins/installer/unzip.js +82 -0
  179. package/dist/plugins/installer/update.d.ts +14 -0
  180. package/dist/plugins/installer/update.js +53 -21
  181. package/dist/plugins/loadPluginHooks.d.ts +47 -1
  182. package/dist/plugins/loadPluginHooks.js +73 -1
  183. package/dist/plugins/marketplaceManager.d.ts +7 -0
  184. package/dist/plugins/marketplaceManager.js +20 -0
  185. package/dist/plugins/pluginCommandHook.js +4 -18
  186. package/dist/plugins/pluginContent.d.ts +30 -0
  187. package/dist/plugins/pluginContent.js +83 -0
  188. package/dist/plugins/pluginInstaller.d.ts +13 -0
  189. package/dist/plugins/pluginInstaller.js +42 -8
  190. package/dist/plugins/schemas.js +1 -0
  191. package/dist/plugins/types.d.ts +6 -0
  192. package/dist/preset/index.d.ts +7 -1
  193. package/dist/preset/index.js +96 -6
  194. package/dist/product/types.d.ts +1 -1
  195. package/dist/prompt/composer.d.ts +27 -0
  196. package/dist/prompt/composer.js +67 -21
  197. package/dist/prompt/instruction-scanner.js +5 -3
  198. package/dist/prompt/section-loader.js +1 -0
  199. package/dist/prompt/sections/base.md +1 -0
  200. package/dist/prompt/sections/browser.md +10 -0
  201. package/dist/prompt/sections/coding.md +4 -0
  202. package/dist/protocol/chat-session-manager.d.ts +8 -2
  203. package/dist/protocol/chat-session-manager.js +36 -0
  204. package/dist/protocol/chat-session.d.ts +65 -0
  205. package/dist/protocol/chat-session.js +94 -2
  206. package/dist/protocol/client.d.ts +27 -0
  207. package/dist/protocol/client.js +38 -0
  208. package/dist/protocol/server.d.ts +112 -0
  209. package/dist/protocol/server.js +586 -16
  210. package/dist/protocol/transport.js +3 -2
  211. package/dist/protocol/types.d.ts +46 -0
  212. package/dist/protocol/types.js +16 -0
  213. package/dist/quota/credentials.d.ts +3 -0
  214. package/dist/quota/credentials.js +80 -0
  215. package/dist/quota/index.d.ts +36 -0
  216. package/dist/quota/index.js +155 -0
  217. package/dist/quota/types.d.ts +48 -0
  218. package/dist/quota/types.js +13 -0
  219. package/dist/review/review-prompt.d.ts +28 -0
  220. package/dist/review/review-prompt.js +81 -0
  221. package/dist/run/FileRunStore.js +8 -3
  222. package/dist/run/RunApprovalBackend.js +25 -5
  223. package/dist/run/RunManager.d.ts +12 -0
  224. package/dist/run/RunManager.js +35 -0
  225. package/dist/run/factory.d.ts +1 -1
  226. package/dist/runtime/background-shell.d.ts +139 -0
  227. package/dist/runtime/background-shell.js +495 -0
  228. package/dist/runtime/output-clean.d.ts +24 -0
  229. package/dist/runtime/output-clean.js +41 -0
  230. package/dist/runtime/ring-file.d.ts +64 -0
  231. package/dist/runtime/ring-file.js +174 -0
  232. package/dist/runtime/safe-spawn.js +15 -34
  233. package/dist/runtime/spawn-common.d.ts +148 -0
  234. package/dist/runtime/spawn-common.js +303 -0
  235. package/dist/runtime/truncate-output.d.ts +22 -0
  236. package/dist/runtime/truncate-output.js +49 -0
  237. package/dist/runtime/utf8-cut.d.ts +11 -0
  238. package/dist/runtime/utf8-cut.js +23 -0
  239. package/dist/services/auto-dream.d.ts +4 -0
  240. package/dist/services/auto-dream.js +26 -26
  241. package/dist/services/diagnostics.d.ts +1 -2
  242. package/dist/services/diagnostics.js +12 -7
  243. package/dist/services/extract-memories.d.ts +14 -1
  244. package/dist/services/extract-memories.js +45 -6
  245. package/dist/services/memory-orchestrator.d.ts +21 -0
  246. package/dist/services/memory-orchestrator.js +125 -50
  247. package/dist/services/session-memory.js +24 -12
  248. package/dist/session/file-history.d.ts +124 -1
  249. package/dist/session/file-history.js +222 -6
  250. package/dist/session/memory.d.ts +116 -2
  251. package/dist/session/memory.js +250 -28
  252. package/dist/session/session-manager.d.ts +40 -0
  253. package/dist/session/session-manager.js +113 -3
  254. package/dist/session/simple-diff.d.ts +23 -0
  255. package/dist/session/simple-diff.js +84 -0
  256. package/dist/session/transcript.d.ts +25 -1
  257. package/dist/session/transcript.js +35 -2
  258. package/dist/session/undo-target.d.ts +67 -0
  259. package/dist/session/undo-target.js +144 -0
  260. package/dist/settings/disk-defaults.d.ts +9 -2
  261. package/dist/settings/disk-defaults.js +11 -2
  262. package/dist/settings/feature-flags.d.ts +64 -0
  263. package/dist/settings/feature-flags.js +61 -0
  264. package/dist/settings/manager.d.ts +68 -1
  265. package/dist/settings/manager.js +266 -29
  266. package/dist/settings/migrate-config.d.ts +45 -0
  267. package/dist/settings/migrate-config.js +125 -0
  268. package/dist/settings/schema-export.d.ts +25 -0
  269. package/dist/settings/schema-export.js +38 -0
  270. package/dist/settings/schema.d.ts +1186 -771
  271. package/dist/settings/schema.js +268 -97
  272. package/dist/skills/scanner.d.ts +9 -0
  273. package/dist/skills/scanner.js +30 -2
  274. package/dist/stt/resolve-transcribe.d.ts +31 -0
  275. package/dist/stt/resolve-transcribe.js +108 -0
  276. package/dist/stt/transcribe.d.ts +51 -0
  277. package/dist/stt/transcribe.js +65 -0
  278. package/dist/tool-system/browser-bridge.d.ts +226 -0
  279. package/dist/tool-system/browser-bridge.js +163 -0
  280. package/dist/tool-system/builtin/agent-heartbeat.d.ts +49 -0
  281. package/dist/tool-system/builtin/agent-heartbeat.js +89 -0
  282. package/dist/tool-system/builtin/agent-notifications.d.ts +12 -3
  283. package/dist/tool-system/builtin/agent-notifications.js +9 -3
  284. package/dist/tool-system/builtin/agent-output-file.d.ts +38 -0
  285. package/dist/tool-system/builtin/agent-output-file.js +72 -0
  286. package/dist/tool-system/builtin/agent-registry.d.ts +12 -0
  287. package/dist/tool-system/builtin/agent-registry.js +8 -0
  288. package/dist/tool-system/builtin/agent.d.ts +21 -1
  289. package/dist/tool-system/builtin/agent.js +489 -42
  290. package/dist/tool-system/builtin/apply-patch/applier.js +66 -8
  291. package/dist/tool-system/builtin/apply-patch/backup-targets.d.ts +10 -0
  292. package/dist/tool-system/builtin/apply-patch/backup-targets.js +30 -0
  293. package/dist/tool-system/builtin/apply-patch/index.js +0 -15
  294. package/dist/tool-system/builtin/background-jobs.d.ts +76 -0
  295. package/dist/tool-system/builtin/background-jobs.js +124 -0
  296. package/dist/tool-system/builtin/background-shell-tools.d.ts +20 -0
  297. package/dist/tool-system/builtin/background-shell-tools.js +108 -0
  298. package/dist/tool-system/builtin/background-work.d.ts +67 -0
  299. package/dist/tool-system/builtin/background-work.js +86 -0
  300. package/dist/tool-system/builtin/bash-output-style.d.ts +32 -0
  301. package/dist/tool-system/builtin/bash-output-style.js +40 -0
  302. package/dist/tool-system/builtin/bash.d.ts +5 -2
  303. package/dist/tool-system/builtin/bash.js +98 -62
  304. package/dist/tool-system/builtin/browser-tools.d.ts +33 -0
  305. package/dist/tool-system/builtin/browser-tools.js +312 -0
  306. package/dist/tool-system/builtin/cancel-goal.d.ts +31 -0
  307. package/dist/tool-system/builtin/cancel-goal.js +64 -0
  308. package/dist/tool-system/builtin/check-quota.d.ts +15 -0
  309. package/dist/tool-system/builtin/check-quota.js +34 -0
  310. package/dist/tool-system/builtin/config.js +7 -0
  311. package/dist/tool-system/builtin/cron.d.ts +7 -0
  312. package/dist/tool-system/builtin/cron.js +55 -3
  313. package/dist/tool-system/builtin/drive-claude-code.d.ts +30 -0
  314. package/dist/tool-system/builtin/drive-claude-code.js +157 -0
  315. package/dist/tool-system/builtin/edit-model-catalog.d.ts +3 -0
  316. package/dist/tool-system/builtin/edit-model-catalog.js +94 -0
  317. package/dist/tool-system/builtin/edit.js +20 -16
  318. package/dist/tool-system/builtin/eol.d.ts +29 -0
  319. package/dist/tool-system/builtin/eol.js +37 -0
  320. package/dist/tool-system/builtin/file-cache.d.ts +6 -0
  321. package/dist/tool-system/builtin/file-cache.js +8 -0
  322. package/dist/tool-system/builtin/generate-image.d.ts +35 -0
  323. package/dist/tool-system/builtin/generate-image.js +278 -50
  324. package/dist/tool-system/builtin/generate-video.d.ts +55 -0
  325. package/dist/tool-system/builtin/generate-video.js +361 -0
  326. package/dist/tool-system/builtin/glob.js +0 -7
  327. package/dist/tool-system/builtin/grep.js +6 -8
  328. package/dist/tool-system/builtin/image-providers.d.ts +86 -0
  329. package/dist/tool-system/builtin/image-providers.js +190 -0
  330. package/dist/tool-system/builtin/image-uploader.d.ts +33 -0
  331. package/dist/tool-system/builtin/image-uploader.js +74 -0
  332. package/dist/tool-system/builtin/index.d.ts +7 -1
  333. package/dist/tool-system/builtin/index.js +249 -25
  334. package/dist/tool-system/builtin/mcp-tools.js +23 -3
  335. package/dist/tool-system/builtin/memory.js +45 -7
  336. package/dist/tool-system/builtin/notebook-edit.js +0 -7
  337. package/dist/tool-system/builtin/powershell.js +4 -1
  338. package/dist/tool-system/builtin/read.js +10 -10
  339. package/dist/tool-system/builtin/repl.js +4 -1
  340. package/dist/tool-system/builtin/skill.js +9 -0
  341. package/dist/tool-system/builtin/sleep.js +3 -2
  342. package/dist/tool-system/builtin/tool-search.js +25 -7
  343. package/dist/tool-system/builtin/video-providers.d.ts +154 -0
  344. package/dist/tool-system/builtin/video-providers.js +235 -0
  345. package/dist/tool-system/builtin/web-fetch.js +12 -2
  346. package/dist/tool-system/builtin/web-search.js +21 -7
  347. package/dist/tool-system/builtin/worktree.d.ts +2 -1
  348. package/dist/tool-system/builtin/worktree.js +31 -4
  349. package/dist/tool-system/builtin/write.js +0 -6
  350. package/dist/tool-system/context.d.ts +154 -6
  351. package/dist/tool-system/executor.d.ts +3 -1
  352. package/dist/tool-system/executor.js +172 -88
  353. package/dist/tool-system/investigation-guard.js +1 -1
  354. package/dist/tool-system/mcp-manager.d.ts +42 -4
  355. package/dist/tool-system/mcp-manager.js +186 -20
  356. package/dist/tool-system/path-policy.d.ts +5 -0
  357. package/dist/tool-system/path-policy.js +307 -8
  358. package/dist/tool-system/permission.d.ts +36 -1
  359. package/dist/tool-system/permission.js +281 -69
  360. package/dist/tool-system/plan-mode-allowlist.d.ts +13 -2
  361. package/dist/tool-system/plan-mode-allowlist.js +24 -2
  362. package/dist/tool-system/registry.d.ts +1 -0
  363. package/dist/tool-system/registry.js +16 -2
  364. package/dist/tool-system/sandbox/index.d.ts +8 -0
  365. package/dist/tool-system/sandbox/index.js +7 -2
  366. package/dist/tool-system/validate-tool-metadata.d.ts +36 -0
  367. package/dist/tool-system/validate-tool-metadata.js +63 -0
  368. package/dist/types.d.ts +217 -6
  369. package/dist/updater.js +20 -9
  370. package/dist/utils/envUtils.d.ts +0 -9
  371. package/dist/utils/envUtils.js +3 -28
  372. package/dist/utils/exec.d.ts +40 -0
  373. package/dist/utils/exec.js +144 -0
  374. package/dist/utils/json.d.ts +12 -0
  375. package/dist/utils/json.js +92 -0
  376. package/dist/utils/theme.d.ts +1 -1
  377. package/dist/utils/theme.js +1 -1
  378. package/dist/utils/toolDisplay.js +0 -1
  379. package/package.json +13 -7
  380. package/dist/agent/coordinator.d.ts +0 -49
  381. package/dist/agent/coordinator.js +0 -77
  382. package/dist/settings/manager.test.js +0 -73
  383. package/dist/tool-system/builtin/remote-trigger.d.ts +0 -6
  384. package/dist/tool-system/builtin/remote-trigger.js +0 -54
  385. package/dist/tool-system/builtin/send-message.d.ts +0 -6
  386. package/dist/tool-system/builtin/send-message.js +0 -47
  387. /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 { foldRunUsage } from "./session-usage.js";
15
+ import { enqueueSteerItem, consumeSteerItems, removeSteerItem, } from "./steer-queue.js";
16
+ import { resolveSandboxConfig } from "./sandbox-config.js";
17
+ import { sandboxCacheKey } from "./sandbox-cache-key.js";
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
54
  import { detectProviderFromApiKey, buildModelPool, } from "../onboarding.js";
44
55
  import { detectPastedNoise } from "../utils/task-sanitizer.js";
45
56
  import { parseTaskWithImages, } from "./parse-task.js";
46
- import { enforceImagePolicy, byteLengthFromBase64, dropOversizedImages, } from "./image-policy.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 {
@@ -362,7 +459,12 @@ export class Engine {
362
459
  // disabledPlugins suppresses a plugin's hooks too (not just its
363
460
  // Skill-tool entries) — see loadPluginHooks. readDisabledLists reads
364
461
  // the same settings the prompt composer / tool context use.
365
- loadPluginHooks(this.hooks, this.readDisabledLists().disabledPlugins);
462
+ // disabledPluginHooks is the per-hook overlay
463
+ // (capabilityOverrides.pluginHooks); applied at construction, so a
464
+ // toggle takes effect for NEW sessions (same semantics as
465
+ // disabledPlugins itself).
466
+ const { disabledPlugins, disabledPluginHooks } = this.readDisabledLists();
467
+ loadPluginHooks(this.hooks, disabledPlugins, disabledPluginHooks);
366
468
  }
367
469
  // settings.hooks → shell-command wrappers. Chain order:
368
470
  // plugin (80) → shell (50) → code (default 0).
@@ -378,7 +480,7 @@ export class Engine {
378
480
  }
379
481
  }
380
482
  /**
381
- * Load models[] / providers[] from settings into the active ModelPool and
483
+ * Load modelConnections[] from settings into the active ModelPool and
382
484
  * resync this.config.llm with the matching entry. Called from the ctor and
383
485
  * from reloadModelPool() (e.g. after onboarding writes new entries to disk).
384
486
  */
@@ -387,54 +489,45 @@ export class Engine {
387
489
  const sm = this.getSettingsManager();
388
490
  sm.invalidate();
389
491
  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));
492
+ // Unified model catalog (统一模型接入方案 §6): register text
493
+ // connections from settings.modelConnections[] into the pool — the
494
+ // catalog-driven instance store is the sole source of model selection.
495
+ // A connection's instance id becomes its pool key. See
496
+ // docs/superpowers/specs/2026-06-15-unified-model-catalog-design.md.
497
+ const connections = settings.modelConnections;
498
+ if (Array.isArray(connections) && connections.length) {
499
+ const catalog = getMergedCatalog();
500
+ const credentials = settings.credentials;
501
+ for (const entry of modelEntriesFromConnections(connections, (Array.isArray(credentials) ? credentials : []), catalog)) {
502
+ this.modelPool.register(entry);
408
503
  }
504
+ }
505
+ const hasConnections = Array.isArray(connections) && connections.length > 0;
506
+ if (hasConnections) {
409
507
  this.modelPool.setCacheDir(defaultCacheDir());
410
508
  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.
509
+ // Resolve the active entry from settings.defaults.text, then switch the
510
+ // pool and write the resolved entry's credentials into config.llm, so the
511
+ // first run() uses the right endpoint instead of whatever env-derived
512
+ // fallback repl.ts seeded earlier.
513
+ // Sub-agents skip this resync: their llm is chosen by the parent's
514
+ // resolveChildLlm (per-role model routing). defaults.text is the *user's*
515
+ // current UI model selection and must not clobber a child's routed model —
516
+ // without this guard a role's `model: flash` is silently overridden back
517
+ // to whatever the user has active in the foreground.
423
518
  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);
519
+ const defaultText = settings.defaults?.text;
520
+ // 统一 catalog only:defaults.text 命中则用;否则回退首个已注册连接,
521
+ // 避免选未配置模型时静默沿用空种子(旧 bug:抛误导性 OPENAI_API_KEY missing)
522
+ let matchKey;
523
+ if (defaultText && this.modelPool.list().some((e) => e.key === defaultText)) {
524
+ matchKey = defaultText;
428
525
  }
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}`)));
526
+ else {
527
+ matchKey = this.modelPool.list()[0]?.key;
435
528
  }
436
- if (match) {
437
- const entry = this.modelPool.switch(match.key);
529
+ if (matchKey) {
530
+ const entry = this.modelPool.switch(matchKey);
438
531
  this.config = {
439
532
  ...this.config,
440
533
  llm: this.modelPool.toLLMConfig(entry),
@@ -443,9 +536,10 @@ export class Engine {
443
536
  }
444
537
  }
445
538
  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.
539
+ // Auto-populate pool from the configured API key when no
540
+ // modelConnections[] are configured. This lets users who only have an
541
+ // env/seed API key still use /model to switch between the provider's
542
+ // available models.
449
543
  this.autoPopulatePool(this.config.llm.apiKey, this.config.llm.baseUrl);
450
544
  }
451
545
  // Carry image-attachment settings + sampling temperature into
@@ -457,8 +551,10 @@ export class Engine {
457
551
  const modelBlock = settings.model;
458
552
  const nextDefaults = { ...(this.config.clientDefaults ?? {}) };
459
553
  let defaultsChanged = false;
460
- if (imageSettings?.detail && nextDefaults.imageDetail !== imageSettings.detail) {
461
- nextDefaults.imageDetail = imageSettings.detail;
554
+ // Migrate legacy "original" → "high" (raw settings may bypass schema).
555
+ const detail = imageSettings?.detail === "original" ? "high" : imageSettings?.detail;
556
+ if (detail && nextDefaults.imageDetail !== detail) {
557
+ nextDefaults.imageDetail = detail;
462
558
  defaultsChanged = true;
463
559
  }
464
560
  if (typeof modelBlock?.temperature === "number" &&
@@ -476,7 +572,7 @@ export class Engine {
476
572
  }
477
573
  /**
478
574
  * 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
575
+ * writes new modelConnections[] to disk so the running engine picks them
480
576
  * up without a process restart. Existing pool entries are kept (re-registering
481
577
  * the same key overwrites them), so callers don't need to clear first.
482
578
  */
@@ -490,7 +586,7 @@ export class Engine {
490
586
  }
491
587
  }
492
588
  /**
493
- * Auto-populate the model pool when settings.models[] is empty but
589
+ * Auto-populate the model pool when no modelConnections[] are configured but
494
590
  * the user has configured an API key. Detects the provider from the
495
591
  * key prefix / baseUrl and registers all its known models.
496
592
  */
@@ -528,6 +624,65 @@ export class Engine {
528
624
  setAskUser(fn) {
529
625
  this.config.askUser = fn;
530
626
  }
627
+ /**
628
+ * Inject the browser automation bridge after construction (same chicken-and-egg
629
+ * as setAskUser: the desktop host builds the bridge — which drives a webview —
630
+ * after the Engine exists). Undefined → the browser_* tools degrade with a
631
+ * clear "no browser panel" error.
632
+ */
633
+ setBrowserBridge(bridge) {
634
+ this.config.browserBridge = bridge;
635
+ }
636
+ /**
637
+ * Queue a user message to be spliced into the in-flight run for `sessionId`
638
+ * at the next turn-loop step boundary — the 不打断 steering path (vs cancel +
639
+ * resend). General-purpose: any host path (UI 引导, future agent coordination,
640
+ * external triggers) can call it. If no run is active for the session the
641
+ * message simply waits in the queue and is consumed when that session next
642
+ * runs (rare race; host normally only steers while busy). No-op on blank text.
643
+ *
644
+ * `id` is the host's stable queue-entry id. It rides through to the
645
+ * `steer_injected` event (so the host can match the injected bubble back to
646
+ * the queued draft) and is the handle `unsteer` uses to revoke a still-pending
647
+ * entry. A blank id is tolerated but means the entry can't be revoked.
648
+ */
649
+ enqueueSteer(sessionId, text, id = "") {
650
+ if (!sessionId)
651
+ return;
652
+ const q = this.steerQueueBySid.get(sessionId) ?? [];
653
+ const next = enqueueSteerItem(q, id || `steer-${q.length}`, text);
654
+ if (next === q)
655
+ return; // blank text dropped
656
+ this.steerQueueBySid.set(sessionId, next);
657
+ }
658
+ /**
659
+ * Revoke a still-pending steer entry (the 撤回 path). Returns true if it was
660
+ * removed, false if it was already consumed by the turn loop (can't take it
661
+ * back — it has been spliced into the run).
662
+ */
663
+ unsteer(sessionId, id) {
664
+ const q = this.steerQueueBySid.get(sessionId);
665
+ if (!q || q.length === 0)
666
+ return false;
667
+ const { list, removed } = removeSteerItem(q, id);
668
+ if (removed)
669
+ this.steerQueueBySid.set(sessionId, list);
670
+ return removed;
671
+ }
672
+ /** Drain + clear the steer queue for a session (turn loop consumes per step). */
673
+ consumeSteer(sessionId) {
674
+ const q = this.steerQueueBySid.get(sessionId);
675
+ if (!q || q.length === 0)
676
+ return [];
677
+ const { drained, rest } = consumeSteerItems(q);
678
+ this.steerQueueBySid.set(sessionId, rest);
679
+ return drained;
680
+ }
681
+ /** Wire the cookie→browser injection callback (InjectCredential tool). Same
682
+ * post-construction injection model as setBrowserBridge. */
683
+ setInjectCredential(fn) {
684
+ this.config.injectCredentialToBrowser = fn;
685
+ }
531
686
  /**
532
687
  * Whether this engine runs unattended (no interactive human). Used by the
533
688
  * in-process AgentServer to decide whether to wire an interactive askUser.
@@ -535,11 +690,34 @@ export class Engine {
535
690
  isHeadless() {
536
691
  return this.config.headless === true;
537
692
  }
693
+ /**
694
+ * Probe whether a session already exists on disk (its state/transcript dir is
695
+ * present). Used by the protocol server to distinguish "resume an existing
696
+ * session" from "silently create a fresh empty one" — e.g. a cron resume job
697
+ * whose target session the user deleted must fail loudly, not run its prompt
698
+ * against a blank session. A stat probe, not a load.
699
+ */
700
+ sessionExistsOnDisk(sessionId) {
701
+ return this.sessionManager.exists(sessionId);
702
+ }
538
703
  /**
539
704
  * Run a task from start to finish.
540
705
  */
541
706
  async run(task, options) {
542
- const cwd = options?.cwd ?? this.config.cwd ?? process.cwd();
707
+ // When the caller omits cwd but is resuming an existing session, recover
708
+ // that session's bound cwd from disk so a project-bound session keeps
709
+ // loading its own agents/settings/memory even if the host's UI repo
710
+ // selection has drifted to null. Only probe on omission — an explicit cwd
711
+ // always wins, and a fresh session has nothing to recover.
712
+ const sessionCwd = options?.cwd === undefined && options?.sessionId
713
+ ? this.sessionManager.readCwd(options.sessionId)
714
+ : undefined;
715
+ const cwd = resolveRunCwd({
716
+ optionCwd: options?.cwd,
717
+ sessionCwd,
718
+ configCwd: this.config.cwd,
719
+ processCwd: process.cwd(),
720
+ });
543
721
  // Wrap the caller's onStream so we can intercept `task_update`
544
722
  // events emitted by TodoWrite and keep an in-engine snapshot.
545
723
  // TaskGuard reads this snapshot at turn end to decide whether to
@@ -701,6 +879,22 @@ export class Engine {
701
879
  permissionMode: this.config.permissionMode ?? "acceptEdits",
702
880
  }),
703
881
  spawn: async (req) => {
882
+ // Anchor this sub-agent in the PARENT transcript at spawn time — before
883
+ // it runs, so it's recorded whether it later completes, is interrupted,
884
+ // or still runs. Replay reads these anchors to rebuild sub-agent cards
885
+ // from sessions/<agentId>/ (agentId === childSid); without it a
886
+ // backgrounded sub-agent leaves no parent-transcript trace and vanishes
887
+ // on reopen. Only on a fresh spawn (not a resume/continuation, which
888
+ // already has its anchor). Guarded so a transcript hiccup never breaks
889
+ // the spawn.
890
+ if (!req.resumeSessionId) {
891
+ try {
892
+ session.transcript.appendSubagent(req.agentId, undefined, req.description);
893
+ }
894
+ catch {
895
+ /* anchor is best-effort; never block the spawn */
896
+ }
897
+ }
704
898
  // No nested agents. Strip Agent / AgentStatus / AgentCancel from the
705
899
  // child's tool pool so the LLM can't spawn grandchildren — matches
706
900
  // Claude Code's ALL_AGENT_DISALLOWED_TOOLS approach. Without this
@@ -733,6 +927,7 @@ export class Engine {
733
927
  sessionStorageDir: this.config.sessionStorageDir,
734
928
  headless: this.config.headless,
735
929
  readOnlySession: req.readOnlySession,
930
+ skillAllowlist: req.skillAllowlist,
736
931
  sandbox: this.config.sandbox,
737
932
  // Subagents inherit the parent's scope: a child runs in the same
738
933
  // cwd/session, so it should see the same config layers the parent did.
@@ -770,12 +965,41 @@ export class Engine {
770
965
  // child.run() establishes its own runWithSid scope internally, so
771
966
  // child log lines route to the child's sid and parent's ALS
772
967
  // binding is unaffected when control returns here.
773
- const result = await child.run(req.prompt, { signal: req.signal, onStream: childStream });
774
- return result.text;
968
+ //
969
+ // agent_id === childSid: cold-start the child UNDER its agentId as the
970
+ // session id (run() shape (2): a fresh sid the host wants materialized),
971
+ // so the session persists at sessions/<agentId>/ and AgentSendInput can
972
+ // later resume it by agentId with no extra id→sid mapping. When
973
+ // resumeSessionId is set we resume that existing session instead —
974
+ // run() detects the on-disk session and replays its full transcript
975
+ // (the CC continuation model; see AgentSendInput).
976
+ const childSessionId = req.resumeSessionId ?? req.agentId;
977
+ const result = await child.run(req.prompt, {
978
+ signal: req.signal,
979
+ onStream: childStream,
980
+ sessionId: childSessionId,
981
+ });
982
+ return { text: result.text, sessionId: result.sessionId };
775
983
  },
984
+ sessionExists: (sessionId) => this.sessionManager.exists(sessionId),
776
985
  };
777
- const sandboxConfig = this.config.sandbox ??
778
- defaultSandboxConfig(this.config.headless ? "auto" : "off");
986
+ // Priority: config.sandbox → project settings.sandbox → global → per-run
987
+ // default. Read UNMERGED per-scope (getForScope) so a project that wrote no
988
+ // sandbox genuinely follows global, rather than inheriting global's mode and
989
+ // looking like it set one. Fixes "项目级配了不生效" + the scope model.
990
+ let projectSandbox;
991
+ let globalSandbox;
992
+ try {
993
+ const sm = this.getSettingsManager();
994
+ if (this.config.isSubAgent !== true) {
995
+ projectSandbox = sm.getForScope("project", cwd).sandbox;
996
+ }
997
+ globalSandbox = sm.getForScope("user").sandbox;
998
+ }
999
+ catch {
1000
+ // settings unavailable → fall through to per-run default
1001
+ }
1002
+ const sandboxConfig = resolveSandboxConfig(this.config.sandbox, projectSandbox, globalSandbox, this.config.headless === true);
779
1003
  // A2: explicit sandbox modes (seatbelt, bwrap) must fail closed
780
1004
  // per standard §S4. resolveSandboxBackend throws when an explicit
781
1005
  // mode is unavailable on this host; we let it propagate. The
@@ -789,6 +1013,17 @@ export class Engine {
789
1013
  const sandboxBackend = this.runtime
790
1014
  ? await this.runtime.resolveSandbox(sandboxConfig, cwd)
791
1015
  : await this.resolveSandboxWithoutRuntime(sandboxConfig, cwd);
1016
+ // Observability: surface what sandbox actually applied this run — the
1017
+ // configured mode vs the resolved backend (auto may downgrade to off when
1018
+ // no OS backend is available) + the network policy. Without this you can't
1019
+ // tell whether shell commands were isolated /网络放没放. One line per run.
1020
+ logger.info("sandbox.resolved", {
1021
+ mode: sandboxConfig.mode,
1022
+ backend: sandboxBackend.name,
1023
+ isolated: sandboxBackend.name !== "off",
1024
+ network: sandboxConfig.network,
1025
+ cwd,
1026
+ });
792
1027
  // sessionId is filled in after the session bundle is resolved below
793
1028
  // (the session may be cold-started or resumed). Until then this is
794
1029
  // intentionally shaped as a mutable local; we treat it as immutable
@@ -797,7 +1032,13 @@ export class Engine {
797
1032
  ...this.buildToolContext(),
798
1033
  subAgentSpawner,
799
1034
  agentDefinitions: this.getAgentDefinitions(cwd),
800
- sandbox: sandboxBackend,
1035
+ // Stamp the resolved network policy onto the backend the tools see so
1036
+ // Bash can surface "网络 deny" on its result. Shallow-copy (don't mutate
1037
+ // the cached backend) — `wrap`/`hintForBlockedOutput` are plain function
1038
+ // properties and survive the spread. Off keeps network undefined.
1039
+ sandbox: sandboxBackend.name === "off"
1040
+ ? sandboxBackend
1041
+ : { ...sandboxBackend, network: sandboxConfig.network },
801
1042
  cwd,
802
1043
  // TodoWrite reads this to push task_update events independently
803
1044
  // of its return value, so the UI's pinned task panel refreshes
@@ -818,10 +1059,24 @@ export class Engine {
818
1059
  // text block (when prose is present) followed by one image block per
819
1060
  // attachment — the provider-specific clients translate this to OpenAI
820
1061
  // `image_url` or Anthropic `{type:image, source:base64}` downstream.
1062
+ // When an attached image came from a workspace FILE (the desktop composer's
1063
+ // path-attach flow sets ParsedImage.name = the absolute path), surface that
1064
+ // path to the model as text. The image bytes still ride along for vision,
1065
+ // but tools that operate on files — GenerateImage(referenceImages),
1066
+ // Read, etc. — need the on-disk path, not just the pixels. Without this the
1067
+ // path the composer already knew was silently dropped, and the model would
1068
+ // answer "图片没落到项目文件夹,找不到路径" (the seedance 图生图 dead-end).
1069
+ // Only names that resolve to an existing file qualify; a pasted screenshot
1070
+ // whose name is just "screenshot.png" is not a path and is left out.
1071
+ const attachedPaths = collectAttachedImagePaths(parsedTask.images, (name) => (isAbsolute(name) ? name : join(cwd, name)), existsSync);
1072
+ const pathHint = attachedPaths.length > 0
1073
+ ? `\n\n<attached-image-paths>\n${attachedPaths.join("\n")}\n</attached-image-paths>\n` +
1074
+ `(上面附带的图片在工作区的真实路径,如需把它们作为工具输入(例如 GenerateImage 的 referenceImages、图生图参考图),直接使用这些路径。)`
1075
+ : "";
821
1076
  const userMessageContent = parsedTask.hasImages
822
1077
  ? [
823
- ...(parsedTask.text
824
- ? [{ type: "text", text: parsedTask.text }]
1078
+ ...(parsedTask.text || pathHint
1079
+ ? [{ type: "text", text: `${parsedTask.text}${pathHint}` }]
825
1080
  : []),
826
1081
  ...parsedTask.images.map((img) => ({
827
1082
  type: "image",
@@ -872,7 +1127,7 @@ export class Engine {
872
1127
  // Append new user message
873
1128
  const userMsg = { role: "user", content: userMessageContent };
874
1129
  messages.push(userMsg);
875
- session.transcript.appendMessage("user", userMessageContent);
1130
+ session.transcript.appendMessage("user", userMessageContent, { injected: options?.injected === true });
876
1131
  // Flush "active" status to disk immediately. resume() set it in memory
877
1132
  // (session-manager.ts), but without this write the on-disk state.json
878
1133
  // still shows the previous run's terminal reason — so any external
@@ -895,6 +1150,12 @@ export class Engine {
895
1150
  session.state.summary = summarySrc.slice(0, 80).replace(/\n/g, " ");
896
1151
  this.sessionManager.saveState(session.state);
897
1152
  }
1153
+ // Bump the conversation-turn counter: this user message starts a new turn.
1154
+ // One user message = one turn, regardless of how many turn-loop iterations
1155
+ // or tool calls it spans. File-history snapshots taken below are tagged
1156
+ // with this value so `/undo` reverts exactly this turn's file changes.
1157
+ // (Both resume and cold-start paths converge here.)
1158
+ session.state.turnSeq = (session.state.turnSeq ?? 0) + 1;
898
1159
  // Stamp the resolved session id for downstream logging.
899
1160
  //
900
1161
  // `setCurrentSid` updates the module-level fallback so any code path
@@ -1043,6 +1304,9 @@ export class Engine {
1043
1304
  toolExecutor.setContext(toolCtx);
1044
1305
  const contextManager = new ContextManager({
1045
1306
  maxTokens: this.resolveMaxContextTokens(),
1307
+ // Drop undefined fields so they don't clobber ContextManager defaults
1308
+ // (spread of `{x: undefined}` would override the default with undefined).
1309
+ ...Object.fromEntries(Object.entries(this.resolveContextRatios()).filter(([, v]) => v !== undefined)),
1046
1310
  });
1047
1311
  this.lastContextManager = contextManager;
1048
1312
  const { disabledSkills, disabledPlugins } = this.readDisabledLists();
@@ -1057,6 +1321,8 @@ export class Engine {
1057
1321
  instructionOptions: { compatFileNames: compatFileNamesFrom(this.config.instructions) },
1058
1322
  disabledSkills,
1059
1323
  disabledPlugins,
1324
+ skillAllowlist: this.config.skillAllowlist,
1325
+ memoriesMaxAgeDays: this.readMemoriesConfig()?.maxAge,
1060
1326
  });
1061
1327
  // Connect MCP servers (if configured and not already connected).
1062
1328
  // B1: prefer the Runtime-owned MCPManager so all sessions in a
@@ -1071,7 +1337,7 @@ export class Engine {
1071
1337
  else {
1072
1338
  this.mcpManager = new MCPManager(this.toolRegistry);
1073
1339
  }
1074
- await this.mcpManager.connectAll(mcpServers);
1340
+ await this.mcpManager.connectAll(mcpServers, this);
1075
1341
  }
1076
1342
  // Parallelize slow initialization:
1077
1343
  // 1. createLLMClient — network handshake (started earlier)
@@ -1114,14 +1380,45 @@ export class Engine {
1114
1380
  const disabledBuiltins = new Set(Object.keys(builtinOverride).filter((name) => builtinOverride[name] === "off" && registryNames.has(name)));
1115
1381
  toolCtx.disabledBuiltins = disabledBuiltins;
1116
1382
  }
1383
+ // MCP tool exposure is per-SESSION even though the pool/registry are
1384
+ // worker-shared (B1): a server connected by another project's session
1385
+ // registers its tools into the SHARED registry, and without this filter
1386
+ // they leaked into every session (e.g. chrome-devtools tools showing up
1387
+ // in a project that never enabled the plugin). Keep an MCP tool only when
1388
+ // its server is in THIS session's merged config.mcpServers — which
1389
+ // already folds the project's capabilityOverrides. Gated on the config
1390
+ // being present: engines without one (sub-agents, bare tests) have no
1391
+ // MCP tools in their private registries anyway.
1392
+ const allowedMcpServers = new Set(Object.entries(this.config.mcpServers ?? {})
1393
+ .filter(([, c]) => c.enabled !== false)
1394
+ .map(([n]) => n));
1395
+ toolCtx.allowedMcpServers = allowedMcpServers;
1396
+ const mcpVisible = (toolName) => {
1397
+ const reg = this.toolRegistry.getTool(toolName);
1398
+ return reg?.source !== "mcp" || allowedMcpServers.has(reg?.serverName ?? "");
1399
+ };
1400
+ // Feature-flag visibility: a builtin mapped in TOOL_FEATURE_FLAGS is
1401
+ // hidden when its flag resolves to false (default-on flags only hide when
1402
+ // explicitly disabled, so zero regression out of the box). Read once per
1403
+ // turn so flipping a flag in settings takes effect on the NEXT message,
1404
+ // like the other capability kinds.
1405
+ const featureFlags = this.readFeatureFlags();
1117
1406
  const allToolDefs = applyBuiltinOverrideVisibility(this.toolRegistry.getToolDefinitions(), builtinOverride)
1407
+ .filter((t) => mcpVisible(t.name))
1118
1408
  .filter((t) => {
1119
1409
  const guard = BUILTIN_TOOL_GUARDS.get(t.name);
1120
1410
  return guard ? guard(guardCwd) : true;
1121
1411
  })
1122
- .map((t) => t.name === "Agent"
1123
- ? { ...t, description: agentToolDefWithTypes(toolCtx.agentDefinitions).description }
1124
- : t);
1412
+ .filter((t) => {
1413
+ const flag = TOOL_FEATURE_FLAGS.get(t.name);
1414
+ return flag ? isFeatureEnabled(featureFlags, flag) : true;
1415
+ })
1416
+ // Dynamic per-engine bits the static defs can't carry: the Agent tool's
1417
+ // agent_type enum + listing, and the image/video provider names. See
1418
+ // applyDynamicToolDef — forwarding only the Agent description (dropping
1419
+ // its rebuilt inputSchema) used to strip the agent_type enum, so the
1420
+ // model omitted agent_type and configured roles never applied.
1421
+ .map((t) => applyDynamicToolDef(t, toolCtx.agentDefinitions, guardCwd));
1125
1422
  // In plan mode, only expose read-only/planning tools so the model won't
1126
1423
  // attempt writes. Shared with executor.ts's execution gate via
1127
1424
  // PLAN_MODE_ALLOWED_TOOLS so what the model SEES and what the executor
@@ -1130,12 +1427,13 @@ export class Engine {
1130
1427
  const toolDefs = this.planMode
1131
1428
  ? allToolDefs.filter((t) => PLAN_MODE_ALLOWED_TOOLS.has(t.name))
1132
1429
  : allToolDefs;
1133
- const [llmClient, systemPrompt, systemContext] = await Promise.all([
1430
+ const [llmClient, fullSystemPrompt, dynamicContextMsg] = await Promise.all([
1134
1431
  llmClientPromise,
1432
+ // System prompt is now the STABLE prefix only — skills + git status moved
1433
+ // out to a trailing per-turn message so they no longer bust the cache.
1135
1434
  promptComposer.buildSystemPrompt(toolDefs),
1136
- promptComposer.buildSystemContext(),
1435
+ promptComposer.buildDynamicContextMessage(),
1137
1436
  ]);
1138
- const fullSystemPrompt = [systemPrompt, systemContext].filter(Boolean).join("\n\n");
1139
1437
  // Prepend userContext (CLAUDE.md) as first message (sync, fast)
1140
1438
  const userContextMsg = promptComposer.buildUserContextMessage();
1141
1439
  if (userContextMsg) {
@@ -1154,6 +1452,12 @@ export class Engine {
1154
1452
  // reminder → user request.
1155
1453
  messages.splice(messages.length - 1, 0, lifecycleReminder);
1156
1454
  }
1455
+ // Volatile context (skills + git status) goes at the very END — after the
1456
+ // user task — so it sits past the conversation's cache breakpoint. A change
1457
+ // here (new skill, edited file) never invalidates the cached history prefix.
1458
+ if (dynamicContextMsg) {
1459
+ messages.push(dynamicContextMsg);
1460
+ }
1157
1461
  this.lastSessionId = session.state.sessionId;
1158
1462
  this.lastMessages = messages;
1159
1463
  // Wire up LLM summarization for context compaction
@@ -1185,8 +1489,13 @@ export class Engine {
1185
1489
  });
1186
1490
  return summaryResponse.text;
1187
1491
  });
1188
- // Create components (requires resolved llmClient)
1492
+ // Create components (requires resolved llmClient).
1189
1493
  const modelFacade = new ModelFacade(llmClient, session.transcript);
1494
+ // Session-cumulative usage baseline: the LLM client is recreated per run
1495
+ // (its getUsage() counts only THIS run), so to accumulate across runs we
1496
+ // capture the persisted total at run start and fold this run's usage onto
1497
+ // it (see foldRunUsage). Snapshot now, before any turn boundary fires.
1498
+ const usageBaseline = { ...session.state.tokenUsage };
1190
1499
  // Wire getOutputTokens for token budget tracking
1191
1500
  modelFacade.getOutputTokens = () => {
1192
1501
  const usage = llmClient.getUsage();
@@ -1221,14 +1530,39 @@ export class Engine {
1221
1530
  // File history: auto-backup before Write/Edit
1222
1531
  const sessionDir = join(this.config.sessionStorageDir ?? join(userHome(), ".code-shell", "sessions"), session.state.sessionId);
1223
1532
  const fileHistory = FileHistory.loadFromDir(sessionDir);
1224
- this.hooks.register("on_tool_start", async (context) => {
1533
+ // Keep a reference so we can unregister in the finally below. Registering an
1534
+ // anonymous handler every run() leaks: unregister matches by handler
1535
+ // identity, so without a stored reference each run stacks another identical
1536
+ // on_tool_start handler that fires (and re-snapshots) on every tool forever.
1537
+ const fileHistoryHandler = async (context) => {
1225
1538
  const toolName = context.data?.toolName;
1226
1539
  const args = context.data?.args;
1540
+ // Tag snapshots with the current turn (stamped above before any tool
1541
+ // runs) so turn-level /undo can revert just this user message's edits.
1542
+ const turnSeq = session.state.turnSeq;
1227
1543
  if ((toolName === "Write" || toolName === "Edit") && args?.file_path) {
1228
- fileHistory.saveSnapshot(args.file_path);
1544
+ const path = args.file_path;
1545
+ // saveSnapshot returns null when the file does not exist yet — this
1546
+ // hook runs BEFORE the tool, so a null here means the turn is CREATING
1547
+ // the file. Record it (idempotent per turn) so /undo can delete it and
1548
+ // /redo can recreate it.
1549
+ if (fileHistory.saveSnapshot(path, turnSeq) === null && turnSeq !== undefined) {
1550
+ fileHistory.recordCreated(path, turnSeq);
1551
+ }
1552
+ }
1553
+ else if (toolName === "ApplyPatch" && typeof args?.patch === "string") {
1554
+ // ApplyPatch mutates files too, so /undo must see them. Snapshot every
1555
+ // existing file the patch updates or deletes (adds have no prior
1556
+ // content). Resolve relative patch paths against the engine cwd, the
1557
+ // same base ApplyPatch itself uses.
1558
+ const cwd = this.config.cwd ?? process.cwd();
1559
+ for (const target of patchBackupTargets(args.patch, cwd)) {
1560
+ fileHistory.saveSnapshot(target, turnSeq);
1561
+ }
1229
1562
  }
1230
1563
  return {};
1231
- }, 100, "file_history_backup");
1564
+ };
1565
+ this.hooks.register("on_tool_start", fileHistoryHandler, 100, "file_history_backup");
1232
1566
  // Hook: agent start
1233
1567
  await this.emitHook("on_agent_start", {
1234
1568
  sessionId: session.state.sessionId,
@@ -1238,20 +1572,70 @@ export class Engine {
1238
1572
  // Goal mode: register a GoalStopHook for the lifetime of THIS run so the
1239
1573
  // turn loop keeps going until the session model judges the goal met.
1240
1574
  // 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).
1575
+ // send doesn't inherit a stale goal. The judge runs on `auxSummaryClient`
1576
+ // — the same cheap aux model used for summarize/compaction not the
1577
+ // (potentially expensive) session model: "is this goal met?" is a classic
1578
+ // aux-tier task, and a goal run can invoke the judge up to maxStopBlocks
1579
+ // times.
1243
1580
  // Normalize the raw goal (string | GoalConfig) once at the run boundary;
1244
1581
  // everything inward uses the GoalConfig. normalizeGoal() returns undefined
1245
1582
  // when there's effectively no goal (empty objective).
1246
- const normalizedGoal = normalizeGoal(options?.goal ?? this.config.goal);
1583
+ //
1584
+ // PERSISTENT GOAL (CC /goal style): a goal set on one send survives across
1585
+ // later sends and manual interrupts until met or cleared. Resolution:
1586
+ // 1. options.goal — this send explicitly sets/replaces the goal.
1587
+ // 2. session.state.activeGoal — a goal set on an earlier send.
1588
+ // 3. config.goal — engine-level default (rare; e.g. headless).
1589
+ // When (1) supplies a goal that differs from the stored one we REPLACE the
1590
+ // persisted active goal (one active goal per session) and announce it. A
1591
+ // bare send with no options.goal inherits the stored active goal so the
1592
+ // model keeps working toward it — that's what makes it persistent.
1593
+ const explicitGoal = normalizeGoal(options?.goal);
1594
+ const storedGoal = this.config.isSubAgent !== true ? session.state.activeGoal : undefined;
1595
+ if (explicitGoal && this.config.isSubAgent !== true) {
1596
+ const replaced = !!storedGoal && storedGoal.objective !== explicitGoal.objective;
1597
+ // Stamp WHEN this goal was set so the judge can anchor relative deadlines
1598
+ // ("做到3点") to the set time, not "now" — else once the clock passes the
1599
+ // deadline the judge could read "3点" as tomorrow's and never stop. A new
1600
+ // or changed objective gets a fresh stamp; re-sending the SAME objective
1601
+ // keeps the original anchor (the goal continues, the user didn't restate a
1602
+ // new deadline). User input never carries setAtMs, so we set it here.
1603
+ explicitGoal.setAtMs = resolveGoalSetAt(explicitGoal.objective, storedGoal, Date.now());
1604
+ session.state.activeGoal = explicitGoal;
1605
+ this.sessionManager.saveState(session.state);
1606
+ options?.onStream?.({
1607
+ type: "goal_set",
1608
+ objective: explicitGoal.objective,
1609
+ replaced,
1610
+ });
1611
+ }
1612
+ const normalizedGoal = explicitGoal ?? storedGoal ?? normalizeGoal(this.config.goal);
1247
1613
  let goalHookHandler = null;
1248
1614
  if (normalizedGoal && this.config.isSubAgent !== true) {
1249
1615
  goalHookHandler = createGoalStopHook({
1250
1616
  goal: normalizedGoal,
1251
- llm: llmClient,
1617
+ llm: auxSummaryClient,
1252
1618
  log: logger,
1619
+ // Clear the persisted active goal the moment the judge says it's met,
1620
+ // so a later bare send doesn't re-inherit a satisfied goal. The hook
1621
+ // calls this from inside its met branch (single source of truth for
1622
+ // "goal achieved"); engine owns the persistence side-effect.
1623
+ onMet: () => {
1624
+ if (session.state.activeGoal) {
1625
+ session.state.activeGoal = undefined;
1626
+ this.sessionManager.saveState(session.state);
1627
+ }
1628
+ },
1629
+ // Re-read the persisted goal each turn so a mid-run 清除 (clearGoal
1630
+ // wrote state.json but this hook's frozen goal copy + the closure's
1631
+ // in-RAM session are untouched) actually stops the judge. Reads disk
1632
+ // via readActiveGoal — authoritative and independent of which session
1633
+ // instance the run closure holds.
1634
+ isGoalActive: (sid) => this.sessionManager.readActiveGoal(sid) !== undefined,
1253
1635
  });
1254
1636
  this.hooks.register("on_stop", goalHookHandler, 0, "goal-stop");
1637
+ // Expose for clearGoal() mid-run. Already guarded by isSubAgent above.
1638
+ this.activeGoalHook = goalHookHandler;
1255
1639
  }
1256
1640
  // Surface compaction events to the UI so the user knows when context was trimmed.
1257
1641
  // Buffer the most recent event so TurnLoop can drain it and emit the
@@ -1278,6 +1662,22 @@ export class Engine {
1278
1662
  pendingCompactInfo = null;
1279
1663
  return info;
1280
1664
  },
1665
+ consumeSteer: () => this.consumeSteer(sid),
1666
+ // Clear the persisted goal for a self-reported completion / confirmed
1667
+ // cancel. Clears the in-RAM session's activeGoal (so THIS run's later
1668
+ // turns don't re-arm) AND persists it, and drops the in-flight stop
1669
+ // hook so nothing re-blocks the stop we're about to return.
1670
+ clearPersistedGoal: () => {
1671
+ if (session.state.activeGoal !== undefined) {
1672
+ session.state.activeGoal = undefined;
1673
+ this.sessionManager.saveState(session.state);
1674
+ }
1675
+ if (goalHookHandler) {
1676
+ this.hooks.unregister("on_stop", goalHookHandler);
1677
+ if (this.activeGoalHook === goalHookHandler)
1678
+ this.activeGoalHook = null;
1679
+ }
1680
+ },
1281
1681
  ctxOverheadStore: {
1282
1682
  get: (s) => this.ctxOverheadBySid.get(s) ?? 0,
1283
1683
  set: (s, n) => {
@@ -1285,8 +1685,20 @@ export class Engine {
1285
1685
  },
1286
1686
  },
1287
1687
  }, {
1288
- maxTurns: this.config.maxTurns ?? 100,
1289
- maxToolCallsPerTurn: this.config.maxToolCallsPerTurn ?? 10,
1688
+ // Goal mode raises the turn ceiling: an unattended goal run keeps
1689
+ // getting re-blocked by the stop-hook until it's done, and the 100
1690
+ // interactive default would silently truncate a long objective. The
1691
+ // real backstops are the goal token/time budgets + maxStopBlocks.
1692
+ maxTurns: resolveMaxTurns(this.config.maxTurns, normalizedGoal),
1693
+ // Consecutive stop-block cap: config override > goal.maxStopBlocks >
1694
+ // GOAL_DEFAULT_MAX_STOP_BLOCKS(25). The old hardcoded 8 was too tight
1695
+ // for complex goals that legitimately get re-blocked while advancing.
1696
+ maxStopBlocks: resolveMaxStopBlocks(this.config.maxStopBlocks, normalizedGoal),
1697
+ // 25 (was 10): modern models routinely batch >10 parallel tool calls
1698
+ // (e.g. reading a dozen files at once). At 10 the excess was silently
1699
+ // dropped; the turn loop now also warns the model when it caps, but a
1700
+ // higher ceiling avoids the round-trip in the common case. (B-3)
1701
+ maxToolCallsPerTurn: this.config.maxToolCallsPerTurn ?? 25,
1290
1702
  onStream: options?.onStream,
1291
1703
  signal: options?.signal,
1292
1704
  // Goal mode: the active goal is surfaced to the on_stop handler via
@@ -1298,62 +1710,99 @@ export class Engine {
1298
1710
  // completed run.
1299
1711
  onTurnBoundary: (turnCount) => {
1300
1712
  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
- };
1713
+ // baseline + this run's running total (idempotent per boundary,
1714
+ // accumulates across runs; carries cacheRead/cacheCreation too).
1715
+ session.state.tokenUsage = foldRunUsage(usageBaseline, modelFacade.getUsage());
1716
+ // Surface the session-cumulative cache counts to the UI (the "本会话
1717
+ // 累计命中率" tooltip). Separate from turn-loop's per-response
1718
+ // usage_update (which drives the live context reading).
1719
+ const cum = session.state.tokenUsage;
1720
+ options?.onStream?.({
1721
+ type: "usage_update",
1722
+ promptTokens: cum.promptTokens,
1723
+ sessionPromptTokens: cum.promptTokens,
1724
+ sessionCacheReadTokens: cum.cacheReadTokens ?? 0,
1725
+ sessionCacheCreationTokens: cum.cacheCreationTokens ?? 0,
1726
+ });
1307
1727
  if (this.config.costStore) {
1308
1728
  session.state.costState = this.config.costStore.serialize();
1309
1729
  }
1310
1730
  this.sessionManager.saveState(session.state);
1311
1731
  },
1312
1732
  });
1733
+ // Expose this run's loop for mid-run extension (TODO 3.1). Top-level only —
1734
+ // a sub-agent's loop is its own concern and isn't user-extendable.
1735
+ if (this.config.isSubAgent !== true)
1736
+ this.activeTurnLoop = turnLoop;
1737
+ // Expose this run's session bundle so a mid-run clearGoal() wipes the goal
1738
+ // on the very instance this loop keeps saving (see field doc). Top-level
1739
+ // only — sub-agents don't carry user-clearable persistent goals.
1740
+ if (this.config.isSubAgent !== true)
1741
+ this.activeRunSession = session;
1313
1742
  let result;
1314
1743
  try {
1315
1744
  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.
1745
+ // ── Headless: drain background sub-agents before resolving ───────
1746
+ // Unified background-work model (2026-06-17): the engine NO LONGER parks
1747
+ // every run waiting on background work. Background work (sub-agents,
1748
+ // video polls, shells) ends the turn, yields, and is picked up later by
1749
+ // the server's notification-wakeup path (maybeWakeIdleSession). The
1750
+ // INTERACTIVE path relies on that wakeup + a run-boundary re-check.
1324
1751
  //
1325
- // Top-level only: a sub-agent must never wait on grandchildren (and
1326
- // nested agents are disabled anyway). `signal` aborts the wait.
1752
+ // HEADLESS is the exception: a one-shot `engine.run` whose caller takes
1753
+ // `result.text` as THE answer (automation / SDK) has no later turn to
1754
+ // pick up a wakeup — so it must wait, before resolving, until its own
1755
+ // background SUB-AGENTS finish and summarize. Only sub-agents (their
1756
+ // summary IS part of this run's result), NOT shells (a dev server never
1757
+ // exits → would hang headless forever) and NOT video (a long render the
1758
+ // one-shot run shouldn't block on). This replaces the old for(;;) park
1759
+ // (s-mpvf4rsj-bb6e4639 invariant) for the headless case only.
1327
1760
  const sid = session.state.sessionId;
1328
1761
  const isTopLevel = this.config.isSubAgent !== true;
1329
- if (isTopLevel) {
1762
+ if (isTopLevel && this.isHeadless()) {
1330
1763
  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) {
1764
+ // Loop: a summarize turn can spawn a NEW background sub-agent; keep
1765
+ // draining + summarizing until none remain. turnCount accumulates, so
1766
+ // the turn-loop's maxTurns still bounds runaway re-summarization.
1767
+ for (;;) {
1768
+ while (!aborted && asyncAgentRegistry.hasRunningForSession(sid)) {
1769
+ aborted = await this.waitForBackgroundAgentChange(sid, options?.signal);
1770
+ }
1771
+ let pending = notificationQueue.drainAll(sid);
1772
+ if (aborted && pending.length === 0) {
1773
+ // Abort race: an agent calls markCompleted (registry notify) and only
1774
+ // THEN enqueue (queue notify) as two separate statements. If the abort
1775
+ // fired before that agent's completion `.then` ran, the while above
1776
+ // exited on `aborted`, this drainAll caught nothing, and a naive
1777
+ // `break` here would drop the agent's output. Give still-settling
1778
+ // agents a bounded window to finish enqueuing, then drain once more.
1779
+ // Each wait is timeout-bounded so a genuinely stuck (never-completing)
1780
+ // agent can't hang abort cleanup forever — we'd rather lose nothing in
1781
+ // the common case and not hang in the pathological one.
1782
+ for (let i = 0; i < 20 && asyncAgentRegistry.hasRunningForSession(sid); i++) {
1783
+ const changed = await this.waitForBackgroundAgentChangeOrTimeout(sid, 25);
1784
+ if (!changed)
1785
+ break; // timed out with no state change → stop waiting
1786
+ }
1787
+ pending = notificationQueue.drainAll(sid);
1788
+ if (pending.length === 0)
1789
+ break;
1790
+ }
1791
+ else if (pending.length === 0) {
1792
+ break;
1793
+ }
1339
1794
  const injected = {
1340
1795
  role: "user",
1341
1796
  content: `<system-reminder>\n${buildNotificationMessage(pending)}\n</system-reminder>`,
1342
1797
  };
1343
1798
  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);
1799
+ // Mark injected: a synthetic notification, not the user's own input —
1800
+ // the disk reader drops it on replay so no phantom user bubble.
1801
+ session.transcript.appendMessage(injected.role, injected.content, { injected: true });
1349
1802
  result = { ...result, messages: [...result.messages, injected] };
1803
+ break;
1350
1804
  }
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
- }
1805
+ result = await turnLoop.run([...result.messages, injected]);
1357
1806
  }
1358
1807
  }
1359
1808
  }
@@ -1362,6 +1811,15 @@ export class Engine {
1362
1811
  // long-lived engine doesn't keep blocking stops.
1363
1812
  if (goalHookHandler)
1364
1813
  this.hooks.unregister("on_stop", goalHookHandler);
1814
+ if (this.activeGoalHook === goalHookHandler)
1815
+ this.activeGoalHook = null;
1816
+ if (this.activeTurnLoop === turnLoop)
1817
+ this.activeTurnLoop = null;
1818
+ if (this.activeRunSession === session)
1819
+ this.activeRunSession = null;
1820
+ // Run-scoped too: this handler is re-registered every run(), so it must be
1821
+ // dropped here or it stacks duplicates that re-snapshot on every tool.
1822
+ this.hooks.unregister("on_tool_start", fileHistoryHandler);
1365
1823
  }
1366
1824
  this.lastMessages = result.messages;
1367
1825
  this.compactedMessagesBySession.set(session.state.sessionId, this.stripUserContextMessage(result.messages, userContextMsg));
@@ -1404,6 +1862,12 @@ export class Engine {
1404
1862
  void buildSessionTitle(auxSummaryClient, firstUserText, result.text)
1405
1863
  .then((title) => {
1406
1864
  if (title) {
1865
+ // Persist the title so it survives a localStorage wipe / disk
1866
+ // rebuild — it used to live only in the renderer's localStorage
1867
+ // index. This .then resolves AFTER the saveState below (:1892), so
1868
+ // it must save again itself rather than rely on that write.
1869
+ session.state.title = title;
1870
+ this.sessionManager.saveState(session.state);
1407
1871
  onStream({
1408
1872
  type: "session_title",
1409
1873
  sessionId: session.state.sessionId,
@@ -1421,12 +1885,9 @@ export class Engine {
1421
1885
  // distinction and misled anyone reading state.json.
1422
1886
  session.state.turnCount = turnLoop.currentTurn;
1423
1887
  session.state.status = result.reason;
1888
+ // Session-cumulative (baseline + this run) for persistence...
1424
1889
  const usage = modelFacade.getUsage();
1425
- session.state.tokenUsage = {
1426
- promptTokens: usage.totalPromptTokens,
1427
- completionTokens: usage.totalCompletionTokens,
1428
- totalTokens: usage.totalTokens,
1429
- };
1890
+ session.state.tokenUsage = foldRunUsage(usageBaseline, usage);
1430
1891
  if (this.config.costStore) {
1431
1892
  session.state.costState = this.config.costStore.serialize();
1432
1893
  }
@@ -1459,8 +1920,8 @@ export class Engine {
1459
1920
  */
1460
1921
  /**
1461
1922
  * 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
1923
+ * auto-dream). When settings.defaults.auxText names a valid pool model, build
1924
+ * (and cache) a dedicated client for it so per-turn book-keeping runs on a cheap
1464
1925
  * fast model instead of the expensive primary. Falls back to `fallback` (the
1465
1926
  * active run's client) when unset, unknown, or on any build failure — aux
1466
1927
  * work is best-effort and must never break a run.
@@ -1473,7 +1934,9 @@ export class Engine {
1473
1934
  // once per run on the post-run background path, so the cost is fine.
1474
1935
  const sm = this.getSettingsManager();
1475
1936
  sm.invalidate();
1476
- auxKey = sm.get().auxModelKey;
1937
+ // Unified store's defaults.auxText (a connection id = pool key) selects
1938
+ // the aux model; resolveAuxKey returns it (or undefined).
1939
+ auxKey = resolveAuxKey(sm.get());
1477
1940
  }
1478
1941
  catch {
1479
1942
  return fallback;
@@ -1519,7 +1982,9 @@ export class Engine {
1519
1982
  try {
1520
1983
  // Background calls run on the auxiliary model when configured, so memory
1521
1984
  // book-keeping doesn't burn the expensive primary model every turn.
1522
- const llmClient = await this.resolveAuxClient(primaryClient);
1985
+ // settings.memories.extractionModel (if set + valid) overrides the aux
1986
+ // model specifically for memory extraction (TODO 8.1).
1987
+ const llmClient = await this.resolveExtractionClient(primaryClient);
1523
1988
  // Only run memory extraction for substantive sessions. The previous
1524
1989
  // threshold of 4 user+assistant messages was low enough that two-line
1525
1990
  // exchanges ("what's the time?" / "noon") triggered a full LLM
@@ -1558,6 +2023,10 @@ export class Engine {
1558
2023
  },
1559
2024
  runDream: async ({ systemPrompt, userPrompt, projectDir }) => this.runDreamLoop({ systemPrompt, userPrompt, projectDir, llmClient, sessionId }),
1560
2025
  projectDir: cwd,
2026
+ // settings.memories.maxCount caps memories accepted per extraction;
2027
+ // autoExtract=false turns the extractor off (summaries/dream stay).
2028
+ maxCount: this.readMemoriesConfig()?.maxCount,
2029
+ autoExtract: this.readMemoriesConfig()?.autoExtract,
1561
2030
  });
1562
2031
  await orchestrator.run(plainMessages, sessionId);
1563
2032
  }
@@ -1608,10 +2077,10 @@ export class Engine {
1608
2077
  * Switch the active model by pool key. Takes effect on the next run() call.
1609
2078
  * Returns the new model entry.
1610
2079
  *
1611
- * Persists settings.activeKey (and a legacy settings.model.* mirror) so the
2080
+ * Persists settings.defaults.text (= the connection id / pool key) so the
1612
2081
  * next process startup defaults to the same model — without this, switches
1613
2082
  * only live in memory and every restart reverts to the previously persisted
1614
- * activeKey.
2083
+ * defaults.text.
1615
2084
  */
1616
2085
  switchModel(key) {
1617
2086
  const entry = this.modelPool.switch(key);
@@ -1620,19 +2089,44 @@ export class Engine {
1620
2089
  // this.config.clientDefaults and survive the switch untouched.
1621
2090
  const nextLlm = this.modelPool.toLLMConfig(entry);
1622
2091
  this.config = { ...this.config, llm: nextLlm };
1623
- this.persistActiveModel(entry, nextLlm);
2092
+ this.persistActiveModel(entry);
1624
2093
  return entry;
1625
2094
  }
2095
+ /**
2096
+ * Zero a session's cumulative token/cache usage on disk. Called on a model
2097
+ * switch: a different model has its own prompt cache, so the accumulated
2098
+ * cache-hit stats from the prior model are no longer meaningful. The next
2099
+ * run's baseline (snapshotted from state.tokenUsage) then starts from zero.
2100
+ */
2101
+ resetSessionUsage(sessionId) {
2102
+ const zero = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
2103
+ // If the session is mid-run right now, update its live state so the next
2104
+ // turn-boundary write doesn't re-fold a stale baseline.
2105
+ if (this.activeRunSession?.state.sessionId === sessionId) {
2106
+ this.activeRunSession.state.tokenUsage = { ...zero };
2107
+ }
2108
+ // Persist to disk so a reload / next run picks up the reset.
2109
+ if (this.sessionManager.exists(sessionId)) {
2110
+ try {
2111
+ const bundle = this.sessionManager.resume(sessionId);
2112
+ bundle.state.tokenUsage = { ...zero };
2113
+ this.sessionManager.saveState(bundle.state);
2114
+ }
2115
+ catch {
2116
+ // Session not resumable (never persisted yet) — the in-memory reset
2117
+ // above covers the live case; nothing else to do.
2118
+ }
2119
+ }
2120
+ }
1626
2121
  /**
1627
2122
  * Write the active model selection to ~/.code-shell/settings.json.
1628
2123
  *
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.
2124
+ * Persists settings.defaults.text = entry.key (the connection id == pool
2125
+ * key). That is the single field the boot path reads to restore the active
2126
+ * text model on the next startup (see ctor: settings.defaults.text pool
2127
+ * switch). No legacy activeKey/model.* mirror is written.
1634
2128
  */
1635
- persistActiveModel(entry, llm) {
2129
+ persistActiveModel(entry) {
1636
2130
  try {
1637
2131
  // userHome() (not raw homedir()) so a test that sets process.env.HOME to
1638
2132
  // a tmpdir gets its writes isolated too — the SettingsManager reader
@@ -1651,29 +2145,30 @@ export class Engine {
1651
2145
  return;
1652
2146
  }
1653
2147
  }
1654
- const prevModel = typeof existing.model === "object" && existing.model
1655
- ? existing.model
2148
+ const prevDefaults = typeof existing.defaults === "object" && existing.defaults
2149
+ ? existing.defaults
1656
2150
  : {};
1657
2151
  const updated = {
1658
2152
  ...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
- },
2153
+ defaults: { ...prevDefaults, text: entry.key },
1667
2154
  };
2155
+ // mode 0o600: this writes model.apiKey (plaintext) into settings.json, so
2156
+ // it must be owner-only — same R-1 hardening as SettingsManager/onboarding.
2157
+ // (Third settings.json writer; the R-1 sweep initially missed this one.)
1668
2158
  const tmp = `${file}.${process.pid}.tmp`;
1669
2159
  const payload = JSON.stringify(updated, null, 2) + "\n";
1670
- writeFileSync(tmp, payload, "utf-8");
2160
+ writeFileSync(tmp, payload, { encoding: "utf-8", mode: 0o600 });
1671
2161
  try {
1672
2162
  renameSync(tmp, file);
1673
2163
  }
1674
2164
  catch {
1675
- writeFileSync(file, payload, "utf-8");
2165
+ writeFileSync(file, payload, { encoding: "utf-8", mode: 0o600 });
1676
2166
  }
2167
+ // mode arg only applies on create; tighten an already-existing file too.
2168
+ try {
2169
+ chmodSync(file, 0o600);
2170
+ }
2171
+ catch { /* best-effort */ }
1677
2172
  }
1678
2173
  catch (err) {
1679
2174
  logger.warn(`persistActiveModel failed: ${err.message}`);
@@ -1708,12 +2203,10 @@ export class Engine {
1708
2203
  * payloads are dropped so out-of-order reload deliveries can't let an older
1709
2204
  * config clobber a newer one (Q5).
1710
2205
  *
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.
2206
+ * MCP: reconciles the shared MCP pool against the new disk-default server
2207
+ * set. Added servers connect idempotently; removed/disabled servers are
2208
+ * disconnected and their registered MCP tools are unregistered so plugin
2209
+ * disable takes effect without an Electron restart.
1717
2210
  *
1718
2211
  * Preset (#2): a preset hot-reload re-resolves `this.preset` so the next-turn
1719
2212
  * PromptComposer picks up the new preset's system prompt / behavior — that's
@@ -1760,16 +2253,17 @@ export class Engine {
1760
2253
  }
1761
2254
  this.reloadHooks();
1762
2255
  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
- }
2256
+ // Fire-and-forget reconcile (connect added / disconnect removed servers).
2257
+ // It must NOT surface as an unhandled rejection: a single flaky server
2258
+ // that fails to connect/disconnect during hot-reload would otherwise
2259
+ // crash the host process (or be silently swallowed). Catch + log so the
2260
+ // reconcile is best-effort and the next reload can retry.
2261
+ void this.mcpManager.reconcile(patch.mcpServers, this).catch((err) => {
2262
+ logger.error("engine.mcp_reconcile_failed", {
2263
+ error: err instanceof Error ? err.message : String(err),
2264
+ version,
2265
+ });
2266
+ });
1773
2267
  }
1774
2268
  this.lastAppliedConfigVersion = version;
1775
2269
  }
@@ -1782,6 +2276,55 @@ export class Engine {
1782
2276
  * engine.run() call for this session picks up the injected content
1783
2277
  * instead of a stale snapshot from the previous run.
1784
2278
  */
2279
+ /**
2280
+ * Read a session's persisted active goal WITHOUT resuming it (cheap — reads
2281
+ * only state.json via SessionManager.readActiveGoal). The desktop host calls
2282
+ * this on session load to re-surface the goal block + its Cancel button: a
2283
+ * persistent goal lives only in state.activeGoal and is never replayed from
2284
+ * the transcript, so after a reload of an aborted goal run the UI would
2285
+ * otherwise show nothing (the "goal 还在但页面不显示、取消不了" bug). Returns
2286
+ * undefined when the session is unknown or has no active goal.
2287
+ */
2288
+ getGoal(sessionId) {
2289
+ return this.sessionManager.readActiveGoal(sessionId);
2290
+ }
2291
+ /**
2292
+ * Clear a session's persisted active goal (CC `/goal clear`). Works whether
2293
+ * the session is idle or its goal run is in flight: it wipes
2294
+ * `state.activeGoal` (so the next bare send won't re-inherit it) and, if a
2295
+ * goal hook is currently registered for this engine, unregisters it so an
2296
+ * in-flight run can stop instead of being re-blocked by the now-cleared goal.
2297
+ * Returns true if a goal was actually cleared. Idempotent — clearing a
2298
+ * session with no active goal is a no-op returning false.
2299
+ */
2300
+ clearGoal(sessionId) {
2301
+ if (!this.sessionManager.exists(sessionId))
2302
+ return false;
2303
+ // Prefer the LIVE run's bundle when it's this session: clearing its
2304
+ // in-RAM state.activeGoal is what stops the run loop from writing the goal
2305
+ // back on its next saveState. A fresh resume() copy would be cleared and
2306
+ // persisted, but the running loop's own detached bundle still holds the
2307
+ // goal and resurrects it — the stale-write-back race. Falls back to a
2308
+ // resumed copy when no run of this session is currently in flight.
2309
+ const live = this.activeRunSession && this.activeRunSession.state.sessionId === sessionId
2310
+ ? this.activeRunSession
2311
+ : null;
2312
+ const session = live ?? this.sessionManager.resume(sessionId);
2313
+ const had = session.state.activeGoal !== undefined;
2314
+ if (had) {
2315
+ session.state.activeGoal = undefined;
2316
+ this.sessionManager.saveState(session.state);
2317
+ }
2318
+ // If THIS session's goal run is in flight, drop its stop hook so the
2319
+ // current run can terminate (the closure-held goal would otherwise keep
2320
+ // re-blocking). The run's own `finally` also unregisters; double-unregister
2321
+ // is safe (set delete is idempotent).
2322
+ if (this.activeGoalHook && this.lastSessionId === sessionId) {
2323
+ this.hooks.unregister("on_stop", this.activeGoalHook);
2324
+ this.activeGoalHook = null;
2325
+ }
2326
+ return had;
2327
+ }
1785
2328
  injectContext(sessionId, content) {
1786
2329
  const session = this.sessionManager.resume(sessionId);
1787
2330
  session.transcript.appendMessage("assistant", content);
@@ -1823,7 +2366,7 @@ export class Engine {
1823
2366
  }
1824
2367
  getSettingsManager() {
1825
2368
  if (!this.settingsManager) {
1826
- this.settingsManager = new SettingsManager(this.config.cwd, this.config.settingsScope ?? "project");
2369
+ this.settingsManager = new SettingsManager(this.config.cwd, this.config.settingsScope ?? "project", this.config.projectTrusted !== false);
1827
2370
  }
1828
2371
  return this.settingsManager;
1829
2372
  }
@@ -1875,7 +2418,7 @@ export class Engine {
1875
2418
  rules.push({ tool: "Bash", decision: "allow" });
1876
2419
  }
1877
2420
  try {
1878
- const settingsManager = new SettingsManager(cwd, this.config.settingsScope ?? "project");
2421
+ const settingsManager = new SettingsManager(cwd, this.config.settingsScope ?? "project", this.config.projectTrusted !== false);
1879
2422
  const settings = settingsManager.get();
1880
2423
  if (settings.permissions?.rules?.length) {
1881
2424
  rules.unshift(...settings.permissions.rules);
@@ -1930,6 +2473,26 @@ export class Engine {
1930
2473
  getPermissionMode() {
1931
2474
  return this.config.permissionMode ?? "acceptEdits";
1932
2475
  }
2476
+ /**
2477
+ * Extend the in-flight run's turn ceiling and/or goal budgets (TODO 3.1 —
2478
+ * 运行中续轮/加预算). No-op (returns null) when no run is active. Lets a user
2479
+ * keep an unattended goal going past its original cap instead of restarting.
2480
+ */
2481
+ extendGoalRun(opts) {
2482
+ if (!this.activeTurnLoop)
2483
+ return null;
2484
+ return this.activeTurnLoop.extend(opts);
2485
+ }
2486
+ /**
2487
+ * The effective permission rules for the current mode + cwd (TODO 5.1) —
2488
+ * preset defaults + mode-derived + settings.permissions.rules, in the same
2489
+ * order the classifier evaluates them. Exposed read-only so `/permissions`
2490
+ * (and any UI) can list what's actually in force. Pure read; builds the same
2491
+ * rule set buildPermissionConfig does, without constructing a backend.
2492
+ */
2493
+ getPermissionRules() {
2494
+ return this.buildPermissionConfig(this.getPermissionMode(), this.config.cwd ?? process.cwd()).rules;
2495
+ }
1933
2496
  /**
1934
2497
  * Toggle plan mode directly. Called by the Plan tool (Task 7) via ToolContext.engine.
1935
2498
  * Also syncs permissionMode to keep both fields consistent.
@@ -1983,6 +2546,30 @@ export class Engine {
1983
2546
  signal?.addEventListener("abort", onAbort, { once: true });
1984
2547
  });
1985
2548
  }
2549
+ /**
2550
+ * Like waitForBackgroundAgentChange but with no abort signal and a hard
2551
+ * timeout. Resolves `true` on a registry/queue change, `false` if `timeoutMs`
2552
+ * elapses first. Used only by the headless abort-drain cleanup, where we want
2553
+ * to catch a completing agent's just-about-to-enqueue notification without
2554
+ * risking a permanent hang on an agent that never completes.
2555
+ */
2556
+ waitForBackgroundAgentChangeOrTimeout(_sessionId, timeoutMs) {
2557
+ return new Promise((resolve) => {
2558
+ let settled = false;
2559
+ const finish = (changed) => {
2560
+ if (settled)
2561
+ return;
2562
+ settled = true;
2563
+ clearTimeout(timer);
2564
+ unsubRegistry();
2565
+ unsubQueue();
2566
+ resolve(changed);
2567
+ };
2568
+ const timer = setTimeout(() => finish(false), timeoutMs);
2569
+ const unsubRegistry = asyncAgentRegistry.subscribe(() => finish(true));
2570
+ const unsubQueue = notificationQueue.subscribe(() => finish(true));
2571
+ });
2572
+ }
1986
2573
  /**
1987
2574
  * Sub-agent role registry for the given cwd, memoized per-cwd so the
1988
2575
  * directory is read once rather than every turn. A new cwd (e.g. via
@@ -2053,7 +2640,7 @@ export class Engine {
2053
2640
  * by tests that want a ToolContext without a full run() cycle.
2054
2641
  */
2055
2642
  resolveSandboxWithoutRuntime(config, cwd) {
2056
- const key = `${config.mode}:${cwd}`;
2643
+ const key = sandboxCacheKey(config, cwd);
2057
2644
  let cached = this.sandboxCache.get(key);
2058
2645
  if (!cached) {
2059
2646
  cached = resolveSandboxBackend(config, cwd);
@@ -2068,20 +2655,129 @@ export class Engine {
2068
2655
  }
2069
2656
  return cached;
2070
2657
  }
2658
+ /**
2659
+ * Build the shell env layered onto the Bash tool / background shells (see
2660
+ * mergeShellEnv). Three user-configured sources, merged lowest → highest:
2661
+ *
2662
+ * 1. project `localEnvironment.env` — the per-project "local environment"
2663
+ * panel (DATABASE_URL etc.); the floor, so a project's own panel values
2664
+ * can be overridden by an explicit top-level `env`.
2665
+ * 2. global top-level `env` — ~/.code-shell/settings.json; the
2666
+ * canonical home for API keys (OPENAI_API_KEY) a skill script reads —
2667
+ * configure once, every project's skills get it.
2668
+ * 3. project top-level `env` — .code-shell/settings.json; a project
2669
+ * that wants to override a global key wins.
2670
+ *
2671
+ * Each scope is read UNMERGED so the layering here is the single source of
2672
+ * precedence (getForScope merges nothing). Returns undefined when no layer
2673
+ * contributes a key, so the caller passes it through unchanged for projects
2674
+ * that configure none.
2675
+ *
2676
+ * Sub-agents: a sub-agent is the user's OWN agent doing the user's work
2677
+ * (mirrors Claude Code, where sub-agents inherit the parent environment), so
2678
+ * it now reads the SAME env as the parent. The sub-agent branch is kept as an
2679
+ * explicit seam (`filterSubagentEnv`) rather than removed — a future policy
2680
+ * could narrow what a sub-agent sees (e.g. drop credential secrets) by
2681
+ * changing that one hook; today it passes everything through unchanged.
2682
+ * A no-cwd context still gets nothing (there is genuinely no project to read).
2683
+ *
2684
+ * None of these is filtered through the deny regex (mergeShellEnv): the user
2685
+ * put them there deliberately. The allowlist/deny machinery only guards the
2686
+ * host's process.env from a tainted model exfiltrating it via `env | curl`.
2687
+ */
2688
+ readShellEnv(cwd) {
2689
+ if (!cwd)
2690
+ return undefined;
2691
+ const merged = {};
2692
+ const layer = (env) => {
2693
+ if (!env)
2694
+ return;
2695
+ for (const [k, v] of Object.entries(env)) {
2696
+ if (typeof v === "string")
2697
+ merged[k] = v;
2698
+ }
2699
+ };
2700
+ try {
2701
+ // The fully-merged settings already apply the scope guard (a 'project'
2702
+ // scope never reads the host ~/.code-shell) and the user < project <
2703
+ // local precedence — so the top-level `env` map read from here is global
2704
+ // values overridden by project values, exactly as specified. We layer
2705
+ // localEnvironment.env *under* it as the floor.
2706
+ const settings = this.getSettingsManager().get();
2707
+ layer(settings.localEnvironment?.env); // floor
2708
+ // Credentials flagged "expose as env var" (Credential.exposeAsEnv). This
2709
+ // is the wiring that was missing — the UI/store recorded the flag but no
2710
+ // code ever injected the secret, so `$FIGMA_TOKEN` was always empty.
2711
+ // Scope mirrors settingsScope so a project-scoped engine never surfaces
2712
+ // the host user's credentials (same isolation contract as top-level env).
2713
+ // Placed below settings.env so an explicit `env` entry can still override.
2714
+ const credScope = (this.config.settingsScope ?? "project") === "full" ? "full" : "project";
2715
+ layer(new CredentialStore(cwd).envExposures(credScope));
2716
+ layer(settings.env); // top-level env (global ⊕ project) wins
2717
+ }
2718
+ catch {
2719
+ return undefined;
2720
+ }
2721
+ const result = this.config.isSubAgent === true ? this.filterSubagentEnv(merged) : merged;
2722
+ return Object.keys(result).length > 0 ? result : undefined;
2723
+ }
2724
+ /**
2725
+ * Policy seam for what a sub-agent's shell sees. A sub-agent inherits the
2726
+ * parent environment by default (mirrors Claude Code), so this is the
2727
+ * identity function today. It exists so a future policy can narrow the set
2728
+ * (e.g. strip credential `exposeAsEnv` secrets, or allowlist by name) in ONE
2729
+ * place instead of scattering `isSubAgent` checks through readShellEnv.
2730
+ */
2731
+ filterSubagentEnv(env) {
2732
+ return env;
2733
+ }
2734
+ /**
2735
+ * Read the project's `localEnvironment.setupScripts` for this cwd (the raw
2736
+ * per-platform map). Used by EnterWorktree to run setup once in a freshly
2737
+ * created worktree. Returns undefined for sub-agents / no cwd (same minimal
2738
+ * surface as readShellEnv). The platform selection + run live in
2739
+ * git/worktree.ts; this only fetches the configured scripts.
2740
+ */
2741
+ readWorktreeSetupScripts(cwd) {
2742
+ if (this.config.isSubAgent === true || !cwd)
2743
+ return undefined;
2744
+ try {
2745
+ const scoped = this.getSettingsManager().getForScope("project", cwd);
2746
+ return scoped.localEnvironment?.setupScripts;
2747
+ }
2748
+ catch {
2749
+ return undefined;
2750
+ }
2751
+ }
2071
2752
  buildToolContext() {
2072
2753
  const { disabledSkills, disabledPlugins } = this.readDisabledLists();
2073
2754
  return {
2755
+ shellEnv: this.readShellEnv(this.config.cwd),
2074
2756
  cwd: this.config.cwd ?? process.cwd(),
2075
2757
  llmConfig: this.config.llm,
2076
2758
  modelPool: this.modelPool,
2077
2759
  toolRegistry: this.toolRegistry,
2078
2760
  askUser: this.config.askUser,
2761
+ browser: this.config.browserBridge,
2762
+ injectCredentialToBrowser: this.config.injectCredentialToBrowser,
2079
2763
  isSubAgent: this.config.isSubAgent === true,
2764
+ // Credential tools narrow their disk reads to this scope: a project/
2765
+ // isolated engine (SDK-embedded) must not surface the host user's
2766
+ // ~/.code-shell credentials or credentialUse.autoApprove. "full" (the
2767
+ // host-application default) merges user + project as before.
2768
+ settingsScope: this.config.settingsScope ?? "project",
2080
2769
  hooks: this.hooks,
2081
2770
  planMode: this.planMode,
2771
+ permissionMode: this.permissionMode,
2082
2772
  engine: this,
2083
2773
  disabledSkills,
2084
2774
  disabledPlugins,
2775
+ skillAllowlist: this.config.skillAllowlist,
2776
+ backgroundShells: backgroundShellManager,
2777
+ // Sub-agents never start background shells (they're short-lived and
2778
+ // their lifecycle ends with the parent turn); unattended automation
2779
+ // opts out via config. Otherwise allowed.
2780
+ allowBackgroundShells: this.config.isSubAgent === true ? false : this.config.allowBackgroundShells !== false,
2085
2781
  };
2086
2782
  }
2087
2783
  /**
@@ -2097,27 +2793,97 @@ export class Engine {
2097
2793
  */
2098
2794
  readDisabledLists() {
2099
2795
  if (this.config.isSubAgent === true) {
2100
- return { disabledSkills: [], disabledPlugins: [] };
2796
+ return { disabledSkills: [], disabledPlugins: [], disabledPluginHooks: [] };
2101
2797
  }
2798
+ // Shared folding (capability-control/disabled-lists.ts): project
2799
+ // capabilityOverrides over the global baseline + the no-repo whitelist
2800
+ // inversion. Extracted so the MCP merge consumers (engineFactory /
2801
+ // diskDefaultsFrom) fold identically — see that module's doc.
2802
+ return computeEffectiveDisabledLists(this.getSettingsManager(), this.config.cwd);
2803
+ }
2804
+ /**
2805
+ * Public view of the folded disabled lists, for hosts that need the
2806
+ * EFFECTIVE state (e.g. the protocol server's settings hot-reload rebuilds
2807
+ * the plugin-MCP merge per session — a project-level "on" must override the
2808
+ * global disabledPlugins there too).
2809
+ */
2810
+ getEffectiveDisabledLists() {
2811
+ return this.readDisabledLists();
2812
+ }
2813
+ /**
2814
+ * Public: resolve every known feature flag to its effective boolean (the
2815
+ * settings overlay merged over the compiled-in defaults). Used by the
2816
+ * `config` protocol query so the `/features` command can list flag state.
2817
+ */
2818
+ getFeatureFlags() {
2819
+ return resolveFeatureFlags(this.readFeatureFlags());
2820
+ }
2821
+ /**
2822
+ * Read the merged `settings.featureFlags` overlay for this cwd. Project
2823
+ * settings override user settings via the normal SettingsManager merge.
2824
+ * Returns undefined (→ all defaults) on any read error or for sub-agents,
2825
+ * so a flag check never throws and a child runs with default behavior.
2826
+ */
2827
+ readFeatureFlags() {
2828
+ if (this.config.isSubAgent === true)
2829
+ return undefined;
2102
2830
  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
- };
2831
+ const settings = this.getSettingsManager().get();
2832
+ return settings.featureFlags;
2833
+ }
2834
+ catch {
2835
+ return undefined;
2836
+ }
2837
+ }
2838
+ /**
2839
+ * Read settings.memories ({ maxCount, maxAge, extractionModel, autoExtract }).
2840
+ * Returns undefined on any error or when absent, so the memory pipeline
2841
+ * falls back to its built-in defaults.
2842
+ */
2843
+ readMemoriesConfig() {
2844
+ try {
2845
+ const settings = this.getSettingsManager().get();
2846
+ return settings.memories;
2118
2847
  }
2119
2848
  catch {
2120
- return { disabledSkills: [], disabledPlugins: [] };
2849
+ return undefined;
2850
+ }
2851
+ }
2852
+ /**
2853
+ * LLM client for memory extraction (TODO 8.1). Prefers
2854
+ * settings.memories.extractionModel when it names a valid pool model;
2855
+ * otherwise falls back to the aux client (which itself falls back to the
2856
+ * passed primary). Build failures fall back too — extraction is best-effort.
2857
+ */
2858
+ async resolveExtractionClient(primaryClient) {
2859
+ const key = this.readMemoriesConfig()?.extractionModel;
2860
+ if (key) {
2861
+ const entry = this.modelPool.get(key);
2862
+ if (entry) {
2863
+ try {
2864
+ return await createLLMClient(this.modelPool.toLLMConfig(entry), this.config.clientDefaults);
2865
+ }
2866
+ catch (err) {
2867
+ logger.warn("engine.extraction_model_build_failed", {
2868
+ extractionModel: key,
2869
+ error: err.message,
2870
+ });
2871
+ }
2872
+ }
2873
+ else {
2874
+ logger.warn("engine.extraction_model_missing", { extractionModel: key });
2875
+ }
2121
2876
  }
2877
+ return this.resolveAuxClient(primaryClient);
2122
2878
  }
2123
2879
  }
2880
+ /**
2881
+ * Builtin tool name → the feature flag that gates its visibility. A tool here
2882
+ * is hidden from the LLM when its flag resolves to false. Tools not listed are
2883
+ * unaffected. Kept beside the engine (not in builtin/index) because the flag
2884
+ * read needs the engine's scoped SettingsManager.
2885
+ */
2886
+ const TOOL_FEATURE_FLAGS = new Map([
2887
+ ["WebSearch", "web_search"],
2888
+ ["Bash", "shell_tool"],
2889
+ ]);