@cjhyy/code-shell-core 0.7.0 → 0.8.0

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 (621) hide show
  1. package/README.md +36 -23
  2. package/dist/agent/agent-definition-registry.d.ts +2 -2
  3. package/dist/agent/agent-definition-registry.js +47 -10
  4. package/dist/agent/agent-definition.d.ts +5 -0
  5. package/dist/agent/agent-definition.js +14 -0
  6. package/dist/automation/index.d.ts +7 -4
  7. package/dist/automation/index.js +4 -2
  8. package/dist/automation/runner.js +3 -1
  9. package/dist/automation/scheduler.d.ts +57 -1
  10. package/dist/automation/scheduler.js +123 -23
  11. package/dist/automation/store.d.ts +6 -2
  12. package/dist/automation/store.js +7 -3
  13. package/dist/capabilities/index.d.ts +109 -0
  14. package/dist/capabilities/index.js +80 -0
  15. package/dist/capability-control/disabled-lists.d.ts +2 -1
  16. package/dist/capability-control/disabled-lists.js +3 -5
  17. package/dist/capability-control/overlay.d.ts +14 -1
  18. package/dist/capability-control/overlay.js +40 -0
  19. package/dist/capability-control/service.js +6 -4
  20. package/dist/cli/agent-server-stdio.d.ts +1 -1
  21. package/dist/cli/agent-server-stdio.js +66 -6
  22. package/dist/cli/agent-server-tcp.js +20 -5
  23. package/dist/context/compaction.d.ts +39 -3
  24. package/dist/context/compaction.js +151 -39
  25. package/dist/context/manager.d.ts +20 -4
  26. package/dist/context/manager.js +42 -7
  27. package/dist/context/token-counter.js +5 -3
  28. package/dist/context/tool-result-storage.d.ts +6 -0
  29. package/dist/context/tool-result-storage.js +25 -4
  30. package/dist/credentials/access.d.ts +13 -2
  31. package/dist/credentials/access.js +99 -4
  32. package/dist/credentials/index.d.ts +3 -2
  33. package/dist/credentials/index.js +2 -1
  34. package/dist/credentials/oauth.d.ts +9 -1
  35. package/dist/credentials/oauth.js +80 -1
  36. package/dist/credentials/store.d.ts +16 -2
  37. package/dist/credentials/store.js +33 -11
  38. package/dist/credentials/types.d.ts +62 -1
  39. package/dist/credentials/types.js +16 -1
  40. package/dist/credentials/use-credential-tool.js +15 -1
  41. package/dist/engine/auxiliary-pipeline.d.ts +40 -0
  42. package/dist/engine/auxiliary-pipeline.js +181 -0
  43. package/dist/{settings → engine}/disk-defaults.d.ts +2 -2
  44. package/dist/{settings → engine}/disk-defaults.js +5 -2
  45. package/dist/engine/dynamic-tool-defs.d.ts +4 -0
  46. package/dist/engine/dynamic-tool-defs.js +4 -0
  47. package/dist/engine/engine.d.ts +272 -184
  48. package/dist/engine/engine.js +1869 -1864
  49. package/dist/engine/file-history-hook.d.ts +11 -0
  50. package/dist/engine/file-history-hook.js +31 -0
  51. package/dist/engine/goal-judge-context.d.ts +9 -0
  52. package/dist/engine/goal-judge-context.js +234 -0
  53. package/dist/engine/injected-context-cache.d.ts +6 -0
  54. package/dist/engine/injected-context-cache.js +13 -0
  55. package/dist/engine/input-attachments.d.ts +1 -1
  56. package/dist/engine/model-facade.d.ts +3 -3
  57. package/dist/engine/model-facade.js +15 -14
  58. package/dist/engine/permission-controller.d.ts +55 -0
  59. package/dist/engine/permission-controller.js +148 -0
  60. package/dist/engine/prompt-cache-diagnostics.d.ts +58 -0
  61. package/dist/engine/prompt-cache-diagnostics.js +178 -0
  62. package/dist/engine/run-accounting.d.ts +41 -0
  63. package/dist/engine/run-accounting.js +126 -0
  64. package/dist/engine/run-context.d.ts +44 -0
  65. package/dist/engine/run-context.js +88 -0
  66. package/dist/engine/run-environment.d.ts +30 -0
  67. package/dist/engine/run-environment.js +77 -0
  68. package/dist/engine/run-finalize.d.ts +58 -0
  69. package/dist/engine/run-finalize.js +245 -0
  70. package/dist/engine/run-goal.d.ts +58 -0
  71. package/dist/engine/run-goal.js +188 -0
  72. package/dist/engine/run-image-input.d.ts +1 -1
  73. package/dist/engine/run-image-input.js +5 -3
  74. package/dist/engine/run-session-open.d.ts +41 -0
  75. package/dist/engine/run-session-open.js +186 -0
  76. package/dist/engine/run-setup.d.ts +42 -0
  77. package/dist/engine/run-setup.js +58 -0
  78. package/dist/engine/run-tooling.d.ts +125 -0
  79. package/dist/engine/run-tooling.js +283 -0
  80. package/dist/engine/run-types.d.ts +139 -0
  81. package/dist/engine/run-types.js +12 -0
  82. package/dist/engine/run-workspace.d.ts +50 -0
  83. package/dist/engine/run-workspace.js +114 -0
  84. package/dist/engine/session-title.d.ts +2 -1
  85. package/dist/engine/session-title.js +4 -1
  86. package/dist/engine/steer-queue.d.ts +1 -1
  87. package/dist/engine/subagent-spawner.d.ts +32 -0
  88. package/dist/engine/subagent-spawner.js +287 -0
  89. package/dist/engine/tool-summary.d.ts +2 -2
  90. package/dist/engine/tool-summary.js +2 -2
  91. package/dist/engine/turn-loop.d.ts +67 -10
  92. package/dist/engine/turn-loop.js +538 -97
  93. package/dist/engine/types.d.ts +35 -4
  94. package/dist/engine/types.js +1 -1
  95. package/dist/{engine/goal.d.ts → goal/lifecycle.d.ts} +80 -6
  96. package/dist/goal/lifecycle.js +482 -0
  97. package/dist/hooks/events.d.ts +26 -9
  98. package/dist/hooks/events.js +0 -3
  99. package/dist/hooks/goal-stop-hook.d.ts +28 -29
  100. package/dist/hooks/goal-stop-hook.js +344 -144
  101. package/dist/hooks/registry.js +4 -1
  102. package/dist/hooks/shell-runner.d.ts +12 -1
  103. package/dist/hooks/shell-runner.js +160 -9
  104. package/dist/index.d.ts +51 -110
  105. package/dist/index.extension.d.ts +51 -0
  106. package/dist/index.extension.js +39 -0
  107. package/dist/index.internal.d.ts +86 -0
  108. package/dist/index.internal.js +92 -0
  109. package/dist/index.js +37 -125
  110. package/dist/links/cli.d.ts +48 -0
  111. package/dist/links/cli.js +617 -0
  112. package/dist/links/http.d.ts +40 -0
  113. package/dist/links/http.js +166 -0
  114. package/dist/links/index.d.ts +4 -0
  115. package/dist/links/index.js +3 -0
  116. package/dist/links/link-action-tool.d.ts +5 -0
  117. package/dist/links/link-action-tool.js +241 -0
  118. package/dist/links/providers.d.ts +8 -0
  119. package/dist/links/providers.js +665 -0
  120. package/dist/links/types.d.ts +44 -0
  121. package/dist/llm/client-base.d.ts +7 -0
  122. package/dist/llm/client-base.js +72 -10
  123. package/dist/llm/model-pool.d.ts +1 -1
  124. package/dist/llm/model-pool.js +1 -1
  125. package/dist/llm/providers/anthropic.d.ts +1 -0
  126. package/dist/llm/providers/anthropic.js +9 -0
  127. package/dist/llm/providers/openai.d.ts +1 -0
  128. package/dist/llm/providers/openai.js +17 -0
  129. package/dist/llm/types.d.ts +12 -5
  130. package/dist/model-catalog/index.js +15 -2
  131. package/dist/model-catalog/resolve.d.ts +13 -1
  132. package/dist/model-catalog/resolve.js +37 -2
  133. package/dist/model-catalog/types.d.ts +74 -48
  134. package/dist/model-catalog/types.js +16 -5
  135. package/dist/model-catalog/upsert.d.ts +3 -1
  136. package/dist/model-catalog/upsert.js +9 -0
  137. package/dist/onboarding.d.ts +0 -1
  138. package/dist/onboarding.js +1 -22
  139. package/dist/panel-apps/bindings.d.ts +24 -0
  140. package/dist/panel-apps/bindings.js +69 -0
  141. package/dist/panel-apps/github-archive.d.ts +21 -0
  142. package/dist/panel-apps/github-archive.js +98 -0
  143. package/dist/panel-apps/index.d.ts +4 -0
  144. package/dist/panel-apps/index.js +4 -0
  145. package/dist/panel-apps/installer.d.ts +68 -0
  146. package/dist/panel-apps/installer.js +622 -0
  147. package/dist/panel-apps/manifest.d.ts +364 -0
  148. package/dist/panel-apps/manifest.js +189 -0
  149. package/dist/panel-apps/paths.d.ts +13 -0
  150. package/dist/panel-apps/paths.js +38 -0
  151. package/dist/panel-apps/registry.d.ts +51 -0
  152. package/dist/panel-apps/registry.js +99 -0
  153. package/dist/panel-apps/runtime.d.ts +80 -0
  154. package/dist/panel-apps/runtime.js +154 -0
  155. package/dist/plugins/gitOps.js +8 -1
  156. package/dist/plugins/installedPlugins.d.ts +2 -1
  157. package/dist/plugins/installedPlugins.js +146 -8
  158. package/dist/plugins/installer/codex/convertAgents.d.ts +4 -1
  159. package/dist/plugins/installer/codex/convertAgents.js +62 -8
  160. package/dist/plugins/installer/codex/convertCommands.d.ts +5 -5
  161. package/dist/plugins/installer/codex/convertCommands.js +35 -12
  162. package/dist/plugins/installer/codex/convertHooks.d.ts +10 -0
  163. package/dist/plugins/installer/codex/convertHooks.js +82 -0
  164. package/dist/plugins/installer/codex/convertSkills.js +41 -6
  165. package/dist/plugins/installer/install.d.ts +7 -1
  166. package/dist/plugins/installer/install.js +21 -87
  167. package/dist/plugins/installer/installFromArchive.d.ts +26 -4
  168. package/dist/plugins/installer/installFromArchive.js +44 -27
  169. package/dist/plugins/installer/installFromNpm.d.ts +31 -0
  170. package/dist/plugins/installer/installFromNpm.js +273 -0
  171. package/dist/plugins/installer/loadPluginAgents.js +17 -4
  172. package/dist/plugins/installer/loadPluginMcp.d.ts +14 -0
  173. package/dist/plugins/installer/loadPluginMcp.js +245 -35
  174. package/dist/plugins/installer/normalizeManifest.d.ts +13 -0
  175. package/dist/plugins/installer/normalizeManifest.js +306 -0
  176. package/dist/plugins/installer/npmTar.d.ts +13 -0
  177. package/dist/plugins/installer/npmTar.js +321 -0
  178. package/dist/plugins/installer/parseSource.d.ts +11 -0
  179. package/dist/plugins/installer/parseSource.js +48 -0
  180. package/dist/plugins/installer/preview.d.ts +101 -0
  181. package/dist/plugins/installer/preview.js +336 -0
  182. package/dist/plugins/installer/projectPluginSource.d.ts +14 -0
  183. package/dist/plugins/installer/projectPluginSource.js +140 -0
  184. package/dist/plugins/installer/types.d.ts +775 -0
  185. package/dist/plugins/installer/types.js +100 -0
  186. package/dist/plugins/installer/unzip.d.ts +11 -0
  187. package/dist/plugins/installer/unzip.js +123 -9
  188. package/dist/plugins/installer/update.d.ts +2 -2
  189. package/dist/plugins/installer/update.js +43 -4
  190. package/dist/plugins/loadPluginHooks.d.ts +19 -2
  191. package/dist/plugins/loadPluginHooks.js +97 -86
  192. package/dist/plugins/pluginAutomationTemplates.d.ts +18 -0
  193. package/dist/plugins/pluginAutomationTemplates.js +50 -0
  194. package/dist/plugins/pluginCatalog.d.ts +45 -0
  195. package/dist/plugins/pluginCatalog.js +87 -0
  196. package/dist/plugins/pluginCommandHook.d.ts +10 -4
  197. package/dist/plugins/pluginCommandHook.js +133 -18
  198. package/dist/plugins/pluginCommandsLoader.d.ts +19 -0
  199. package/dist/plugins/pluginCommandsLoader.js +181 -7
  200. package/dist/plugins/pluginContent.d.ts +10 -1
  201. package/dist/plugins/pluginContent.js +87 -30
  202. package/dist/plugins/pluginHookApproval.d.ts +30 -0
  203. package/dist/plugins/pluginHookApproval.js +160 -0
  204. package/dist/plugins/pluginHookIntegrity.d.ts +69 -0
  205. package/dist/plugins/pluginHookIntegrity.js +361 -0
  206. package/dist/plugins/pluginInstaller.js +71 -12
  207. package/dist/plugins/pluginMcpApproval.d.ts +19 -0
  208. package/dist/plugins/pluginMcpApproval.js +110 -0
  209. package/dist/plugins/pluginMcpIntegrity.d.ts +21 -0
  210. package/dist/plugins/pluginMcpIntegrity.js +74 -0
  211. package/dist/plugins/types.d.ts +30 -0
  212. package/dist/plugins/varRewrite.d.ts +9 -5
  213. package/dist/plugins/varRewrite.js +26 -17
  214. package/dist/preset/index.d.ts +11 -8
  215. package/dist/preset/index.js +62 -203
  216. package/dist/product/define.js +9 -3
  217. package/dist/product/types.d.ts +0 -2
  218. package/dist/profile/activation.d.ts +36 -0
  219. package/dist/profile/activation.js +57 -0
  220. package/dist/profile/catalog-store.d.ts +77 -0
  221. package/dist/profile/catalog-store.js +346 -0
  222. package/dist/profile/catalog.d.ts +54 -0
  223. package/dist/profile/catalog.js +152 -0
  224. package/dist/profile/index.d.ts +7 -0
  225. package/dist/profile/index.js +7 -0
  226. package/dist/profile/requirements.d.ts +76 -0
  227. package/dist/profile/requirements.js +117 -0
  228. package/dist/profile/resolve.d.ts +11 -0
  229. package/dist/profile/resolve.js +41 -0
  230. package/dist/profile/store.d.ts +17 -0
  231. package/dist/profile/store.js +167 -0
  232. package/dist/profile/types.d.ts +321 -0
  233. package/dist/profile/types.js +104 -0
  234. package/dist/prompt/composer.d.ts +46 -7
  235. package/dist/prompt/composer.js +78 -48
  236. package/dist/prompt/instruction-scanner.d.ts +6 -5
  237. package/dist/prompt/instruction-scanner.js +8 -23
  238. package/dist/prompt/section-loader.d.ts +2 -2
  239. package/dist/prompt/section-loader.js +7 -4
  240. package/dist/prompt/sections/browser.md +10 -9
  241. package/dist/prompt/sections/harness-base.md +9 -0
  242. package/dist/protocol/chat-session-manager.d.ts +77 -3
  243. package/dist/protocol/chat-session-manager.js +194 -18
  244. package/dist/protocol/chat-session.d.ts +72 -9
  245. package/dist/protocol/chat-session.js +151 -16
  246. package/dist/protocol/client.d.ts +37 -4
  247. package/dist/protocol/client.js +118 -6
  248. package/dist/protocol/index.d.ts +2 -2
  249. package/dist/protocol/index.js +2 -2
  250. package/dist/protocol/mobile-remote-types.d.ts +406 -0
  251. package/dist/protocol/mobile-remote-types.js +1 -0
  252. package/dist/protocol/server.d.ts +152 -14
  253. package/dist/protocol/server.js +1574 -229
  254. package/dist/protocol/types.d.ts +182 -32
  255. package/dist/protocol/types.js +18 -0
  256. package/dist/run/ArtifactTracker.d.ts +6 -1
  257. package/dist/run/ArtifactTracker.js +29 -21
  258. package/dist/run/EngineRunner.d.ts +2 -0
  259. package/dist/run/EngineRunner.js +1 -0
  260. package/dist/run/RunManager.d.ts +26 -0
  261. package/dist/run/RunManager.js +82 -11
  262. package/dist/run/factory.d.ts +3 -0
  263. package/dist/run/factory.js +2 -0
  264. package/dist/runtime/safe-spawn.d.ts +6 -0
  265. package/dist/runtime/safe-spawn.js +8 -13
  266. package/dist/services/auto-dream.d.ts +13 -1
  267. package/dist/services/auto-dream.js +56 -8
  268. package/dist/services/dream-consolidation.d.ts +3 -0
  269. package/dist/services/dream-consolidation.js +14 -2
  270. package/dist/services/index.d.ts +1 -1
  271. package/dist/services/index.js +1 -1
  272. package/dist/services/memory-orchestrator.js +6 -2
  273. package/dist/services/oauth.d.ts +34 -10
  274. package/dist/services/oauth.js +236 -98
  275. package/dist/services/session-memory.d.ts +4 -4
  276. package/dist/services/session-memory.js +13 -10
  277. package/dist/session/memory.d.ts +26 -7
  278. package/dist/session/memory.js +79 -21
  279. package/dist/session/session-manager.d.ts +208 -25
  280. package/dist/session/session-manager.js +1232 -116
  281. package/dist/session/session-message.d.ts +22 -0
  282. package/dist/session/session-message.js +1 -0
  283. package/dist/session/transcript.d.ts +76 -6
  284. package/dist/session/transcript.js +278 -7
  285. package/dist/settings/feature-flags.d.ts +40 -0
  286. package/dist/settings/feature-flags.js +40 -0
  287. package/dist/settings/manager.d.ts +39 -1
  288. package/dist/settings/manager.js +102 -41
  289. package/dist/settings/migrate-config.js +4 -2
  290. package/dist/settings/schema.d.ts +511 -177
  291. package/dist/settings/schema.js +98 -25
  292. package/dist/skills/scanner.d.ts +6 -2
  293. package/dist/skills/scanner.js +160 -9
  294. package/dist/sources/adapter.d.ts +14 -0
  295. package/dist/sources/adapter.js +7 -0
  296. package/dist/sources/adapters/local-files.d.ts +10 -0
  297. package/dist/sources/adapters/local-files.js +127 -0
  298. package/dist/sources/adapters/mcp-resource.d.ts +17 -0
  299. package/dist/sources/adapters/mcp-resource.js +41 -0
  300. package/dist/sources/adapters/mock.d.ts +3 -0
  301. package/dist/sources/adapters/mock.js +29 -0
  302. package/dist/sources/binding.d.ts +6 -0
  303. package/dist/sources/binding.js +24 -0
  304. package/dist/sources/catalog.d.ts +6 -0
  305. package/dist/sources/catalog.js +64 -0
  306. package/dist/sources/context-summary.d.ts +8 -0
  307. package/dist/sources/context-summary.js +13 -0
  308. package/dist/sources/credential-status.d.ts +2 -0
  309. package/dist/sources/credential-status.js +13 -0
  310. package/dist/sources/index.d.ts +10 -0
  311. package/dist/sources/index.js +10 -0
  312. package/dist/sources/resolve.d.ts +28 -0
  313. package/dist/sources/resolve.js +47 -0
  314. package/dist/sources/truncate-utf8.d.ts +7 -0
  315. package/dist/sources/truncate-utf8.js +18 -0
  316. package/dist/sources/types.d.ts +71 -0
  317. package/dist/sources/types.js +26 -0
  318. package/dist/testing/fetch-stub.d.ts +32 -0
  319. package/dist/testing/fetch-stub.js +24 -0
  320. package/dist/themes/image.d.ts +18 -0
  321. package/dist/themes/image.js +117 -0
  322. package/dist/themes/index.d.ts +4 -0
  323. package/dist/themes/index.js +4 -0
  324. package/dist/themes/installer.d.ts +50 -0
  325. package/dist/themes/installer.js +290 -0
  326. package/dist/themes/manifest.d.ts +102 -0
  327. package/dist/themes/manifest.js +86 -0
  328. package/dist/themes/paths.d.ts +14 -0
  329. package/dist/themes/paths.js +33 -0
  330. package/dist/tool-system/browser-bridge.d.ts +53 -11
  331. package/dist/tool-system/browser-bridge.js +8 -9
  332. package/dist/tool-system/builtin/agent-heartbeat.d.ts +2 -2
  333. package/dist/tool-system/builtin/agent-heartbeat.js +20 -6
  334. package/dist/tool-system/builtin/agent-notifications.d.ts +127 -72
  335. package/dist/tool-system/builtin/agent-notifications.js +291 -101
  336. package/dist/tool-system/builtin/agent-output-file.js +1 -3
  337. package/dist/tool-system/builtin/agent-progress.d.ts +6 -0
  338. package/dist/tool-system/builtin/agent-progress.js +83 -0
  339. package/dist/tool-system/builtin/agent-registry.d.ts +38 -2
  340. package/dist/tool-system/builtin/agent-registry.js +178 -9
  341. package/dist/tool-system/builtin/agent-transcript-translator.js +1 -1
  342. package/dist/tool-system/builtin/agent.d.ts +2 -0
  343. package/dist/tool-system/builtin/agent.js +279 -34
  344. package/dist/tool-system/builtin/background-jobs.d.ts +63 -7
  345. package/dist/tool-system/builtin/background-jobs.js +111 -15
  346. package/dist/tool-system/builtin/background-work.d.ts +12 -1
  347. package/dist/tool-system/builtin/background-work.js +12 -1
  348. package/dist/tool-system/builtin/bash.d.ts +3 -5
  349. package/dist/tool-system/builtin/bash.js +14 -6
  350. package/dist/tool-system/builtin/browser-tools.d.ts +8 -5
  351. package/dist/tool-system/builtin/browser-tools.js +89 -23
  352. package/dist/tool-system/builtin/edit-model-catalog.d.ts +6 -0
  353. package/dist/tool-system/builtin/edit-model-catalog.js +172 -12
  354. package/dist/tool-system/builtin/edit.d.ts +2 -1
  355. package/dist/tool-system/builtin/edit.js +14 -10
  356. package/dist/tool-system/builtin/file-cache.d.ts +2 -0
  357. package/dist/tool-system/builtin/file-cache.js +4 -0
  358. package/dist/tool-system/builtin/generate-image.js +17 -10
  359. package/dist/tool-system/builtin/generate-video.d.ts +4 -0
  360. package/dist/tool-system/builtin/generate-video.js +164 -28
  361. package/dist/tool-system/builtin/glob.d.ts +2 -1
  362. package/dist/tool-system/builtin/glob.js +28 -3
  363. package/dist/tool-system/builtin/grep.d.ts +1 -0
  364. package/dist/tool-system/builtin/grep.js +82 -17
  365. package/dist/tool-system/builtin/image-uploader.js +7 -1
  366. package/dist/tool-system/builtin/index.d.ts +57 -18
  367. package/dist/tool-system/builtin/index.js +280 -167
  368. package/dist/tool-system/builtin/memory.d.ts +6 -0
  369. package/dist/tool-system/builtin/memory.js +36 -15
  370. package/dist/tool-system/builtin/panel.d.ts +9 -0
  371. package/dist/tool-system/builtin/panel.js +314 -0
  372. package/dist/tool-system/builtin/read.js +5 -1
  373. package/dist/tool-system/builtin/send-message-to-session.d.ts +6 -0
  374. package/dist/tool-system/builtin/send-message-to-session.js +67 -0
  375. package/dist/tool-system/builtin/sources.d.ts +8 -0
  376. package/dist/tool-system/builtin/sources.js +137 -0
  377. package/dist/tool-system/builtin/tool-search.js +7 -2
  378. package/dist/tool-system/builtin/update-automation-memory.js +2 -0
  379. package/dist/tool-system/builtin/video-providers.d.ts +12 -15
  380. package/dist/tool-system/builtin/video-providers.js +1 -0
  381. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  382. package/dist/tool-system/builtin/web-fetch.js +60 -15
  383. package/dist/tool-system/builtin/web-search.js +1 -3
  384. package/dist/tool-system/builtin/write.d.ts +2 -1
  385. package/dist/tool-system/builtin/write.js +14 -4
  386. package/dist/tool-system/capability-module.d.ts +93 -0
  387. package/dist/tool-system/capability-module.js +53 -0
  388. package/dist/tool-system/context.d.ts +94 -24
  389. package/dist/tool-system/executor.js +99 -58
  390. package/dist/tool-system/external-tool-exposure.d.ts +60 -0
  391. package/dist/tool-system/external-tool-exposure.js +304 -0
  392. package/dist/tool-system/mcp-manager.d.ts +17 -1
  393. package/dist/tool-system/mcp-manager.js +72 -7
  394. package/dist/tool-system/mcp-tool-policy.d.ts +9 -0
  395. package/dist/tool-system/mcp-tool-policy.js +34 -0
  396. package/dist/tool-system/panel-bridge.d.ts +53 -0
  397. package/dist/tool-system/panel-bridge.js +1 -0
  398. package/dist/tool-system/path-policy.d.ts +19 -0
  399. package/dist/tool-system/path-policy.js +62 -1
  400. package/dist/tool-system/permission.d.ts +69 -3
  401. package/dist/tool-system/permission.js +429 -30
  402. package/dist/tool-system/registry.d.ts +15 -3
  403. package/dist/tool-system/registry.js +82 -40
  404. package/dist/tool-system/sandbox/index.js +3 -8
  405. package/dist/tool-system/sandbox/seatbelt.js +2 -6
  406. package/dist/tool-system/session-tool-host.d.ts +139 -0
  407. package/dist/tool-system/session-tool-host.js +167 -0
  408. package/dist/tool-system/testing/tool-registry-harness.d.ts +31 -0
  409. package/dist/tool-system/testing/tool-registry-harness.js +51 -0
  410. package/dist/tool-system/validation.d.ts +14 -7
  411. package/dist/tool-system/validation.js +701 -7
  412. package/dist/types.d.ts +201 -20
  413. package/dist/{cc-orchestrator → utils}/cwd-normalize.d.ts +1 -0
  414. package/dist/{cc-orchestrator → utils}/cwd-normalize.js +1 -0
  415. package/dist/utils/file-mutex.d.ts +65 -0
  416. package/dist/utils/file-mutex.js +145 -0
  417. package/dist/utils/json.d.ts +2 -4
  418. package/dist/utils/json.js +2 -4
  419. package/dist/utils/lockfile.d.ts +30 -1
  420. package/dist/utils/lockfile.js +2 -2
  421. package/dist/utils/secret-scrubber.d.ts +8 -0
  422. package/dist/utils/secret-scrubber.js +227 -0
  423. package/dist/utils/toolDisplay.js +1 -1
  424. package/package.json +20 -5
  425. package/THIRD_PARTY_NOTICES.md +0 -206
  426. package/dist/arena/arena.d.ts +0 -43
  427. package/dist/arena/arena.js +0 -333
  428. package/dist/arena/context/context-tools.d.ts +0 -16
  429. package/dist/arena/context/context-tools.js +0 -272
  430. package/dist/arena/context/within-root.d.ts +0 -7
  431. package/dist/arena/context/within-root.js +0 -15
  432. package/dist/arena/detect-mode.d.ts +0 -20
  433. package/dist/arena/detect-mode.js +0 -78
  434. package/dist/arena/digest-builder.d.ts +0 -25
  435. package/dist/arena/digest-builder.js +0 -120
  436. package/dist/arena/index.d.ts +0 -29
  437. package/dist/arena/index.js +0 -29
  438. package/dist/arena/iterate/convergence.d.ts +0 -25
  439. package/dist/arena/iterate/convergence.js +0 -103
  440. package/dist/arena/iterate/formats/index.d.ts +0 -22
  441. package/dist/arena/iterate/formats/index.js +0 -283
  442. package/dist/arena/iterate/index.d.ts +0 -11
  443. package/dist/arena/iterate/index.js +0 -9
  444. package/dist/arena/iterate/iterative-arena.d.ts +0 -23
  445. package/dist/arena/iterate/iterative-arena.js +0 -237
  446. package/dist/arena/iterate/parse.d.ts +0 -42
  447. package/dist/arena/iterate/parse.js +0 -123
  448. package/dist/arena/iterate/phases/argue.d.ts +0 -22
  449. package/dist/arena/iterate/phases/argue.js +0 -165
  450. package/dist/arena/iterate/phases/revise.d.ts +0 -16
  451. package/dist/arena/iterate/phases/revise.js +0 -62
  452. package/dist/arena/iterate/phases/tournament.d.ts +0 -34
  453. package/dist/arena/iterate/phases/tournament.js +0 -113
  454. package/dist/arena/iterate/tools/web-tools.d.ts +0 -13
  455. package/dist/arena/iterate/tools/web-tools.js +0 -54
  456. package/dist/arena/iterate/types.d.ts +0 -152
  457. package/dist/arena/iterate/types.js +0 -8
  458. package/dist/arena/ledger.d.ts +0 -47
  459. package/dist/arena/ledger.js +0 -159
  460. package/dist/arena/lenses/architecture.d.ts +0 -5
  461. package/dist/arena/lenses/architecture.js +0 -22
  462. package/dist/arena/lenses/engineering.d.ts +0 -5
  463. package/dist/arena/lenses/engineering.js +0 -22
  464. package/dist/arena/lenses/general.d.ts +0 -5
  465. package/dist/arena/lenses/general.js +0 -20
  466. package/dist/arena/lenses/index.d.ts +0 -16
  467. package/dist/arena/lenses/index.js +0 -47
  468. package/dist/arena/lenses/product.d.ts +0 -5
  469. package/dist/arena/lenses/product.js +0 -22
  470. package/dist/arena/model-presets.d.ts +0 -23
  471. package/dist/arena/model-presets.js +0 -44
  472. package/dist/arena/phases/adjudication.d.ts +0 -24
  473. package/dist/arena/phases/adjudication.js +0 -141
  474. package/dist/arena/phases/build-consensus.d.ts +0 -29
  475. package/dist/arena/phases/build-consensus.js +0 -83
  476. package/dist/arena/phases/claim-registry.d.ts +0 -26
  477. package/dist/arena/phases/claim-registry.js +0 -60
  478. package/dist/arena/phases/cross-review.d.ts +0 -45
  479. package/dist/arena/phases/cross-review.js +0 -218
  480. package/dist/arena/phases/debate-rounds.d.ts +0 -27
  481. package/dist/arena/phases/debate-rounds.js +0 -159
  482. package/dist/arena/phases/participant-research.d.ts +0 -38
  483. package/dist/arena/phases/participant-research.js +0 -314
  484. package/dist/arena/phases/planning-detail-expansion.d.ts +0 -38
  485. package/dist/arena/phases/planning-detail-expansion.js +0 -119
  486. package/dist/arena/planner.d.ts +0 -27
  487. package/dist/arena/planner.js +0 -311
  488. package/dist/arena/providers/docs.d.ts +0 -7
  489. package/dist/arena/providers/docs.js +0 -111
  490. package/dist/arena/providers/git.d.ts +0 -8
  491. package/dist/arena/providers/git.js +0 -174
  492. package/dist/arena/providers/index.d.ts +0 -32
  493. package/dist/arena/providers/index.js +0 -132
  494. package/dist/arena/providers/none.d.ts +0 -7
  495. package/dist/arena/providers/none.js +0 -11
  496. package/dist/arena/providers/repo.d.ts +0 -7
  497. package/dist/arena/providers/repo.js +0 -258
  498. package/dist/arena/render/session.d.ts +0 -17
  499. package/dist/arena/render/session.js +0 -190
  500. package/dist/arena/render/terminal.d.ts +0 -34
  501. package/dist/arena/render/terminal.js +0 -286
  502. package/dist/arena/strategies/discussion.d.ts +0 -25
  503. package/dist/arena/strategies/discussion.js +0 -143
  504. package/dist/arena/strategies/index.d.ts +0 -15
  505. package/dist/arena/strategies/index.js +0 -28
  506. package/dist/arena/strategies/language-wrapper.d.ts +0 -17
  507. package/dist/arena/strategies/language-wrapper.js +0 -102
  508. package/dist/arena/strategies/lens-wrapper.d.ts +0 -16
  509. package/dist/arena/strategies/lens-wrapper.js +0 -236
  510. package/dist/arena/strategies/planning.d.ts +0 -30
  511. package/dist/arena/strategies/planning.js +0 -225
  512. package/dist/arena/strategies/review.d.ts +0 -26
  513. package/dist/arena/strategies/review.js +0 -168
  514. package/dist/arena/strategies/utils.d.ts +0 -36
  515. package/dist/arena/strategies/utils.js +0 -603
  516. package/dist/arena/tools/selector.d.ts +0 -17
  517. package/dist/arena/tools/selector.js +0 -61
  518. package/dist/arena/transitions.d.ts +0 -53
  519. package/dist/arena/transitions.js +0 -97
  520. package/dist/arena/types.d.ts +0 -514
  521. package/dist/arena/types.js +0 -27
  522. package/dist/cc-orchestrator/agent-adapter.d.ts +0 -54
  523. package/dist/cc-orchestrator/agent-adapter.js +0 -143
  524. package/dist/cc-orchestrator/cc-capability.d.ts +0 -19
  525. package/dist/cc-orchestrator/cc-capability.js +0 -53
  526. package/dist/cc-orchestrator/codex-session-discovery.d.ts +0 -24
  527. package/dist/cc-orchestrator/codex-session-discovery.js +0 -191
  528. package/dist/cc-orchestrator/codex-session-history.d.ts +0 -38
  529. package/dist/cc-orchestrator/codex-session-history.js +0 -247
  530. package/dist/cc-orchestrator/external-agent-bindings.d.ts +0 -27
  531. package/dist/cc-orchestrator/external-agent-bindings.js +0 -150
  532. package/dist/cc-orchestrator/external-agent-changes.d.ts +0 -19
  533. package/dist/cc-orchestrator/external-agent-changes.js +0 -231
  534. package/dist/cc-orchestrator/external-agent-driver.d.ts +0 -19
  535. package/dist/cc-orchestrator/external-agent-driver.js +0 -284
  536. package/dist/cc-orchestrator/external-agent-session-store.d.ts +0 -23
  537. package/dist/cc-orchestrator/external-agent-session-store.js +0 -146
  538. package/dist/cc-orchestrator/index.d.ts +0 -8
  539. package/dist/cc-orchestrator/index.js +0 -8
  540. package/dist/cc-orchestrator/relevance-judge.d.ts +0 -15
  541. package/dist/cc-orchestrator/relevance-judge.js +0 -29
  542. package/dist/cc-orchestrator/session-discovery.d.ts +0 -46
  543. package/dist/cc-orchestrator/session-discovery.js +0 -125
  544. package/dist/cc-orchestrator/session-history.d.ts +0 -54
  545. package/dist/cc-orchestrator/session-history.js +0 -150
  546. package/dist/cron/cron-runtime.d.ts +0 -2
  547. package/dist/cron/cron-runtime.js +0 -2
  548. package/dist/cron/cron-store.d.ts +0 -2
  549. package/dist/cron/cron-store.js +0 -2
  550. package/dist/cron/scheduler.d.ts +0 -7
  551. package/dist/cron/scheduler.js +0 -7
  552. package/dist/engine/goal.js +0 -206
  553. package/dist/external-agents/config.d.ts +0 -2
  554. package/dist/external-agents/config.js +0 -15
  555. package/dist/external-agents/types.d.ts +0 -31
  556. package/dist/git/parse-log.d.ts +0 -13
  557. package/dist/git/parse-log.js +0 -21
  558. package/dist/git/utils.d.ts +0 -49
  559. package/dist/git/utils.js +0 -161
  560. package/dist/git/worktree/crud.d.ts +0 -69
  561. package/dist/git/worktree/crud.js +0 -206
  562. package/dist/git/worktree/diff.d.ts +0 -14
  563. package/dist/git/worktree/diff.js +0 -82
  564. package/dist/git/worktree/git-exec.d.ts +0 -7
  565. package/dist/git/worktree/git-exec.js +0 -51
  566. package/dist/git/worktree/index.d.ts +0 -5
  567. package/dist/git/worktree/index.js +0 -5
  568. package/dist/git/worktree/query.d.ts +0 -42
  569. package/dist/git/worktree/query.js +0 -121
  570. package/dist/git/worktree/slug.d.ts +0 -11
  571. package/dist/git/worktree/slug.js +0 -58
  572. package/dist/git/worktree.d.ts +0 -1
  573. package/dist/git/worktree.js +0 -5
  574. package/dist/lsp/client.d.ts +0 -41
  575. package/dist/lsp/client.js +0 -186
  576. package/dist/lsp/manager.d.ts +0 -40
  577. package/dist/lsp/manager.js +0 -155
  578. package/dist/lsp/root-path.d.ts +0 -9
  579. package/dist/lsp/root-path.js +0 -12
  580. package/dist/lsp/servers.d.ts +0 -16
  581. package/dist/lsp/servers.js +0 -60
  582. package/dist/prompt/sections/coding.md +0 -35
  583. package/dist/quota/credentials.d.ts +0 -3
  584. package/dist/quota/credentials.js +0 -80
  585. package/dist/quota/index.d.ts +0 -36
  586. package/dist/quota/index.js +0 -155
  587. package/dist/quota/types.d.ts +0 -48
  588. package/dist/quota/types.js +0 -13
  589. package/dist/review/review-prompt.d.ts +0 -28
  590. package/dist/review/review-prompt.js +0 -81
  591. package/dist/state.d.ts +0 -160
  592. package/dist/state.js +0 -267
  593. package/dist/tool-system/builtin/apply-patch/applier.d.ts +0 -26
  594. package/dist/tool-system/builtin/apply-patch/applier.js +0 -308
  595. package/dist/tool-system/builtin/apply-patch/backup-targets.d.ts +0 -10
  596. package/dist/tool-system/builtin/apply-patch/backup-targets.js +0 -30
  597. package/dist/tool-system/builtin/apply-patch/index.d.ts +0 -20
  598. package/dist/tool-system/builtin/apply-patch/index.js +0 -105
  599. package/dist/tool-system/builtin/apply-patch/parser.d.ts +0 -17
  600. package/dist/tool-system/builtin/apply-patch/parser.js +0 -212
  601. package/dist/tool-system/builtin/apply-patch/seek-sequence.d.ts +0 -18
  602. package/dist/tool-system/builtin/apply-patch/seek-sequence.js +0 -123
  603. package/dist/tool-system/builtin/apply-patch/types.d.ts +0 -49
  604. package/dist/tool-system/builtin/apply-patch/types.js +0 -13
  605. package/dist/tool-system/builtin/arena.d.ts +0 -31
  606. package/dist/tool-system/builtin/arena.js +0 -415
  607. package/dist/tool-system/builtin/brief.d.ts +0 -6
  608. package/dist/tool-system/builtin/brief.js +0 -41
  609. package/dist/tool-system/builtin/check-quota.d.ts +0 -15
  610. package/dist/tool-system/builtin/check-quota.js +0 -34
  611. package/dist/tool-system/builtin/drive-claude-code.d.ts +0 -51
  612. package/dist/tool-system/builtin/drive-claude-code.js +0 -698
  613. package/dist/tool-system/builtin/lsp.d.ts +0 -7
  614. package/dist/tool-system/builtin/lsp.js +0 -144
  615. package/dist/tool-system/builtin/notebook-edit.d.ts +0 -7
  616. package/dist/tool-system/builtin/notebook-edit.js +0 -127
  617. package/dist/tool-system/builtin/worktree.d.ts +0 -11
  618. package/dist/tool-system/builtin/worktree.js +0 -345
  619. /package/dist/{external-agents → links}/types.js +0 -0
  620. /package/dist/{engine/session-usage.d.ts → session/usage.d.ts} +0 -0
  621. /package/dist/{engine/session-usage.js → session/usage.js} +0 -0
@@ -3,66 +3,59 @@
3
3
  */
4
4
  import { createLLMClient } from "../llm/client-factory.js";
5
5
  import { ToolRegistry } from "../tool-system/registry.js";
6
- import { ToolExecutor } from "../tool-system/executor.js";
7
- import { InvestigationGuard } from "../tool-system/investigation-guard.js";
8
- import { TaskGuard } from "../tool-system/task-guard.js";
6
+ import { queryExtensionModules, registerExtensionModules, } from "../tool-system/capability-module.js";
9
7
  import { readLastTodoSnapshot } from "../tool-system/builtin/task.js";
10
- import { applyDynamicToolDef } from "./dynamic-tool-defs.js";
11
8
  import { getMergedCatalog } from "../model-catalog/index.js";
12
9
  import { modelEntriesFromConnections } from "./model-connections-pool.js";
13
- import { resolveAuxKey } from "./aux-key.js";
14
- import { addCumulativeUsage, cumulativeCacheHitRate, foldRunUsage, normalizeCumulativeUsageCounters, } from "./session-usage.js";
10
+ import { cumulativeCacheHitRate, foldRunUsage, normalizeCumulativeUsageCounters, } from "../session/usage.js";
15
11
  import { enqueueSteerItem, consumeSteerItems, removeSteerItem, } from "./steer-queue.js";
16
- import { resolveSandboxConfig } from "./sandbox-config.js";
17
- import { sandboxCacheKey } from "./sandbox-cache-key.js";
18
- import { BUILTIN_TOOL_GUARDS } from "../tool-system/builtin/index.js";
12
+ import { RunEnvironmentResolver } from "./run-environment.js";
13
+ import { BUILTIN_TOOLS, } from "../tool-system/builtin/index.js";
19
14
  import { asyncAgentRegistry } from "../tool-system/builtin/agent-registry.js";
20
15
  import { backgroundShellManager } from "../runtime/background-shell.js";
21
- import { notificationQueue, buildNotificationMessage, } from "../tool-system/builtin/agent-notifications.js";
22
- import { PermissionClassifier, HeadlessApprovalBackend, AutoApprovalBackend, InteractiveApprovalBackend, getInteractiveApprovalBackend, } from "../tool-system/permission.js";
16
+ import { notificationQueue } from "../tool-system/builtin/agent-notifications.js";
23
17
  import { HookRegistry } from "../hooks/registry.js";
24
- import { wrapHookMessages } from "../hooks/inject.js";
25
- import { createGoalStopHook } from "../hooks/goal-stop-hook.js";
26
- import { normalizeGoal, resolveGoalSetAt, resolveMaxTurns, resolveMaxStopBlocks, isSameGoalInstance, } from "./goal.js";
18
+ import { normalizeGoal, resolveMaxTurns, resolveMaxStopBlocks, goalConfigFromLifecycle, isGoalLifecycleCurrent, isSameGoalVersion, } from "../goal/lifecycle.js";
27
19
  import { loadPluginHooks } from "../plugins/loadPluginHooks.js";
28
20
  import { pluginAgentDirs } from "../plugins/installer/loadPluginAgents.js";
29
- import { patchOrphanedToolUses } from "./patch-orphaned-tools.js";
30
21
  import { runShellHook, shellHookMatches } from "../hooks/shell-runner.js";
31
22
  import { ContextManager } from "../context/manager.js";
32
- import { estimateTokens, clampContextRatios as clampContextRatiosImpl, } from "../context/compaction.js";
33
- import { PLAN_MODE_ALLOWED_TOOLS } from "../tool-system/plan-mode-allowlist.js";
23
+ import { CONTEXT_PACKAGE_MAX_OUTPUT_TOKENS, buildContextPackagePromptFromSerialized, estimateTokens, groupMessagesByApiRound, serializeContextPackageMessages, clampContextRatios as clampContextRatiosImpl, } from "../context/compaction.js";
34
24
  import { PromptComposer } from "../prompt/composer.js";
35
- import { SessionManager } from "../session/session-manager.js";
36
- import { ModelFacade } from "./model-facade.js";
37
- import { logger, runWithSid, getCurrentSid } from "../logging/logger.js";
38
- import { recordSessionStart, recordSessionEnd } from "../logging/session-recorder.js";
39
- import { sanitizeContent, sanitizeTaskString } from "../logging/sanitize-messages.js";
25
+ import { SessionManager, assertSafeSessionId, isEphemeralSessionState, sessionsRoot, } from "../session/session-manager.js";
26
+ import { createRunUsageAccounting, wireRunModelFacade } from "./run-accounting.js";
27
+ import { logger, runWithSid } from "../logging/logger.js";
28
+ import { recordSessionStart } from "../logging/session-recorder.js";
29
+ import { sanitizeTaskString } from "../logging/sanitize-messages.js";
40
30
  import { TurnLoop } from "./turn-loop.js";
41
- import { MCPManager } from "../tool-system/mcp-manager.js";
42
31
  import { SettingsManager, userHome } from "../settings/manager.js";
43
32
  import { getCredentialAccess } from "../credentials/access.js";
44
- import { isFeatureEnabled, resolveFeatureFlags, } from "../settings/feature-flags.js";
45
- import { effectiveDisabledList, effectiveBuiltinLists } from "../capability-control/overlay.js";
33
+ import { resolveFeatureFlags, } from "../settings/feature-flags.js";
34
+ import { effectiveBuiltinLists, effectiveDisabledList, effectiveProjectOverrides, } from "../capability-control/overlay.js";
46
35
  import { computeEffectiveDisabledLists } from "../capability-control/disabled-lists.js";
47
- import { FileHistory } from "../session/file-history.js";
48
- import { patchBackupTargets } from "../tool-system/builtin/apply-patch/backup-targets.js";
49
- import { resolveSandboxBackend, } from "../tool-system/sandbox/index.js";
36
+ import { registerFileHistoryHook } from "./file-history-hook.js";
50
37
  import { resolveAgentPreset, resolveBuiltinToolNames } from "../preset/index.js";
38
+ import { composeDynamicContextProviders, composeCapabilityEngineHooks, composePromptSections, composeToolCatalog, resolveCapabilities, resolveInstructionBoundary, } from "../capabilities/index.js";
51
39
  import { ModelPool } from "../llm/model-pool.js";
52
40
  import { AgentDefinitionRegistry } from "../agent/agent-definition-registry.js";
53
41
  import { defaultCacheDir } from "../llm/model-cache.js";
54
42
  import { detectProviderFromApiKey, buildModelPool } from "../onboarding.js";
55
43
  import { detectPastedNoise } from "../utils/task-sanitizer.js";
56
- import { buildSessionTitle } from "./session-title.js";
57
- import { MemoryOrchestrator } from "../services/memory-orchestrator.js";
58
- import { runDreamConsolidation } from "../services/dream-consolidation.js";
44
+ import { PromptCacheDiagnosticRecorder, promptCacheDropHint, } from "./prompt-cache-diagnostics.js";
59
45
  import { buildRunUserMessageContent, prepareRunImageInput } from "./run-image-input.js";
46
+ import { QUICK_CHAT_RESTRICTED_PROFILE, } from "./run-types.js";
47
+ import { createSubAgentSpawner } from "./subagent-spawner.js";
48
+ import { AuxiliaryPipeline, sameLlmIdentity } from "./auxiliary-pipeline.js";
49
+ import { PermissionController } from "./permission-controller.js";
50
+ import { buildPromptComposerConfig } from "./run-setup.js";
51
+ import { resolveRunWorkspace } from "./run-workspace.js";
52
+ import { openRunSession } from "./run-session-open.js";
53
+ import { buildRunToolContext, buildRunPermissionPipeline, connectRunMcp, assembleRunToolDefs, } from "./run-tooling.js";
54
+ import { createRunContextManager, composeRunSystemPrompt, assembleRunMessages, } from "./run-context.js";
55
+ import { resolveRunGoal, armRunGoalHook, createGoalTerminationApplier, } from "./run-goal.js";
56
+ import { drainHeadlessBackgroundAgents, finalizeRunSuccess, buildRunFailureResult, } from "./run-finalize.js";
60
57
  import { join } from "node:path";
61
58
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
62
- const CACHE_READ_DROP_MIN_PREVIOUS_TOKENS = 100;
63
- const CACHE_READ_DROP_MAX_CURRENT_TOKENS = 64;
64
- const CACHE_READ_DROP_RATIO = 0.1;
65
- const CACHE_READ_DIAGNOSTIC_MAX_SESSIONS = 256;
66
59
  /**
67
60
  * Build ScanOptions.compatFileNames from the user's instruction compat toggles.
68
61
  * Primary file name stays hard-wired to CODESHELL.md (not exposed). Turning a
@@ -78,31 +71,11 @@ export function compatFileNamesFrom(instructions) {
78
71
  names.push("AGENTS.md");
79
72
  return names;
80
73
  }
81
- /**
82
- * True when two LLMConfigs name the SAME client identity — i.e. building a
83
- * client from either would talk to the same model on the same endpoint with the
84
- * same shaping. Used by resolveAuxClient to de-dup the aux client against the
85
- * active model WITHOUT collapsing two distinct pool keys that merely share a
86
- * `model` NAME but differ in reasoning/maxTokens/baseUrl/provider. Compares the
87
- * fields that actually change request behavior; apiKey is intentionally NOT
88
- * compared (two keys with the same endpoint+model but different credentials
89
- * still produce equivalent aux work and don't warrant a second client). The
90
- * reasoning object is compared by normalized JSON since it's a small
91
- * discriminated union.
92
- */
93
- function sameLlmIdentity(a, b) {
94
- return (a.model === b.model &&
95
- (a.baseUrl ?? undefined) === (b.baseUrl ?? undefined) &&
96
- (a.provider ?? undefined) === (b.provider ?? undefined) &&
97
- (a.providerKind ?? undefined) === (b.providerKind ?? undefined) &&
98
- (a.maxTokens ?? undefined) === (b.maxTokens ?? undefined) &&
99
- JSON.stringify(a.reasoning ?? null) === JSON.stringify(b.reasoning ?? null));
100
- }
101
74
  // Re-export the config hot-reload patch builder from here so the protocol
102
75
  // server (and tests) can import it alongside Engine without reaching into the
103
76
  // settings/ subtree directly. The implementation lives in settings/ to keep
104
77
  // engine.ts from growing and to sit next to personalizationFrom it composes.
105
- export { diskDefaultsFrom } from "../settings/disk-defaults.js";
78
+ export { diskDefaultsFrom } from "./disk-defaults.js";
106
79
  /**
107
80
  * Resolve the LLM config for a spawned child Engine.
108
81
  * - `modelKey` set + present in pool → that model's config (pure entry-derived
@@ -115,34 +88,16 @@ export { diskDefaultsFrom } from "../settings/disk-defaults.js";
115
88
  * Engine directly via EngineConfig.clientDefaults — they do not flow through
116
89
  * this helper because they're not part of LLMConfig anymore.
117
90
  */
118
- export function resolveChildLlm(modelKey, pool, parentLlm) {
119
- if (modelKey && pool?.has(modelKey)) {
120
- const resolved = pool.resolveLLMConfig(modelKey);
121
- if (resolved)
122
- return resolved;
123
- }
124
- return parentLlm;
125
- }
91
+ export { resolveChildLlm, resolveChildToolScope } from "./subagent-spawner.js";
92
+ // resolveRunCwd moved to run-workspace.ts; re-exported here so
93
+ // engine.resolve-cwd.test.ts keeps resolving it from engine.js unchanged.
94
+ export { resolveRunCwd } from "./run-workspace.js";
126
95
  /**
127
96
  * Load reusable sub-agent role definitions, merging:
128
97
  * 1. project-level <cwd>/.code-shell/agents/*.md (ships built-ins)
129
98
  * 2. user-level ~/.code-shell/agents/*.md (user wins on name)
130
99
  * Names in `disabledAgents` are filtered out so the LLM never sees them.
131
100
  */
132
- /**
133
- * Resolve the working directory for a run. Precedence for legacy sessions:
134
- * options.cwd > resumed session's state.cwd > config.cwd > process.cwd()
135
- *
136
- * The session-cwd tier is what stops a project-bound session from being
137
- * resumed against the wrong directory: when a host omits options.cwd (e.g. its
138
- * sidebar repo selection drifted to null), the session's own recorded cwd is
139
- * recovered so the engine still loads THAT project's agents/settings/memory,
140
- * not whatever process.cwd() happens to be. Pure so the precedence is testable
141
- * without standing up an Engine.
142
- */
143
- export function resolveRunCwd(args) {
144
- return args.optionCwd ?? args.sessionCwd ?? args.configCwd ?? args.processCwd;
145
- }
146
101
  export function loadAgentDefinitionsForCwd(cwd, disabledAgents = [], disabledPlugins = []) {
147
102
  // userHome() (not raw homedir(), which bun caches at process start and never
148
103
  // re-reads) so the user-agents dir honors a test's process.env.HOME override
@@ -163,7 +118,6 @@ export function loadAgentDefinitionsForCwd(cwd, disabledAgents = [], disabledPlu
163
118
  ...(cwd ? [{ dir: `${cwd}/.code-shell/agents`, source: "project" }] : []),
164
119
  ], disabledAgents);
165
120
  }
166
- const NESTED_AGENT_TOOLS = ["Agent", "AgentStatus", "AgentCancel", "AgentSendInput"];
167
121
  /**
168
122
  * #7: apply a project's per-turn builtin capability override to a tool list.
169
123
  * A builtin marked `off` for the current cwd is HIDDEN from the turn's tool
@@ -184,17 +138,6 @@ export function applyBuiltinOverrideVisibility(tools, override) {
184
138
  * - `allowlist` undefined → inherit parent enabled/disabled, always with the
185
139
  * nested-agent tools forced into `disabled` (no grandchildren).
186
140
  */
187
- export function resolveChildToolScope(allowlist, parentDisabled, parentEnabled) {
188
- if (allowlist) {
189
- return {
190
- enabled: allowlist.filter((t) => !NESTED_AGENT_TOOLS.includes(t)),
191
- disabled: [...NESTED_AGENT_TOOLS],
192
- };
193
- }
194
- const disabled = Array.from(new Set([...(parentDisabled ?? []), ...NESTED_AGENT_TOOLS]));
195
- const enabled = parentEnabled?.filter((t) => !NESTED_AGENT_TOOLS.includes(t));
196
- return { enabled, disabled };
197
- }
198
141
  export class Engine {
199
142
  config;
200
143
  // Resolved per-session preset. Set in the ctor; re-resolved by
@@ -204,9 +147,22 @@ export class Engine {
204
147
  // and is NOT rebuilt on reload — a preset change that alters the builtin tool
205
148
  // set only takes effect on session restart (logged in refreshRuntimeConfig).
206
149
  preset;
150
+ /** Capability-free seed owned by the runtime/host; never mutated by an Engine. */
151
+ runtimeToolRegistry;
152
+ /** Engine-local view containing only this Engine's capability modules. */
207
153
  toolRegistry;
154
+ capabilities;
155
+ toolCatalog;
156
+ toolGuards;
157
+ /** Per-turn dynamic definition rewriters contributed by builtin exposures. */
158
+ toolRewriters;
159
+ /** Named per-run behavior profiles (core defaults + config + extensions). */
160
+ behaviorProfiles;
161
+ capabilityPromptSections;
162
+ capabilityDynamicContextProviders;
208
163
  hooks;
209
164
  sessionManager;
165
+ sessionMessageRouter;
210
166
  mcpManager;
211
167
  modelPool;
212
168
  /**
@@ -228,24 +184,15 @@ export class Engine {
228
184
  agentDefsCache;
229
185
  /** Shared resources supplied at construction (adapter pattern — null when self-constructed). */
230
186
  runtime;
231
- sandboxCache = new Map();
232
- /** Active permission mode for this Engine instance. */
233
- permissionMode;
234
- /** True when permissionMode === "plan". */
235
- planMode;
187
+ runEnvironmentResolver;
188
+ auxiliaryPipeline;
189
+ permissionController;
236
190
  // Lazy SettingsManager — reused across updateConfig/readSetting so we
237
191
  // don't re-read 6+ JSON files on every /model, /login, etc. The manager
238
192
  // handles its own cache invalidation in saveUserSetting().
239
193
  settingsManager;
240
- /**
241
- * Cached auxiliary-task LLM client, keyed by the models[].key it was built
242
- * from. Background calls (memory extraction, auto-dream) reuse it across
243
- * runs so we don't redo the provider handshake every session. Invalidated
244
- * implicitly: a changed auxModelKey produces a different cache key.
245
- */
246
- auxClientCache;
247
194
  // Live state from the current/most-recent run, retained for /compact and
248
- // for live-mutating PermissionClassifier on permission-mode switch.
195
+ // run-boundary PermissionClassifier replacement/reconfiguration.
249
196
  lastContextManager;
250
197
  lastMessages;
251
198
  lastSessionId;
@@ -266,7 +213,7 @@ export class Engine {
266
213
  * LLM response arrives.
267
214
  */
268
215
  ctxOverheadBySid = new Map();
269
- lastCacheReadBySid = new Map();
216
+ promptCacheDiagnostics = new PromptCacheDiagnosticRecorder({ maxSessions: 256 });
270
217
  /**
271
218
  * Step-gap steering queue (per sessionId, in-memory). Host pushes user
272
219
  * messages here via enqueueSteer while a run is in flight; the turn loop
@@ -276,7 +223,6 @@ export class Engine {
276
223
  * multiple Engines don't interfere and it stays cleanly extractable.
277
224
  */
278
225
  steerQueueBySid = new Map();
279
- activePermission;
280
226
  /**
281
227
  * The TurnLoop of the in-flight run(), exposed so extendGoalRun() can bump a
282
228
  * running goal's turn/budget ceilings mid-run (TODO 3.1). Null when idle.
@@ -288,25 +234,46 @@ export class Engine {
288
234
  * otherwise keep re-blocking the stop). Null when no goal run is active.
289
235
  */
290
236
  activeGoalHook = null;
237
+ /** Whether activeGoalHook is currently registered (pause detaches it). */
238
+ activeGoalHookAttached = false;
239
+ /** Mutable goal view consumed by the running judge after edits/resume. */
240
+ activeRuntimeGoal = null;
241
+ /** Mutable terminal snapshot kept in sync with a mid-run objective edit. */
242
+ activePersistedRunGoal = null;
291
243
  /**
292
244
  * The in-flight run's session bundle, held so clearGoal() can wipe the goal
293
245
  * on the SAME instance the run loop is persisting each turn — not a fresh
294
246
  * detached copy from resume(). Without this, a mid-run 清除 clears disk, but
295
247
  * the still-running loop's next saveState(bundle.state) resurrects the goal
296
- * (bundle.state.activeGoal was never dropped). A never-completing goal run
248
+ * (bundle.state.goalLifecycle was never rebased). A never-completing goal run
297
249
  * (judge keeps returning not_met → continueSession) stays live for a long
298
250
  * time, so this write-back race is the norm, not an edge case, for such runs.
299
251
  * Single-valued like activeTurnLoop — one top-level run per engine at a time.
300
252
  * Null when idle; set at run start, cleared in run's finally.
301
253
  */
302
254
  activeRunSession = null;
255
+ /**
256
+ * Same-instance run guard. Engine owns single-valued live controls and one
257
+ * HookRegistry, so a second run must not enter until the first has completed
258
+ * all state persistence and end hooks. Cross-instance/process whole-state
259
+ * writers are additionally fenced by SessionManager's persisted revision CAS.
260
+ */
261
+ runInProgress = false;
262
+ agentControlStateListener;
263
+ agentDirectionsDeliveredListener;
264
+ /** Permission update requested while runInProgress. Applied in run() finally. */
303
265
  /** Public accessor so UI/clients can read the resolved per-model window. */
304
266
  get maxContextTokens() {
305
267
  return this.resolveMaxContextTokens();
306
268
  }
307
269
  resolveMaxContextTokens() {
308
- const modelEntry = this.modelPool.get();
309
- return modelEntry?.maxContextTokens ?? this.config.maxContextTokens ?? 200_000;
270
+ const modelEntry = this.modelPool
271
+ .list()
272
+ .find((entry) => sameLlmIdentity(this.modelPool.toLLMConfig(entry), this.config.llm));
273
+ return (this.config.llm.maxContextTokens ??
274
+ modelEntry?.maxContextTokens ??
275
+ this.config.maxContextTokens ??
276
+ 200_000);
310
277
  }
311
278
  /**
312
279
  * Compaction thresholds from settings.context, clamped so they keep the
@@ -335,10 +302,11 @@ export class Engine {
335
302
  * emits should go through this wrapper to keep the context envelope
336
303
  * uniform with TurnLoop.emitHook.
337
304
  */
338
- async emitHook(event, data = {}) {
305
+ async emitHook(event, data = {}, signal) {
339
306
  return this.hooks.emit(event, {
340
307
  ...data,
341
308
  isSubAgent: this.config.isSubAgent === true,
309
+ signal,
342
310
  });
343
311
  }
344
312
  /**
@@ -423,10 +391,63 @@ export class Engine {
423
391
  this.config = config;
424
392
  // Wire shared runtime (adapter pattern — null when self-constructing).
425
393
  this.runtime = config.runtime ?? null;
426
- // Instance-level permission/plan mode fields.
427
- this.permissionMode = config.permissionMode ?? "acceptEdits";
428
- this.planMode = this.permissionMode === "plan";
429
- this.preset = resolveAgentPreset(config.preset);
394
+ this.runEnvironmentResolver = new RunEnvironmentResolver({
395
+ config: () => this.config,
396
+ settings: () => this.getSettingsManager(),
397
+ credentialAccess: {
398
+ envExposures: (cwd, scope) => getCredentialAccess().envExposures(cwd, scope),
399
+ },
400
+ ...(this.runtime ? { runtime: this.runtime } : {}),
401
+ });
402
+ this.capabilities = resolveCapabilities(config.capabilities);
403
+ this.config = { ...config, capabilities: this.capabilities };
404
+ this.toolCatalog = composeToolCatalog(BUILTIN_TOOLS, this.capabilities, config.extensionModules ?? []);
405
+ this.toolGuards = new Map(this.toolCatalog.flatMap((tool) => tool.exposure.availability
406
+ ? [[tool.definition.name, tool.exposure.availability]]
407
+ : []));
408
+ this.toolRewriters = new Map(this.toolCatalog.flatMap((tool) => tool.exposure.rewriteDefinition
409
+ ? [[tool.definition.name, tool.exposure.rewriteDefinition]]
410
+ : []));
411
+ // Behavior profile registry: core defaults first, then host config, then
412
+ // extension modules — later registrations override earlier ones by id.
413
+ this.behaviorProfiles = new Map([
414
+ QUICK_CHAT_RESTRICTED_PROFILE,
415
+ ...(config.behaviorProfiles ?? []),
416
+ ...(config.extensionModules ?? []).flatMap((module) => module.behaviorProfiles ?? []),
417
+ ].map((profile) => [profile.id, profile]));
418
+ this.capabilityPromptSections = composePromptSections(this.capabilities);
419
+ this.capabilityDynamicContextProviders = composeDynamicContextProviders(this.capabilities);
420
+ this.preset = resolveAgentPreset(config.preset, this.capabilities);
421
+ // Extension catalogTools join the active preset regardless of its name:
422
+ // presets snapshot their tool lists from the catalogs known at module
423
+ // load, which can never include extension packages. Visibility stays
424
+ // gated by each tool's exposure.availability guard.
425
+ const extensionCatalogTools = (config.extensionModules ?? []).flatMap((module) => [
426
+ ...(module.catalogTools ?? []),
427
+ ]);
428
+ if (extensionCatalogTools.length > 0) {
429
+ this.preset = {
430
+ ...this.preset,
431
+ builtinTools: [
432
+ ...this.preset.builtinTools,
433
+ ...extensionCatalogTools.map((tool) => tool.definition.name),
434
+ ],
435
+ defaultPermissionRules: [
436
+ ...this.preset.defaultPermissionRules,
437
+ ...extensionCatalogTools.flatMap((tool) => [
438
+ ...(tool.exposure.defaultPermissionRules ?? []),
439
+ ]),
440
+ ],
441
+ };
442
+ }
443
+ this.permissionController = new PermissionController({
444
+ config: () => this.config,
445
+ updateConfig: (next) => {
446
+ this.config = next;
447
+ },
448
+ presetRules: () => [...this.preset.defaultPermissionRules],
449
+ runInProgress: () => this.runInProgress,
450
+ });
430
451
  // Fold the project's capabilityOverrides.builtin overlay over the global
431
452
  // enabled/disabled builtin lists so a project can force-enable a
432
453
  // globally-disabled builtin tool or force-disable a globally-enabled one
@@ -442,16 +463,25 @@ export class Engine {
442
463
  // per-turn path can only hide, not add, so a freshly-`on`'d builtin not in
443
464
  // the set needs a session restart to appear.
444
465
  const builtinLists = effectiveBuiltinLists(config.enabledBuiltinTools ?? [], config.disabledBuiltinTools ?? [], this.readBuiltinOverride(config.cwd));
445
- this.toolRegistry =
466
+ this.runtimeToolRegistry =
446
467
  config.runtime?.toolRegistry ??
447
468
  new ToolRegistry({
448
469
  builtinTools: resolveBuiltinToolNames({
449
470
  preset: this.preset.name,
450
471
  host: config.builtinToolHost,
451
- enabledBuiltinTools: builtinLists.enabledBuiltinTools,
472
+ enabledBuiltinTools: [
473
+ ...builtinLists.enabledBuiltinTools,
474
+ // Extension catalogTools are preset-agnostic (see preset merge
475
+ // above); their availability guards gate actual visibility.
476
+ ...extensionCatalogTools.map((tool) => tool.definition.name),
477
+ ],
452
478
  disabledBuiltinTools: builtinLists.disabledBuiltinTools,
479
+ capabilities: this.capabilities,
453
480
  }),
481
+ toolCatalog: this.toolCatalog,
454
482
  });
483
+ this.toolRegistry = this.runtimeToolRegistry.fork();
484
+ registerExtensionModules(this.toolRegistry, config.extensionModules ?? []);
455
485
  this.hooks = new HookRegistry();
456
486
  // Installed-plugin hooks — declared in each plugin's hooks/hooks.json.
457
487
  // Registered first (priority 80) so user-authored hooks at lower
@@ -472,14 +502,26 @@ export class Engine {
472
502
  loadPluginHooks(this.hooks, disabledPlugins, disabledPluginHooks);
473
503
  }
474
504
  // settings.hooks → shell-command wrappers. Chain order:
475
- // plugin (80) → shell (50) → code (default 0).
505
+ // plugin (80) → shell (50) → capability (20) → SDK code (default 0).
476
506
  this.registerSettingsHooks();
507
+ for (const hook of composeCapabilityEngineHooks(this.capabilities)) {
508
+ this.hooks.register(hook.event, hook.handler, hook.priority, hook.name);
509
+ }
477
510
  for (const hook of config.hooks ?? []) {
478
511
  this.hooks.register(hook.event, hook.handler, hook.priority, hook.name);
479
512
  }
480
- this.sessionManager = new SessionManager(config.sessionStorageDir);
513
+ this.sessionManager = new SessionManager(config.sessionStorageDir, this.capabilities
514
+ .map((capability) => capability.sessionWorkspace)
515
+ .find((candidate) => candidate !== undefined));
481
516
  // Initialize model pool — prefer runtime's shared pool, fall back to self-constructed.
482
517
  this.modelPool = config.runtime?.modelPool ?? new ModelPool();
518
+ this.auxiliaryPipeline = new AuxiliaryPipeline({
519
+ config: () => this.config,
520
+ settings: () => this.getSettingsManager(),
521
+ modelPool: () => this.modelPool,
522
+ toolRegistry: () => this.toolRegistry,
523
+ toolContext: () => this.buildToolContext(),
524
+ });
483
525
  if (!config.runtime) {
484
526
  this.populateModelPoolFromSettings();
485
527
  }
@@ -621,6 +663,10 @@ export class Engine {
621
663
  registerCustomTool(definition, executor) {
622
664
  this.toolRegistry.registerTool(definition, executor);
623
665
  }
666
+ /** Dispatch a host-installed capability query without teaching core its name. */
667
+ queryCapability(type, params = {}) {
668
+ return queryExtensionModules(this.config.extensionModules ?? [], type, params);
669
+ }
624
670
  /**
625
671
  * Inject the askUser handler after construction. Used by AgentServer
626
672
  * to wire its protocol-backed askUser into an Engine that was created
@@ -629,6 +675,13 @@ export class Engine {
629
675
  setAskUser(fn) {
630
676
  this.config.askUser = fn;
631
677
  }
678
+ /** Internal child-runtime seam used by the single-writer supervisor. */
679
+ setAgentControlStateListener(listener) {
680
+ this.agentControlStateListener = listener;
681
+ }
682
+ setAgentDirectionsDeliveredListener(listener) {
683
+ this.agentDirectionsDeliveredListener = listener;
684
+ }
632
685
  /**
633
686
  * Inject the browser automation bridge after construction (same chicken-and-egg
634
687
  * as setAskUser: the desktop host builds the bridge — which drives a webview —
@@ -642,6 +695,14 @@ export class Engine {
642
695
  setWorkspaceBridge(bridge) {
643
696
  this.config.workspaceBridge = bridge;
644
697
  }
698
+ /** Inject the host-backed panel discovery/focus bridge after construction. */
699
+ setPanelBridge(bridge) {
700
+ this.config.panelBridge = bridge;
701
+ }
702
+ /** Inject the host router used by SendMessageToSession. */
703
+ setSessionMessageRouter(router) {
704
+ this.sessionMessageRouter = router;
705
+ }
645
706
  /**
646
707
  * Queue a user message to be spliced into the in-flight run for `sessionId`
647
708
  * at the next turn-loop step boundary — the 不打断 steering path (vs cancel +
@@ -737,6 +798,12 @@ export class Engine {
737
798
  isHeadless() {
738
799
  return this.config.headless === true;
739
800
  }
801
+ get permissionMode() {
802
+ return this.permissionController.permissionMode;
803
+ }
804
+ get planMode() {
805
+ return this.permissionController.planMode;
806
+ }
740
807
  /**
741
808
  * Probe whether a session already exists on disk (its state/transcript dir is
742
809
  * present). Used by the protocol server to distinguish "resume an existing
@@ -747,49 +814,184 @@ export class Engine {
747
814
  sessionExistsOnDisk(sessionId) {
748
815
  return this.sessionManager.exists(sessionId);
749
816
  }
817
+ forkSession(sourceSessionId, options) {
818
+ return this.sessionManager.fork(sourceSessionId, options);
819
+ }
820
+ selectContextPackage(sourceSessionId, range) {
821
+ return this.sessionManager.selectContextPackage(sourceSessionId, range);
822
+ }
823
+ createSummaryFork(sourceSessionId, options) {
824
+ return this.sessionManager.createSummaryFork(sourceSessionId, options);
825
+ }
826
+ /** Summarize a selected transcript package using the configured aux tier. */
827
+ async summarizeContextPackage(messages, signal, sourceSessionId) {
828
+ if (messages.length === 0)
829
+ throw new Error("Cannot summarize an empty context package");
830
+ const serializedSelection = serializeContextPackageMessages(messages);
831
+ if (!serializedSelection.hasSummarizableContent) {
832
+ throw new Error("Cannot summarize an image-only context package without textual or tool facts");
833
+ }
834
+ if (sourceSessionId && this.config.costStore) {
835
+ const persistedCost = this.sessionManager.resume(sourceSessionId).state.costState;
836
+ if (persistedCost)
837
+ this.config.costStore.restore(persistedCost);
838
+ }
839
+ const primaryClient = await createLLMClient(this.config.llm, this.config.clientDefaults);
840
+ const resolvedAux = await this.auxiliaryPipeline.resolveAuxClientWithMetadata(primaryClient, this.resolveMaxContextTokens());
841
+ const client = resolvedAux.client;
842
+ const systemPrompt = "You package selected conversation context. Be concise, factual, and complete.";
843
+ const fitsAuxWindow = (conversation, priorSummary) => {
844
+ const prompt = buildContextPackagePromptFromSerialized(conversation, priorSummary);
845
+ const requestTokens = estimateTokens([
846
+ { role: "system", content: systemPrompt },
847
+ { role: "user", content: prompt },
848
+ ]);
849
+ return requestTokens + CONTEXT_PACKAGE_MAX_OUTPUT_TOKENS <= resolvedAux.maxContextTokens;
850
+ };
851
+ if (!fitsAuxWindow("x")) {
852
+ throw new Error(`Auxiliary model context window (${resolvedAux.maxContextTokens}) is too small for the context package template and output reserve`);
853
+ }
854
+ // Preserve complete API rounds whenever they fit. If one round alone is
855
+ // larger than the aux window, split its lossless serialized form and feed
856
+ // every fragment through the same rolling nine-section merge.
857
+ const pending = groupMessagesByApiRound(messages).map((group) => serializeContextPackageMessages(group).text);
858
+ let summary;
859
+ while (pending.length > 0) {
860
+ let conversation = "";
861
+ while (pending.length > 0) {
862
+ const next = pending[0];
863
+ const candidate = conversation ? `${conversation}\n${next}` : next;
864
+ if (fitsAuxWindow(candidate, summary)) {
865
+ conversation = candidate;
866
+ pending.shift();
867
+ continue;
868
+ }
869
+ if (conversation)
870
+ break;
871
+ let low = 1;
872
+ let high = next.length;
873
+ let fitLength = 0;
874
+ while (low <= high) {
875
+ const middle = Math.floor((low + high) / 2);
876
+ if (fitsAuxWindow(next.slice(0, middle), summary)) {
877
+ fitLength = middle;
878
+ low = middle + 1;
879
+ }
880
+ else {
881
+ high = middle - 1;
882
+ }
883
+ }
884
+ if (fitLength === 0) {
885
+ throw new Error(`Auxiliary model context window (${resolvedAux.maxContextTokens}) cannot fit the rolling context package prompt`);
886
+ }
887
+ conversation = next.slice(0, fitLength);
888
+ const remainder = next.slice(fitLength);
889
+ if (remainder)
890
+ pending[0] = remainder;
891
+ else
892
+ pending.shift();
893
+ break;
894
+ }
895
+ const response = await client.createMessage({
896
+ systemPrompt,
897
+ messages: [
898
+ {
899
+ role: "user",
900
+ content: buildContextPackagePromptFromSerialized(conversation, summary),
901
+ },
902
+ ],
903
+ tools: [],
904
+ maxTokens: CONTEXT_PACKAGE_MAX_OUTPUT_TOKENS,
905
+ billingEnabled: true,
906
+ requestVisible: false,
907
+ reasoning: { mode: "off" },
908
+ signal,
909
+ });
910
+ if (response.usage && sourceSessionId) {
911
+ this.sessionManager.recordAuxiliaryUsage(sourceSessionId, response.usage, this.config.costStore?.serialize());
912
+ }
913
+ summary = response.text.trim();
914
+ if (!summary)
915
+ throw new Error("Context package summary was empty");
916
+ }
917
+ return {
918
+ summary: summary,
919
+ estimatedTokens: estimateTokens([{ role: "user", content: summary }]),
920
+ };
921
+ }
922
+ /** Restore a cold Engine's configured model from persisted source state without resetting usage. */
923
+ restoreSessionModel(sessionId) {
924
+ const state = this.sessionManager.resume(sessionId).state;
925
+ if (this.config.llm.model === state.model && this.config.llm.provider === state.provider)
926
+ return;
927
+ const entry = this.modelPool
928
+ .list()
929
+ .find((candidate) => candidate.model === state.model && candidate.provider === state.provider) ?? this.modelPool.list().find((candidate) => candidate.model === state.model);
930
+ if (!entry) {
931
+ throw new Error(`Persisted source model is no longer configured: ${state.model}`);
932
+ }
933
+ this.config = { ...this.config, llm: this.modelPool.toLLMConfig(entry) };
934
+ }
750
935
  /**
751
- * Run a task from start to finish.
936
+ * Run a task from start to finish. Rejects immediately when this Engine
937
+ * instance already has a run in progress; hosts that want queueing own that
938
+ * policy (for example ChatSession's FIFO queue).
752
939
  */
753
940
  async run(task, options) {
754
- const workspaceResume = options?.sessionId && this.sessionManager.exists(options.sessionId)
755
- ? await this.sessionManager.resolveSessionWorkspaceForResume(options.sessionId)
756
- : undefined;
757
- if (workspaceResume && !workspaceResume.ok) {
758
- return {
759
- text: `ERROR: ${workspaceResume.message}`,
760
- reason: "completed",
761
- sessionId: options.sessionId,
762
- turnCount: 0,
763
- usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
764
- };
941
+ if (this.runInProgress) {
942
+ throw new Error("Engine.run() cannot start while another run is in progress");
765
943
  }
766
- if (workspaceResume?.ok &&
767
- workspaceResume.reason === "worktree_missing_branch_gone" &&
768
- workspaceResume.message) {
769
- return {
770
- text: workspaceResume.message,
771
- reason: "completed",
772
- sessionId: options.sessionId,
773
- turnCount: 0,
774
- usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
775
- };
944
+ this.runInProgress = true;
945
+ try {
946
+ return await this.runExclusive(task, options);
776
947
  }
777
- // Existing P1 sessions resolve cwd from SessionWorkspace, even if the host
778
- // passes a stale cwd. Legacy sessions without workspace keep the historical
779
- // explicit-cwd precedence for backward compatibility.
780
- const workspaceCwd = workspaceResume?.ok && workspaceResume.reason !== "legacy" ? workspaceResume.cwd : undefined;
781
- const sessionCwd = workspaceCwd === undefined && options?.cwd === undefined && options?.sessionId
782
- ? workspaceResume?.ok
783
- ? workspaceResume.cwd
784
- : this.sessionManager.readCwd(options.sessionId)
785
- : undefined;
786
- const cwd = workspaceCwd ??
787
- resolveRunCwd({
788
- optionCwd: options?.cwd,
789
- sessionCwd,
790
- configCwd: this.config.cwd,
791
- processCwd: process.cwd(),
792
- });
948
+ finally {
949
+ try {
950
+ this.permissionController.applyPending();
951
+ }
952
+ finally {
953
+ this.runInProgress = false;
954
+ }
955
+ }
956
+ }
957
+ /**
958
+ * Resolve the run's active behavior profile. A profile bound to the
959
+ * persisted session kind wins (so e.g. a resumed pet session keeps the safe
960
+ * profile even when the host omits behaviorMode); otherwise the explicit
961
+ * behaviorMode names a registered profile directly. Explicit unknown modes
962
+ * and non-work session kinds without an owning profile fail closed: silently
963
+ * falling back to an unrestricted run would turn a missing extension into a
964
+ * permission-boundary bypass.
965
+ */
966
+ resolveBehaviorProfile(sessionKind, behaviorMode) {
967
+ const explicitProfile = behaviorMode !== undefined ? this.behaviorProfiles.get(behaviorMode) : undefined;
968
+ if (behaviorMode !== undefined && !explicitProfile) {
969
+ throw new Error(`unknown behavior profile: ${behaviorMode}`);
970
+ }
971
+ const sessionProfile = [...this.behaviorProfiles.values()].find((profile) => profile.activateForSessionKinds?.includes(sessionKind));
972
+ if (sessionKind !== "work" && !sessionProfile) {
973
+ throw new Error(`session kind has no registered behavior profile: ${sessionKind}`);
974
+ }
975
+ return sessionProfile ?? explicitProfile;
976
+ }
977
+ async runExclusive(task, options) {
978
+ // Freeze permission context once, before the first await. Per-turn protocol
979
+ // overrides live only for this run; persistent setPermissionMode/setPlanMode
980
+ // calls made while busy are staged separately and cannot mutate this pair.
981
+ const workspaceResolved = await resolveRunWorkspace({
982
+ options,
983
+ sessionManager: this.sessionManager,
984
+ resolveBehaviorProfile: (kind, mode) => this.resolveBehaviorProfile(kind, mode),
985
+ configPermissionMode: this.config.permissionMode,
986
+ configCwd: this.config.cwd,
987
+ settings: this.getSettingsManager(),
988
+ processCwd: process.cwd(),
989
+ });
990
+ if (!workspaceResolved.ok)
991
+ return workspaceResolved.result;
992
+ const { sessionKind, sessionWorkspaceProfile, profile, profileParams, runPermissionMode, runPlanMode, cwd, profileState: { workspaceProfile: runWorkspaceProfile, sessionProfileOverrides, profileMemoryDir, }, } = workspaceResolved.resolution;
993
+ /** Structured results the profile's run services report; keyed per profile contract. */
994
+ let profileReportedResults;
793
995
  // Wrap the caller's onStream so we can intercept `task_update`
794
996
  // events emitted by TodoWrite and keep an in-engine snapshot.
795
997
  // TaskGuard reads this snapshot at turn end to decide whether to
@@ -798,23 +1000,13 @@ export class Engine {
798
1000
  // store is the transcript, but TaskGuard runs in-loop and can't
799
1001
  // afford a transcript scan per turn.
800
1002
  let latestTodos = [];
801
- const userOnStream = options?.onStream;
802
- const wrappedOnStream = (event) => {
803
- if (event.type === "task_update") {
804
- latestTodos = event.tasks;
805
- }
806
- // Persist goal progress so replay/history shows how many rounds the
807
- // goal ran. Display-only — toMessages() ignores this type, so it never
808
- // re-enters the LLM context.
809
- if (event.type === "goal_progress") {
810
- session.transcript.append("goal_progress", {
811
- status: event.status,
812
- round: event.round,
813
- ...(event.gaps ? { gaps: event.gaps } : {}),
814
- });
815
- }
816
- userOnStream?.(event);
817
- };
1003
+ const wrappedOnStream = this.buildWrappedOnStream({
1004
+ userOnStream: options?.onStream,
1005
+ getSession: () => session,
1006
+ setLatestTodos: (todos) => {
1007
+ latestTodos = todos;
1008
+ },
1009
+ });
818
1010
  if (options)
819
1011
  options.onStream = wrappedOnStream;
820
1012
  const imageInput = await prepareRunImageInput({
@@ -841,173 +1033,20 @@ export class Engine {
841
1033
  usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
842
1034
  };
843
1035
  }
844
- // Build the per-Engine ToolContext that will be threaded through every
845
- // tool call. Replaces the old module-level singletons (setAskUserFn,
846
- // setArenaLLMConfig, setSubAgentConfig, setToolSearchRegistry).
847
- const subAgentSpawner = {
848
- parentStream: options?.onStream,
849
- describe: () => ({
850
- cwd,
851
- preset: this.preset.name,
852
- permissionMode: this.config.permissionMode ?? "acceptEdits",
853
- }),
854
- spawn: async (req) => {
855
- // Anchor this sub-agent in the PARENT transcript at spawn time — before
856
- // it runs, so it's recorded whether it later completes, is interrupted,
857
- // or still runs. Replay reads these anchors to rebuild sub-agent cards
858
- // from sessions/<agentId>/ (agentId === childSid); without it a
859
- // backgrounded sub-agent leaves no parent-transcript trace and vanishes
860
- // on reopen. Only on a fresh spawn (not a resume/continuation, which
861
- // already has its anchor). Guarded so a transcript hiccup never breaks
862
- // the spawn.
863
- if (!req.resumeSessionId) {
864
- try {
865
- session.transcript.appendSubagent(req.agentId, undefined, req.description);
866
- }
867
- catch {
868
- /* anchor is best-effort; never block the spawn */
869
- }
870
- }
871
- // No nested agents. Strip Agent / AgentStatus / AgentCancel from the
872
- // child's tool pool so the LLM can't spawn grandchildren — matches
873
- // Claude Code's ALL_AGENT_DISALLOWED_TOOLS approach. Without this
874
- // guard a runaway model could fork-bomb sub-agents (token cost +
875
- // background process explosion), and the sid / approval / dock
876
- // model assumes a flat parent→children hierarchy. Layered with a
877
- // runtime check in agent.ts as defense-in-depth.
878
- const { enabled: childEnabled, disabled: childDisabled } = resolveChildToolScope(req.toolAllowlist, this.config.disabledBuiltinTools, this.config.enabledBuiltinTools);
879
- const childLlm = resolveChildLlm(req.model, this.modelPool, this.config.llm);
880
- const child = new Engine({
881
- llm: childLlm,
882
- // Inherit parent's runtime knobs (temperature, image detail, timeouts)
883
- // but cap sub-agent retries at 2 — they're short-lived and we'd
884
- // rather surface failures than burn a 9 s exponential backoff loop.
885
- clientDefaults: { ...(this.config.clientDefaults ?? {}), retryMaxAttempts: 2 },
886
- cwd,
887
- permissionMode: this.config.permissionMode,
888
- preset: this.preset.name,
889
- enabledBuiltinTools: childEnabled,
890
- disabledBuiltinTools: childDisabled,
891
- builtinToolHost: this.config.builtinToolHost,
892
- customSystemPrompt: this.config.customSystemPrompt,
893
- appendSystemPrompt: [this.config.appendSystemPrompt, req.appendSystemPrompt].filter(Boolean).join("\n\n") ||
894
- undefined,
895
- responseLanguage: this.config.responseLanguage,
896
- userProfile: this.config.userProfile,
897
- instructions: this.config.instructions,
898
- maxTurns: req.maxTurns,
899
- maxContextTokens: this.config.maxContextTokens ?? 200_000,
900
- sessionStorageDir: this.config.sessionStorageDir,
901
- headless: this.config.headless,
902
- readOnlySession: req.readOnlySession,
903
- skillAllowlist: req.skillAllowlist,
904
- sandbox: this.config.sandbox,
905
- // Subagents inherit the parent's scope: a child runs in the same
906
- // cwd/session, so it should see the same config layers the parent did.
907
- settingsScope: this.config.settingsScope ?? "project",
908
- isSubAgent: true,
909
- });
910
- // Where the spawned child Engine's stream events go. AgentTool's
911
- // background path passes a `streamOverride` (transcriptSink) so the
912
- // per-event detail is captured into the agent's transcript instead
913
- // of flooding the main feed. Sync calls leave streamOverride unset
914
- // and we fall back to the parent UI's onStream so synchronous
915
- // sub-agents still render inline.
916
- const destStream = req.streamOverride ?? options?.onStream;
917
- const childStream = destStream
918
- ? (event) => {
919
- // Filter ctx-bar signals: the bar tracks the main conversation's
920
- // prompt size, and a sub-agent's own session emits would clobber
921
- // it (its sid is new, its messages are tiny, the rough char/4
922
- // seed lands the bar at <1% mid-turn). Sub-agent token accounting
923
- // lives in CostTracker (recordUsage), not the ctx bar.
924
- //
925
- // - session_started: would seed main ctx with sub-agent's prompt
926
- // - usage_update: would overwrite main ctx with sub-agent's prompt
927
- // - context_compact: would reset main ctx to sub-agent's post-compact
928
- // value AND print a misleading "context compacted" boundary in
929
- // the main chat (the main session didn't compact).
930
- if (event.type === "usage_update" ||
931
- event.type === "session_started" ||
932
- event.type === "context_compact") {
933
- return;
934
- }
935
- destStream({ ...event, agentId: req.agentId });
936
- }
937
- : undefined;
938
- // child.run() establishes its own runWithSid scope internally, so
939
- // child log lines route to the child's sid and parent's ALS
940
- // binding is unaffected when control returns here.
941
- //
942
- // agent_id === childSid: cold-start the child UNDER its agentId as the
943
- // session id (run() shape (2): a fresh sid the host wants materialized),
944
- // so the session persists at sessions/<agentId>/ and AgentSendInput can
945
- // later resume it by agentId with no extra id→sid mapping. When
946
- // resumeSessionId is set we resume that existing session instead —
947
- // run() detects the on-disk session and replays its full transcript
948
- // (the CC continuation model; see AgentSendInput).
949
- const childSessionId = req.resumeSessionId ?? req.agentId;
950
- const result = await child.run(req.prompt, {
951
- signal: req.signal,
952
- onStream: childStream,
953
- sessionId: childSessionId,
954
- });
955
- return { text: result.text, sessionId: result.sessionId };
956
- },
957
- sessionExists: (sessionId) => this.sessionManager.exists(sessionId),
958
- };
959
- const sandboxConfig = this.resolveSandboxConfigForCwd(cwd);
960
- // A2: explicit sandbox modes (seatbelt, bwrap) must fail closed
961
- // per standard §S4. resolveSandboxBackend throws when an explicit
962
- // mode is unavailable on this host; we let it propagate. The
963
- // previous behavior — catching the throw inside the hot turn and
964
- // silently downgrading to "off" — was the leak A2 closes. The
965
- // `auto` mode handles its own downgrade with a one-time warning
966
- // inside resolveSandboxBackend; explicit modes do not.
967
- //
968
- // Backend is cached per runtime/engine so the capability probe runs once
969
- // per (mode, cwd) instead of every turn.
970
- const sandboxBackend = this.runtime
971
- ? await this.runtime.resolveSandbox(sandboxConfig, cwd)
972
- : await this.resolveSandboxWithoutRuntime(sandboxConfig, cwd);
973
- // Observability: surface what sandbox actually applied this run — the
974
- // configured mode vs the resolved backend (auto may downgrade to off when
975
- // no OS backend is available) + the network policy. Without this you can't
976
- // tell whether shell commands were isolated /网络放没放. One line per run.
977
- logger.info("sandbox.resolved", {
978
- mode: sandboxConfig.mode,
979
- backend: sandboxBackend.name,
980
- isolated: sandboxBackend.name !== "off",
981
- network: sandboxConfig.network,
982
- cwd,
983
- });
984
- // sessionId is filled in after the session bundle is resolved below
985
- // (the session may be cold-started or resumed). Until then this is
986
- // intentionally shaped as a mutable local; we treat it as immutable
987
- // after the assignment.
988
- const toolCtx = {
989
- ...this.buildToolContext(),
990
- subAgentSpawner,
991
- agentDefinitions: this.getAgentDefinitions(cwd),
992
- // Stamp the resolved network policy onto the backend the tools see so
993
- // Bash can surface "网络 deny" on its result. Shallow-copy (don't mutate
994
- // the cached backend) — `wrap`/`hintForBlockedOutput` are plain function
995
- // properties and survive the spread. Off keeps network undefined.
996
- sandbox: sandboxBackend.name === "off"
997
- ? sandboxBackend
998
- : { ...sandboxBackend, network: sandboxConfig.network },
1036
+ const toolCtx = await this.wireRunSandboxToolContext({
1037
+ options,
999
1038
  cwd,
1000
- shellEnv: this.readShellEnv(cwd),
1001
- // TodoWrite reads this to push task_update events independently
1002
- // of its return value, so the UI's pinned task panel refreshes
1003
- // immediately rather than after the LLM next surfaces the
1004
- // snapshot. wrappedOnStream snoops the same channel to keep
1005
- // latestTodos current for TaskGuard.
1006
- streamCallback: options?.onStream,
1007
- setCwd(nextCwd) {
1008
- toolCtx.cwd = nextCwd;
1039
+ runPermissionMode,
1040
+ runPlanMode,
1041
+ profile,
1042
+ profileParams,
1043
+ sessionProfileOverrides,
1044
+ profileMemoryDir,
1045
+ getSession: () => session,
1046
+ reportResult: (key, value) => {
1047
+ (profileReportedResults ??= {})[key] = value;
1009
1048
  },
1010
- };
1049
+ });
1011
1050
  logger.info("engine.run", {
1012
1051
  task: taskText.slice(0, 200),
1013
1052
  cwd,
@@ -1029,459 +1068,83 @@ export class Engine {
1029
1068
  // surfacing as `[-32603] Session not found: <sid>` on the very first
1030
1069
  // TUI turn. Detection now uses `sessionManager.exists()` (one stat
1031
1070
  // call) instead of a try/catch on resume.
1071
+ // wrappedOnStream (defined before the session opens, executed only after)
1072
+ // closes over `session`, so keep the declaration here and assign from the
1073
+ // opener's result.
1032
1074
  let session;
1033
- let messages;
1034
- let freshImageMessage;
1035
- let resumedFromDisk = false;
1036
- const claimedClientMessageIds = new Set();
1037
- const claimClientMessageId = (bundle, clientMessageId, source) => {
1038
- if (!clientMessageId)
1039
- return true;
1040
- if (claimedClientMessageIds.has(clientMessageId) ||
1041
- bundle.transcript.hasClientMessageId(clientMessageId)) {
1042
- logger.info("engine.client_message.duplicate_ignored", {
1043
- sessionId: bundle.state.sessionId,
1044
- clientMessageId,
1045
- source,
1046
- });
1047
- return false;
1048
- }
1049
- claimedClientMessageIds.add(clientMessageId);
1050
- return true;
1051
- };
1052
- if (options?.sessionId && this.sessionManager.exists(options.sessionId)) {
1053
- resumedFromDisk = true;
1054
- session = this.sessionManager.resume(options.sessionId);
1055
- const cachedCompacted = this.compactedMessagesBySession.get(options.sessionId);
1056
- messages = cachedCompacted ? [...cachedCompacted] : session.transcript.toMessages();
1057
- // If the previous run was Ctrl+C'd or crashed between an assistant
1058
- // tool_use and the matching tool_result being persisted, the
1059
- // loaded sequence is invalid for OpenAI (which 400s on dangling
1060
- // tool_calls). Patch synthetic tool_results so the next API call
1061
- // doesn't fail before the turn even starts.
1062
- const patched = patchOrphanedToolUses(messages);
1063
- if (patched.gapsPatched > 0) {
1064
- logger.warn("engine.resume.patched_orphaned_tool_uses", {
1065
- sessionId: options.sessionId,
1066
- gaps: patched.gapsPatched,
1067
- toolResults: patched.toolResultsInjected,
1068
- });
1069
- }
1070
- // Restore cost state from previous session, if the caller injected a store
1071
- if (session.state.costState && this.config.costStore) {
1072
- this.config.costStore.restore(session.state.costState);
1073
- }
1074
- // Append new user message
1075
- const userMsg = { role: "user", content: userMessageContent };
1076
- if (!claimClientMessageId(session, options?.clientMessageId, "submit")) {
1077
- const usage = session.state.tokenUsage ?? {
1078
- promptTokens: 0,
1079
- completionTokens: 0,
1080
- totalTokens: 0,
1081
- };
1082
- return {
1083
- text: "",
1084
- reason: "completed",
1085
- sessionId: session.state.sessionId,
1086
- turnCount: session.state.turnCount ?? 0,
1087
- usage: {
1088
- promptTokens: usage.promptTokens ?? 0,
1089
- completionTokens: usage.completionTokens ?? 0,
1090
- totalTokens: usage.totalTokens ?? 0,
1091
- },
1092
- };
1093
- }
1094
- if (parsedTask.hasImages)
1095
- freshImageMessage = userMsg;
1096
- messages.push(userMsg);
1097
- session.transcript.appendMessage("user", userMessageContent, {
1098
- injected: options?.injected === true,
1099
- clientMessageId: options?.clientMessageId,
1100
- });
1101
- // Flush "active" status to disk immediately. resume() set it in memory
1102
- // (session-manager.ts), but without this write the on-disk state.json
1103
- // still shows the previous run's terminal reason — so any external
1104
- // observer (another CLI process, /sid, the session list) would think
1105
- // the session is still errored/aborted while we're actually running.
1106
- this.sessionManager.saveState(session.state);
1107
- }
1108
- else {
1109
- // Cold start: shape (2) reuses the host-supplied sid; shape (3)
1110
- // lets sessionManager generate one with nanoid.
1111
- session = this.sessionManager.create(cwd, this.config.llm.model, this.config.llm.provider, options?.sessionId, this.config.isSubAgent === true ? getCurrentSid() : undefined, this.config.isSubAgent === true ? "subagent" : this.config.origin);
1112
- const userMsg = { role: "user", content: userMessageContent };
1113
- claimClientMessageId(session, options?.clientMessageId, "submit");
1114
- if (parsedTask.hasImages)
1115
- freshImageMessage = userMsg;
1116
- messages = [userMsg];
1117
- session.transcript.appendMessage("user", userMessageContent, {
1118
- clientMessageId: options?.clientMessageId,
1119
- });
1120
- // Save first user message as session summary — text only. The summary
1121
- // shows up in the session list; "[image]" is more informative than a
1122
- // truncated `[object Object]` when the prompt was purely visual.
1123
- const summarySrc = parsedTask.hasImages
1124
- ? parsedTask.text ||
1125
- `[image${parsedTask.images.length > 1 ? `s × ${parsedTask.images.length}` : ""}]`
1126
- : taskText;
1127
- session.state.summary = summarySrc.slice(0, 80).replace(/\n/g, " ");
1128
- this.sessionManager.saveState(session.state);
1129
- }
1130
- // Bump the conversation-turn counter: this user message starts a new turn.
1131
- // One user message = one turn, regardless of how many turn-loop iterations
1132
- // or tool calls it spans. File-history snapshots taken below are tagged
1133
- // with this value so `/undo` reverts exactly this turn's file changes.
1134
- // (Both resume and cold-start paths converge here.)
1135
- session.state.turnSeq = (session.state.turnSeq ?? 0) + 1;
1136
- // B2 / Gate 1: stamp the resolved sid onto the tool context so
1137
- // session-scoped side effects (background-agent completion
1138
- // notifications) attribute to the right session. toolCtx is created
1139
- // before the session bundle is resolved (see ~line 635), so this is
1140
- // the first point we can set it. After this assignment treat the
1141
- // field follows the latest successfully injected user intent for the rest
1142
- // of the run, so tools launched after a steer attribute their side effects
1143
- // to that steer rather than this original submit.
1144
- toolCtx.sessionId = session.state.sessionId;
1145
- toolCtx.originClientMessageId = options?.clientMessageId;
1146
- toolCtx.recordExternalFileChanges = (record) => {
1147
- session.transcript.append("external_file_changes", { ...record });
1148
- };
1149
- toolCtx.setSessionWorkspace = (workspace) => {
1150
- session.state.workspace = workspace;
1151
- };
1152
- return runWithSid(session.state.sessionId, async () => {
1153
- recordSessionStart(session.state.sessionId, {
1154
- // Strip <codeshell-image> base64 payloads before they reach
1155
- // <repo>/log/. Reader still sees the marker + byte count, just
1156
- // not the bytes. Transcript persistence keeps the full payload.
1157
- task: sanitizeTaskString(task),
1158
- cwd,
1159
- model: this.config.llm.model,
1160
- provider: this.config.llm.provider,
1161
- permissionMode: this.config.permissionMode ?? "acceptEdits",
1162
- resumed: resumedFromDisk,
1163
- });
1164
- // Session-level hook: fired once per Engine.run() entry, regardless of
1165
- // cold-start vs resume. Handlers can return `messages` to inject a
1166
- // <system-reminder> at the head of the conversation (between
1167
- // userContext and the new user prompt). Used by the built-in
1168
- // superpowers injector to surface the `using-superpowers` ruleset.
1169
- const sessionStartHook = await this.emitHook("on_session_start", {
1170
- sessionId: session.state.sessionId,
1075
+ const openedResult = openRunSession({
1076
+ sessionManager: this.sessionManager,
1077
+ options,
1078
+ parsedTask,
1079
+ taskText,
1080
+ userMessageContent,
1081
+ cwd,
1082
+ sessionKind,
1083
+ sessionWorkspaceProfile,
1084
+ ...(options?.sessionBrief ? { sessionBrief: options.sessionBrief } : {}),
1085
+ llmModel: this.config.llm.model,
1086
+ llmProvider: this.config.llm.provider,
1087
+ isSubAgent: this.config.isSubAgent === true,
1088
+ origin: this.config.origin,
1089
+ costStore: this.config.costStore,
1090
+ onAgentDirectionsDelivered: (ids) => this.agentDirectionsDeliveredListener?.(ids),
1091
+ cachedCompactedMessages: options?.sessionId
1092
+ ? this.compactedMessagesBySession.get(options.sessionId)
1093
+ : undefined,
1094
+ });
1095
+ if (!openedResult.ok)
1096
+ return openedResult.result;
1097
+ const { messages, freshImageMessage, resumedFromDisk, claimClientMessageId, releaseClientMessageId, } = openedResult.opened;
1098
+ session = openedResult.opened.session;
1099
+ this.stampRunToolContext(toolCtx, session, options);
1100
+ const sessionRun = runWithSid(session.state.sessionId, async () => {
1101
+ const hookMessages = await this.runSessionStartHooks({
1102
+ session,
1103
+ task,
1171
1104
  cwd,
1172
- resumed: resumedFromDisk,
1173
- source: resumedFromDisk ? "resume" : "startup",
1174
- });
1175
- // Per-turn hook: fired every time a new user prompt enters the loop.
1176
- // Equivalent to CC's UserPromptSubmit. Handlers can inject lightweight
1177
- // reminders that should accompany each user turn (e.g. "skills
1178
- // available — check before acting").
1179
- const promptSubmitHook = await this.emitHook("user_prompt_submit", {
1180
- sessionId: session.state.sessionId,
1181
- // Pass the text-only portion. Handlers reading the prompt for keyword
1182
- // detection / classification (e.g. superpowers' "did the user ask
1183
- // about X?") don't gain anything from megabytes of base64 inlined here,
1184
- // and silently leaking attachment bytes through hooks is the kind of
1185
- // exfiltration risk a curious user-installed shell hook shouldn't carry.
1186
- prompt: taskText,
1187
- resumed: resumedFromDisk,
1188
- });
1189
- // updatedPrompt: handler rewrote the user's prompt text. Replace the
1190
- // last user message we just pushed (cold-start: line ~511; resume:
1191
- // line ~500). Original prompt is in the transcript already — we log
1192
- // the rewrite so audit chains know a hook touched user input.
1193
- if (typeof promptSubmitHook.updatedPrompt === "string") {
1194
- const lastIdx = messages.length - 1;
1195
- const last = messages[lastIdx];
1196
- if (last && last.role === "user" && typeof last.content === "string") {
1197
- logger.info("hook.updated_prompt", {
1198
- sessionId: session.state.sessionId,
1199
- originalChars: last.content.length,
1200
- updatedChars: promptSubmitHook.updatedPrompt.length,
1201
- });
1202
- messages[lastIdx] = { role: "user", content: promptSubmitHook.updatedPrompt };
1203
- }
1204
- }
1205
- const contextManager = new ContextManager({
1206
- maxTokens: this.resolveMaxContextTokens(),
1207
- // Drop undefined fields so they don't clobber ContextManager defaults
1208
- // (spread of `{x: undefined}` would override the default with undefined).
1209
- ...Object.fromEntries(Object.entries(this.resolveContextRatios()).filter(([, v]) => v !== undefined)),
1105
+ runPermissionMode,
1106
+ resumedFromDisk,
1107
+ options,
1108
+ taskText,
1109
+ messages,
1210
1110
  });
1211
- this.lastContextManager = contextManager;
1212
- const persistedContextAnchor = session.state.contextUsageAnchor;
1213
- const contextAnchorCompatible = persistedContextAnchor !== undefined &&
1214
- (persistedContextAnchor.provider === undefined ||
1215
- persistedContextAnchor.provider === this.config.llm.provider) &&
1216
- (persistedContextAnchor.model === undefined ||
1217
- persistedContextAnchor.model === this.config.llm.model) &&
1218
- (persistedContextAnchor.messageCount <= messages.length ||
1219
- persistedContextAnchor.estimateAtAnchor !== undefined);
1220
- if (contextAnchorCompatible) {
1221
- contextManager.seedActualUsage(persistedContextAnchor);
1222
- }
1223
- // Best-effort token estimate of the full prompt so the UI's ctx bar isn't
1224
- // 0% before the first real usage_update arrives. The authoritative count
1225
- // comes from `usage.promptTokens` after the first LLM response — this is
1226
- // just a display-friendly approximation for the first frame, annotated
1227
- // with source/confidence so consumers don't treat heuristics as truth.
1228
- //
1229
- // Only seed once per (process, sid). On subsequent turns the UI already
1230
- // shows the previous turn's accurate ctx; overwriting it with a fresh
1231
- // best-effort estimate would make the bar visibly drop on every submit.
1232
1111
  const sid = session.state.sessionId;
1233
- const needsCtxSeed = !this.ctxSeedSent.has(sid);
1234
- const ctxSeed = needsCtxSeed
1235
- ? (() => {
1236
- const checked = contextManager.checkLimits(messages);
1237
- return {
1238
- tokens: checked.tokens,
1239
- source: checked.promptTokensSource,
1240
- confidence: checked.promptTokensConfidence,
1241
- };
1242
- })()
1243
- : {
1244
- tokens: 0,
1245
- source: "heuristic_estimate",
1246
- confidence: "low",
1247
- };
1248
- if (needsCtxSeed)
1249
- this.ctxSeedSent.add(sid);
1250
- // Tell the client the sid *now* instead of waiting for run() to resolve.
1251
- // The user wants `/sid` to work mid-turn; without this, the client only
1252
- // learns the sid when the run completes.
1253
- options?.onStream?.({
1254
- type: "session_started",
1255
- sessionId: sid,
1256
- promptTokens: ctxSeed.tokens,
1257
- promptTokensSource: ctxSeed.source,
1258
- promptTokensConfidence: ctxSeed.confidence,
1259
- });
1260
- // Replay the last TodoWrite snapshot on resume so the UI's pinned
1261
- // task panel re-hydrates without the LLM needing to call TodoWrite
1262
- // again. Scans the resumed transcript newest-first (and tolerates
1263
- // legacy TaskCreate/Update events for sessions recorded against
1264
- // the pre-2026-05-24 API). New sessions have no transcript yet so
1265
- // readLastTodoSnapshot returns null and nothing is emitted.
1266
- if (options?.sessionId) {
1267
- const snap = readLastTodoSnapshot(session.transcript.getEvents());
1268
- if (snap && snap.length > 0) {
1269
- latestTodos = snap;
1270
- options?.onStream?.({ type: "task_update", tasks: snap });
1271
- }
1272
- }
1273
- // Kick off LLM client creation early (network handshake)
1274
- const llmClientPromise = createLLMClient(this.config.llm, this.config.clientDefaults);
1275
- const mode = this.config.permissionMode ?? "acceptEdits";
1276
- const { rules: defaultRules, backend: approvalBackend } = this.buildPermissionConfig(mode, cwd);
1277
- const permission = new PermissionClassifier(defaultRules, mode, approvalBackend);
1278
- this.activePermission = permission;
1279
- // If the backend is the interactive one, wire it for project-scope
1280
- // persistence: it needs cwd to find settings.local.json, and a callback
1281
- // to apply newly-saved rules to the live classifier so subsequent calls
1282
- // in this same session don't re-prompt. Headless/auto backends skip
1283
- // this — they don't prompt, so there are no project rules to persist.
1284
- if (approvalBackend instanceof InteractiveApprovalBackend) {
1285
- approvalBackend.setSessionContext(session.state.sessionId, {
1286
- cwd,
1287
- onProjectRules: (rules) => {
1288
- // Prepend the *full* accumulated list of session-saved project rules
1289
- // so user approvals win over defaults and earlier approvals aren't
1290
- // dropped when later ones come in.
1291
- permission.reconfigure(mode, approvalBackend, [...rules, ...defaultRules]);
1292
- },
1293
- });
1294
- }
1295
- const toolExecutor = new ToolExecutor(this.toolRegistry, permission, this.hooks);
1296
- const investigationGuard = new InvestigationGuard();
1297
- if (this.config.readOnlySession) {
1298
- investigationGuard.setPolicy("read-only-review");
1299
- }
1300
- else if (this.config.headless) {
1301
- investigationGuard.setSoftMode(true);
1302
- }
1303
- toolExecutor.setInvestigationGuard(investigationGuard);
1304
- toolExecutor.setTaskGuard(new TaskGuard(() => latestTodos));
1305
- // Wire abort signal for cascading cancellation + per-Engine ToolContext
1306
- toolExecutor.setSignal(options?.signal);
1307
- toolExecutor.setContext(toolCtx);
1308
- const { disabledSkills, disabledPlugins } = this.readDisabledLists();
1309
- const promptComposer = new PromptComposer({
1112
+ const { contextManager, llmClientPromise, toolExecutor } = this.wireRunContextAndPermission({
1113
+ session,
1114
+ sid,
1115
+ options,
1310
1116
  cwd,
1311
- model: this.config.llm.model,
1312
- preset: this.preset,
1313
- customSystemPrompt: this.config.customSystemPrompt,
1314
- appendSystemPrompt: this.config.appendSystemPrompt,
1315
- responseLanguage: this.config.responseLanguage,
1316
- userProfile: this.config.userProfile,
1317
- instructionOptions: { compatFileNames: compatFileNamesFrom(this.config.instructions) },
1318
- disabledSkills,
1319
- disabledPlugins,
1320
- skillAllowlist: this.config.skillAllowlist,
1321
- memoriesMaxAgeDays: this.readMemoriesConfig()?.maxAge,
1322
- goalToolState: {
1323
- hasGoal: this.config.isSubAgent !== true &&
1324
- (normalizeGoal(options?.goal) !== undefined ||
1325
- session.state.activeGoal !== undefined ||
1326
- normalizeGoal(this.config.goal) !== undefined),
1117
+ toolCtx,
1118
+ runPermissionMode,
1119
+ messages,
1120
+ getLatestTodos: () => latestTodos,
1121
+ setLatestTodos: (todos) => {
1122
+ latestTodos = todos;
1327
1123
  },
1328
1124
  });
1329
- // Connect MCP servers (if configured and not already connected).
1330
- // B1: prefer the Runtime-owned MCPManager so all sessions in a
1331
- // worker share one set of connections. Falling back to a
1332
- // per-Engine instance keeps the null-runtime path (tests, ad-hoc
1333
- // scripts) working.
1334
- const mcpServers = this.config.mcpServers ?? {};
1335
- if (Object.keys(mcpServers).length > 0 && !this.mcpManager) {
1336
- if (this.runtime) {
1337
- this.mcpManager = this.runtime.mcpPool;
1338
- }
1339
- else {
1340
- this.mcpManager = new MCPManager(this.toolRegistry);
1341
- }
1342
- await this.mcpManager.connectAll(mcpServers, this);
1343
- }
1344
- // Parallelize slow initialization:
1345
- // 1. createLLMClient — network handshake (started earlier)
1346
- // 2. buildSystemPrompt — includes git status (3 execSync calls)
1347
- // 3. buildSystemContext — reads environment context
1348
- // Inject the live available-agent-types listing into the Agent tool's
1349
- // description. The registry is per-engine (loaded from .code-shell/agents
1350
- // for this cwd), so it can't live in the static tool def — without this
1351
- // the model never learns the reusable roles exist and spawns nameless
1352
- // ad-hoc agents instead (the Core A/B/C incident).
1353
- // The Agent tool is always available: with configured roles, an omitted
1354
- // agent_type falls back to one of them (see resolveAgentTypeOverrides); with
1355
- // no roles configured it runs a true ephemeral agent, so workflows that need
1356
- // sub-agents (e.g. superpowers) work in any project.
1357
- // Availability guard (tool-visibility): a gated builtin (WebSearch needs a
1358
- // search provider, GenerateImage needs an OpenAI provider) is hidden from
1359
- // the toolDefs the model sees when its credential isn't configured for this
1360
- // cwd. Recomputed every message, so configuring a key takes effect on the
1361
- // NEXT message without a restart. Tools with no guard entry are always kept.
1362
- const guardCwd = toolCtx.cwd;
1363
- const toolVisibility = {
1364
- cwd: guardCwd,
1365
- hasGoal: this.config.isSubAgent !== true &&
1366
- (normalizeGoal(options?.goal) !== undefined ||
1367
- session.state.activeGoal !== undefined ||
1368
- normalizeGoal(this.config.goal) !== undefined),
1369
- settingsScope: this.config.settingsScope ?? "project",
1370
- };
1371
- toolCtx.toolVisibility = toolVisibility;
1372
- // #7: per-turn project builtin override. The toolRegistry's builtin tool
1373
- // SET is ctor-frozen (and may be shared via runtime), so a mid-session
1374
- // project override of a builtin can't rebuild the registry. But the tool
1375
- // LIST handed to the LLM is assembled fresh every turn, so we apply the
1376
- // override here: a builtin marked `off` for this cwd is HIDDEN from the
1377
- // turn's tool list (matching how skills/plugins/agents `off` apply
1378
- // mid-session via readDisabledLists). `on`/`inherit` keep whatever the
1379
- // registry already has — we can't re-add a tool the frozen registry omits,
1380
- // but `on` for a tool already present is a no-op (it stays). This makes a
1381
- // builtin toggle take effect on the NEXT message, like other capability
1382
- // kinds, without touching the registry.
1383
- const builtinOverride = this.readBuiltinOverride(guardCwd);
1384
- // Turn `off` from a prompt-visibility filter into a real execution gate:
1385
- // collect the builtin tool names the override marks `off` and hand them to
1386
- // the executor (via the shared toolCtx the executor already holds a
1387
- // reference to, set at setContext above) so it rejects a call to a hidden
1388
- // builtin instead of running it from the still-populated registry.
1389
- if (builtinOverride) {
1390
- const registryNames = new Set(this.toolRegistry.getToolDefinitions().map((t) => t.name));
1391
- const disabledBuiltins = new Set(Object.keys(builtinOverride).filter((name) => builtinOverride[name] === "off" && registryNames.has(name)));
1392
- toolCtx.disabledBuiltins = disabledBuiltins;
1393
- }
1394
- // MCP tool exposure is per-SESSION even though the pool/registry are
1395
- // worker-shared (B1): a server connected by another project's session
1396
- // registers its tools into the SHARED registry, and without this filter
1397
- // they leaked into every session (e.g. chrome-devtools tools showing up
1398
- // in a project that never enabled the plugin). Keep an MCP tool only when
1399
- // its server is in THIS session's merged config.mcpServers — which
1400
- // already folds the project's capabilityOverrides. Gated on the config
1401
- // being present: engines without one (sub-agents, bare tests) have no
1402
- // MCP tools in their private registries anyway.
1403
- const allowedMcpServers = new Set(Object.entries(this.config.mcpServers ?? {})
1404
- .filter(([, c]) => c.enabled !== false)
1405
- .map(([n]) => n));
1406
- toolCtx.allowedMcpServers = allowedMcpServers;
1407
- const mcpVisible = (toolName) => {
1408
- const reg = this.toolRegistry.getTool(toolName);
1409
- return reg?.source !== "mcp" || allowedMcpServers.has(reg?.serverName ?? "");
1410
- };
1411
- // Feature-flag visibility: a builtin mapped in TOOL_FEATURE_FLAGS is
1412
- // hidden when its flag resolves to false (default-on flags only hide when
1413
- // explicitly disabled, so zero regression out of the box). Read once per
1414
- // turn so flipping a flag in settings takes effect on the NEXT message,
1415
- // like the other capability kinds.
1416
- const featureFlags = this.readFeatureFlags();
1417
- const allToolDefs = applyBuiltinOverrideVisibility(this.toolRegistry.getToolDefinitions(), builtinOverride)
1418
- .filter((t) => mcpVisible(t.name))
1419
- .filter((t) => {
1420
- const guard = BUILTIN_TOOL_GUARDS.get(t.name);
1421
- return guard ? guard(toolVisibility) : true;
1422
- })
1423
- .filter((t) => {
1424
- const flag = TOOL_FEATURE_FLAGS.get(t.name);
1425
- return flag ? isFeatureEnabled(featureFlags, flag) : true;
1426
- })
1427
- // Dynamic per-engine bits the static defs can't carry: the Agent tool's
1428
- // agent_type enum + listing, and the image/video provider names. See
1429
- // applyDynamicToolDef — forwarding only the Agent description (dropping
1430
- // its rebuilt inputSchema) used to strip the agent_type enum, so the
1431
- // model omitted agent_type and configured roles never applied.
1432
- .map((t) => applyDynamicToolDef(t, toolCtx.agentDefinitions, guardCwd));
1433
- // In plan mode, only expose read-only/planning tools so the model won't
1434
- // attempt writes. Shared with executor.ts's execution gate via
1435
- // PLAN_MODE_ALLOWED_TOOLS so what the model SEES and what the executor
1436
- // RUNS can't drift apart. (Bash is in the set; the executor additionally
1437
- // gates Bash to read-only commands at call time.)
1438
- const toolDefs = this.planMode
1439
- ? allToolDefs.filter((t) => PLAN_MODE_ALLOWED_TOOLS.has(t.name))
1440
- : allToolDefs;
1441
- const [llmClient, fullSystemPrompt, dynamicContextMsg] = await Promise.all([
1125
+ const { promptComposer, toolDefs } = await this.wireRunTooling({
1126
+ options,
1127
+ session,
1128
+ cwd,
1129
+ toolCtx,
1130
+ profile,
1131
+ profileParams,
1132
+ runWorkspaceProfile,
1133
+ profileMemoryDir,
1134
+ sessionProfileOverrides,
1135
+ runPlanMode,
1136
+ });
1137
+ const { llmClient, fullSystemPrompt, dynamicContextMsg, userContextMsg } = await this.assembleRunPrompts({
1138
+ session,
1139
+ messages,
1140
+ hookMessages,
1141
+ promptComposer,
1142
+ toolDefs,
1442
1143
  llmClientPromise,
1443
- // System prompt is now the STABLE prefix only — skills + git status moved
1444
- // out to a trailing per-turn message so they no longer bust the cache.
1445
- promptComposer.buildSystemPrompt(toolDefs),
1446
- promptComposer.buildDynamicContextMessage(),
1447
- ]);
1448
- // Prepend userContext (CLAUDE.md) as first message (sync, fast)
1449
- const userContextMsg = promptComposer.buildUserContextMessage();
1450
- if (userContextMsg) {
1451
- messages.unshift(userContextMsg);
1452
- }
1453
- // Inject hook-supplied reminders just before the most recent user task.
1454
- // Combined into one <system-reminder> block so a noisy handler chain
1455
- // doesn't turn into 3+ separate user turns in the API request.
1456
- const lifecycleReminder = wrapHookMessages([
1457
- ...(sessionStartHook.messages ?? []),
1458
- ...(promptSubmitHook.messages ?? []),
1459
- ]);
1460
- if (lifecycleReminder) {
1461
- // messages[length - 1] is the user task we just pushed above. Insert
1462
- // the reminder immediately before it so the model reads: CLAUDE.md →
1463
- // reminder → user request.
1464
- messages.splice(messages.length - 1, 0, lifecycleReminder);
1465
- }
1466
- // Volatile context (skills + git status) goes at the very END — after the
1467
- // user task — so it sits past the conversation's cache breakpoint. A change
1468
- // here (new skill, edited file) never invalidates the cached history prefix.
1469
- if (dynamicContextMsg) {
1470
- messages.push(dynamicContextMsg);
1471
- }
1472
- this.lastSessionId = session.state.sessionId;
1473
- this.lastMessages = messages;
1474
- // Wire up LLM summarization for context compaction
1475
- // Uses a lightweight call without tools
1476
- contextManager.setTranscriptPath(session.transcript.getFilePath());
1477
- // Re-derive frozen persistence decisions from the messages we just
1478
- // loaded. Skipped on cold start (messages == [userContextMsg] only).
1479
- // Critical for resume — otherwise a result that was persisted last
1480
- // run would be evaluated fresh and might get a different replacement
1481
- // string than the one already in the message, breaking idempotency.
1482
- contextManager.initReplacementStateFromMessages(messages);
1483
- // Two summarizers with DIFFERENT quality needs:
1484
- //
1144
+ contextManager,
1145
+ profile,
1146
+ profileParams,
1147
+ });
1485
1148
  // 1. Context-compaction summary (setSummarizeFn) → PRIMARY model. This
1486
1149
  // condenses many rounds into the running summary that REPLACES the real
1487
1150
  // history; a dropped decision makes the conversation "forget" and poisons
@@ -1494,719 +1157,1064 @@ export class Engine {
1494
1157
  // These are tiny throwaway outputs ("Wrote design doc") fired every turn;
1495
1158
  // that high-frequency, low-stakes chore is exactly what aux is for.
1496
1159
  const auxSummaryClient = await this.resolveAuxClient(llmClient);
1497
- Object.assign(session.state, normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage));
1498
- const recordCumulativeUsage = (usage) => {
1499
- const next = addCumulativeUsage(session.state, usage);
1500
- Object.assign(session.state, next);
1501
- return next;
1502
- };
1503
- contextManager.setSummarizeFn(this.buildSummarizeFn(llmClient, recordCumulativeUsage));
1504
- // Create components (requires resolved llmClient).
1505
- const modelFacade = new ModelFacade(llmClient, session.transcript);
1506
- // Session-cumulative usage baseline: the LLM client is recreated per run
1507
- // (its getUsage() counts only THIS run), so to accumulate across runs we
1508
- // capture the persisted total at run start and fold this run's usage onto
1509
- // it (see foldRunUsage). Snapshot now, before any turn boundary fires.
1510
- const usageBaseline = { ...session.state.tokenUsage };
1511
- // Wire getOutputTokens for token budget tracking
1512
- modelFacade.getOutputTokens = () => {
1513
- const usage = llmClient.getUsage();
1514
- return usage.totalCompletionTokens;
1515
- };
1516
- // Wire summarize for tool use summaries (uses lightweight call).
1517
- // recordUsage=false keeps these auxiliary sub-calls out of the main usage
1518
- // tracker so session_end.cost reflects only the user-facing turns and
1519
- // turns/requestCount stay aligned.
1520
- modelFacade.summarize = async (sysPrompt, userMsg) => {
1521
- const resp = await auxSummaryClient.createMessage({
1522
- systemPrompt: sysPrompt,
1523
- messages: [{ role: "user", content: userMsg }],
1524
- tools: [],
1525
- maxTokens: 256,
1526
- recordUsage: false,
1527
- // Auxiliary call — see contextManager.setSummarizeFn above.
1528
- reasoning: { mode: "off" },
1529
- });
1530
- logger.debug("summarize.call", {
1531
- sysPromptLen: sysPrompt.length,
1532
- userMsgLen: userMsg.length,
1533
- userMsgPreview: userMsg.slice(0, 300),
1534
- completionLen: resp.text.length,
1535
- completionPreview: resp.text.slice(0, 300),
1536
- stopReason: resp.stopReason,
1537
- promptTokens: resp.usage?.promptTokens,
1538
- completionTokens: resp.usage?.completionTokens,
1539
- });
1540
- return resp.text;
1541
- };
1542
- // File history: auto-backup before Write/Edit
1543
- const sessionDir = join(this.config.sessionStorageDir ?? join(userHome(), ".code-shell", "sessions"), session.state.sessionId);
1544
- const fileHistory = FileHistory.loadFromDir(sessionDir);
1545
- // Keep a reference so we can unregister in the finally below. Registering an
1546
- // anonymous handler every run() leaks: unregister matches by handler
1547
- // identity, so without a stored reference each run stacks another identical
1548
- // on_tool_start handler that fires (and re-snapshots) on every tool forever.
1549
- const fileHistoryHandler = async (context) => {
1550
- const toolName = context.data?.toolName;
1551
- const args = context.data?.args;
1552
- // Tag snapshots with the current turn (stamped above before any tool
1553
- // runs) so turn-level /undo can revert just this user message's edits.
1554
- const turnSeq = session.state.turnSeq;
1555
- if ((toolName === "Write" || toolName === "Edit") && args?.file_path) {
1556
- const path = args.file_path;
1557
- // saveSnapshot returns null when the file does not exist yet — this
1558
- // hook runs BEFORE the tool, so a null here means the turn is CREATING
1559
- // the file. Record it (idempotent per turn) so /undo can delete it and
1560
- // /redo can recreate it.
1561
- if (fileHistory.saveSnapshot(path, turnSeq) === null && turnSeq !== undefined) {
1562
- fileHistory.recordCreated(path, turnSeq);
1563
- }
1564
- }
1565
- else if (toolName === "ApplyPatch" && typeof args?.patch === "string") {
1566
- // ApplyPatch mutates files too, so /undo must see them. Snapshot every
1567
- // existing file the patch updates or deletes (adds have no prior
1568
- // content). Resolve relative patch paths against the engine cwd, the
1569
- // same base ApplyPatch itself uses.
1570
- const cwd = this.config.cwd ?? process.cwd();
1571
- for (const target of patchBackupTargets(args.patch, cwd)) {
1572
- fileHistory.saveSnapshot(target, turnSeq);
1573
- }
1574
- }
1575
- return {};
1576
- };
1577
- this.hooks.register("on_tool_start", fileHistoryHandler, 100, "file_history_backup");
1578
- // Hook: agent start
1579
- await this.emitHook("on_agent_start", {
1580
- sessionId: session.state.sessionId,
1160
+ // Auto-compaction runs inside TurnLoop.manageAsync(), after the loop has
1161
+ // initialized its run-scoped Goal tracker. The closure is wired before
1162
+ // construction but cannot execute until turnLoop.run() starts.
1163
+ // Assigned after the callbacks that close over it are constructed; they
1164
+ // cannot run until turnLoop.run(), so definite assignment is intentional.
1165
+ const { turnLoop, applyGoalTermination, goalHookHandler, fileHistoryHook, getRunUsage, recordExternalBilledUsage, accounting, usageBaseline, } = await this.wireRunLoop({
1166
+ session,
1167
+ sid,
1581
1168
  task,
1582
- model: this.config.llm.model,
1169
+ cwd,
1170
+ options,
1171
+ toolCtx,
1172
+ toolExecutor,
1173
+ contextManager,
1174
+ llmClient,
1175
+ auxSummaryClient,
1176
+ fullSystemPrompt,
1177
+ toolDefs,
1178
+ claimClientMessageId,
1179
+ releaseClientMessageId,
1180
+ freshImageMessage,
1181
+ dynamicContextMsg,
1583
1182
  });
1584
- // Goal mode: register a GoalStopHook for the lifetime of THIS run so the
1585
- // turn loop keeps going until the session model judges the goal met.
1586
- // Registered per-run (and cleared in `finally`) so a later goal-less
1587
- // send doesn't inherit a stale goal. The judge runs on the primary
1588
- // session client; auxSummaryClient remains dedicated to low-consequence
1589
- // summaries/titles and retains defaults.auxText routing/fallback behavior.
1590
- // Normalize the raw goal (string | GoalConfig) once at the run boundary;
1591
- // everything inward uses the GoalConfig. normalizeGoal() returns undefined
1592
- // when there's effectively no goal (empty objective).
1593
- //
1594
- // PERSISTENT GOAL (CC /goal style): a goal set on one send survives across
1595
- // later sends and manual interrupts until met or cleared. Goal completion
1596
- // is a high-consequence decision, so V1 routes it to the primary session
1597
- // client, which is the model expected to interpret the supplied execution
1598
- // evidence. defaults.auxText remains in force for summaries, titles and
1599
- // other auxiliary work through auxSummaryClient.
1600
- // Resolution:
1601
- // 1. options.goal — this send explicitly sets/replaces the goal.
1602
- // 2. session.state.activeGoal — a goal set on an earlier send.
1603
- // 3. config.goal — engine-level default (rare; e.g. headless).
1604
- // When (1) supplies a goal that differs from the stored one we REPLACE the
1605
- // persisted active goal (one active goal per session) and announce it. A
1606
- // bare send with no options.goal inherits the stored active goal so the
1607
- // model keeps working toward it — that's what makes it persistent.
1608
- const explicitGoal = normalizeGoal(options?.goal);
1609
- let storedGoal = this.config.isSubAgent !== true ? session.state.activeGoal : undefined;
1610
- // Defense in depth: a stale whole-state writer may have restored the
1611
- // activeGoal field after this exact goal instance was force-terminated.
1612
- // Refuse to arm it and converge the live bundle before hook registration.
1613
- if (storedGoal && isSameGoalInstance(storedGoal, session.state.goalTerminal)) {
1614
- session.state.activeGoal = undefined;
1615
- storedGoal = undefined;
1616
- this.sessionManager.saveState(session.state);
1183
+ let result;
1184
+ let firstGoalTermination;
1185
+ try {
1186
+ ({ result, firstGoalTermination } = await this.runTurnLoopWithHeadlessDrain({
1187
+ turnLoop,
1188
+ messages,
1189
+ applyGoalTermination,
1190
+ session,
1191
+ options,
1192
+ }));
1617
1193
  }
1618
- if (explicitGoal && this.config.isSubAgent !== true) {
1619
- const replaced = !!storedGoal && storedGoal.objective !== explicitGoal.objective;
1620
- // Stamp WHEN this goal was set so the judge can anchor relative deadlines
1621
- // ("做到3点") to the set time, not "now" — else once the clock passes the
1622
- // deadline the judge could read "3点" as tomorrow's and never stop. A new
1623
- // or changed objective gets a fresh stamp; re-sending the SAME objective
1624
- // keeps the original anchor (the goal continues, the user didn't restate a
1625
- // new deadline). User input never carries setAtMs, so we set it here.
1626
- const resolvedSetAt = resolveGoalSetAt(explicitGoal.objective, storedGoal, Date.now());
1627
- // A user explicitly re-starting the same objective creates a new goal
1628
- // instance. Avoid a same-millisecond collision with its old tombstone.
1629
- explicitGoal.setAtMs =
1630
- session.state.goalTerminal?.objective === explicitGoal.objective &&
1631
- session.state.goalTerminal.setAtMs === resolvedSetAt
1632
- ? resolvedSetAt + 1
1633
- : resolvedSetAt;
1634
- session.state.activeGoal = explicitGoal;
1635
- this.sessionManager.saveState(session.state);
1636
- options?.onStream?.({
1637
- type: "goal_set",
1638
- objective: explicitGoal.objective,
1639
- replaced,
1640
- });
1194
+ finally {
1195
+ // Run-scoped: drop the GoalStopHook so a later goal-less send on this
1196
+ // long-lived engine doesn't keep blocking stops.
1197
+ if (goalHookHandler)
1198
+ this.hooks.unregister("on_stop", goalHookHandler);
1199
+ if (this.activeGoalHook === goalHookHandler) {
1200
+ this.activeGoalHook = null;
1201
+ this.activeGoalHookAttached = false;
1202
+ this.activeRuntimeGoal = null;
1203
+ this.activePersistedRunGoal = null;
1204
+ }
1205
+ if (this.activeTurnLoop === turnLoop)
1206
+ this.activeTurnLoop = null;
1207
+ if (this.activeRunSession === session)
1208
+ this.activeRunSession = null;
1209
+ // Run-scoped too: this handler is re-registered every run(), so it must be
1210
+ // dropped here or it stacks duplicates that re-snapshot on every tool.
1211
+ fileHistoryHook.dispose();
1641
1212
  }
1642
- const normalizedGoal = explicitGoal ?? storedGoal ?? normalizeGoal(this.config.goal);
1643
- // Snapshot the persisted goal identity owned by THIS run. Terminal
1644
- // cleanup compares against this immutable copy so an old run cannot
1645
- // delete a replacement goal installed while it was finishing.
1646
- const persistedRunGoal = normalizedGoal && isSameGoalInstance(session.state.activeGoal, normalizedGoal)
1647
- ? { ...normalizedGoal }
1648
- : undefined;
1649
- let goalHookHandler = null;
1650
- let goalJudgeContext;
1651
- if (normalizedGoal && this.config.isSubAgent !== true) {
1652
- goalHookHandler = createGoalStopHook({
1653
- goal: normalizedGoal,
1654
- llm: llmClient,
1655
- log: logger,
1656
- getJudgeContext: () => goalJudgeContext,
1657
- // Clear the persisted active goal the moment the judge says it's met,
1658
- // so a later bare send doesn't re-inherit a satisfied goal. The hook
1659
- // calls this from inside its met branch (single source of truth for
1660
- // "goal achieved"); engine owns the persistence side-effect.
1661
- onMet: () => {
1662
- if (persistedRunGoal &&
1663
- isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
1664
- session.state.activeGoal = undefined;
1665
- this.sessionManager.saveState(session.state);
1666
- }
1667
- },
1668
- // Re-read the persisted goal each turn so a mid-run 清除 (clearGoal
1669
- // wrote state.json but this hook's frozen goal copy + the closure's
1670
- // in-RAM session are untouched) actually stops the judge. Reads disk
1671
- // via readActiveGoal — authoritative and independent of which session
1672
- // instance the run closure holds.
1673
- isGoalActive: (sid) => isSameGoalInstance(this.sessionManager.readActiveGoal(sid), normalizedGoal),
1674
- });
1675
- this.hooks.register("on_stop", goalHookHandler, 0, "goal-stop");
1676
- // Expose for clearGoal() mid-run. Already guarded by isSubAgent above.
1677
- this.activeGoalHook = goalHookHandler;
1213
+ const finalized = await this.finalizeRun({
1214
+ session,
1215
+ result,
1216
+ firstGoalTermination,
1217
+ turnCount: turnLoop.currentTurn,
1218
+ getRunUsage,
1219
+ usageBaseline,
1220
+ userContextMsg,
1221
+ dynamicContextMsg,
1222
+ options,
1223
+ cwd,
1224
+ llmClient,
1225
+ auxSummaryClient,
1226
+ recordExternalBilledUsage,
1227
+ accounting,
1228
+ profile,
1229
+ getProfileReportedResults: () => profileReportedResults,
1230
+ });
1231
+ if (options?.clientMessageId) {
1232
+ this.appendClientRunReceipt(session, options.clientMessageId, finalized);
1678
1233
  }
1679
- // Surface compaction events to the UI so the user knows when context was trimmed.
1680
- // Buffer the most recent event so TurnLoop can drain it and emit the
1681
- // post_compact hook on the next turn (ContextManager itself doesn't
1682
- // know about HookRegistry — the buffer is the seam).
1683
- let pendingCompactInfo = null;
1684
- contextManager.setOnCompact((info) => {
1685
- pendingCompactInfo = info;
1686
- options?.onStream?.({ type: "context_compact", ...info });
1234
+ return finalized;
1235
+ });
1236
+ return Promise.resolve(sessionRun).catch((err) => {
1237
+ const failed = buildRunFailureResult({
1238
+ err,
1239
+ session,
1240
+ options,
1241
+ persistFinalRunState: (state) => this.persistFinalRunState(state),
1687
1242
  });
1688
- // Run turn loop
1689
- const turnLoop = new TurnLoop({
1690
- model: modelFacade,
1691
- toolExecutor,
1692
- contextManager,
1693
- hooks: this.hooks,
1694
- transcript: session.transcript,
1695
- systemPrompt: fullSystemPrompt,
1696
- tools: toolDefs,
1697
- sessionId: sid,
1698
- isSubAgent: this.config.isSubAgent === true,
1699
- consumePendingCompactInfo: () => {
1700
- const info = pendingCompactInfo;
1701
- pendingCompactInfo = null;
1702
- return info;
1703
- },
1704
- consumeSteer: (source) => this.consumeSteer(sid, source),
1705
- restoreSteer: (items) => this.restoreSteer(sid, items),
1706
- buildSteerUserMessageContent: async (item) => {
1707
- const steerImageInput = await prepareRunImageInput({
1708
- task: item.text,
1709
- cwd,
1710
- llm: this.config.llm,
1711
- sessionId: sid,
1712
- attachments: item.attachments,
1713
- });
1714
- if (!steerImageInput.ok) {
1715
- throw new Error(steerImageInput.result.text);
1716
- }
1717
- return buildRunUserMessageContent(steerImageInput.parsedTask, cwd, steerImageInput.taskText);
1718
- },
1719
- claimClientMessageId: (clientMessageId, source) => claimClientMessageId(session, clientMessageId, source),
1720
- releaseClientMessageId: (clientMessageId) => {
1721
- claimedClientMessageIds.delete(clientMessageId);
1722
- },
1723
- setOriginClientMessageId: (clientMessageId) => {
1724
- toolCtx.originClientMessageId = clientMessageId;
1725
- },
1726
- recordCumulativeUsage,
1727
- recordCacheReadDiagnostics: (usage) => {
1728
- this.recordCacheReadDiagnostics(sid, usage);
1729
- },
1730
- recordContextUsageAnchor: (anchor) => {
1731
- session.state.contextUsageAnchor = {
1732
- ...anchor,
1733
- provider: this.config.llm.provider,
1734
- model: this.config.llm.model,
1735
- };
1736
- },
1737
- // Clear the persisted goal for a self-reported completion / confirmed
1738
- // cancel. Clears the in-RAM session's activeGoal (so THIS run's later
1739
- // turns don't re-arm) AND persists it, and drops the in-flight stop
1740
- // hook so nothing re-blocks the stop we're about to return.
1741
- clearPersistedGoal: () => {
1742
- if (persistedRunGoal &&
1743
- isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
1744
- session.state.activeGoal = undefined;
1745
- this.sessionManager.saveState(session.state);
1746
- }
1747
- if (goalHookHandler) {
1748
- this.hooks.unregister("on_stop", goalHookHandler);
1749
- if (this.activeGoalHook === goalHookHandler)
1750
- this.activeGoalHook = null;
1751
- }
1752
- },
1753
- updateGoalJudgeContext: (context) => {
1754
- goalJudgeContext = context;
1755
- },
1756
- ctxOverheadStore: {
1757
- get: (s) => this.ctxOverheadBySid.get(s) ?? 0,
1758
- set: (s, n) => {
1759
- this.ctxOverheadBySid.set(s, n);
1760
- },
1761
- },
1762
- }, {
1763
- // Goal mode raises the turn ceiling: an unattended goal run keeps
1764
- // getting re-blocked by the stop-hook until it's done, and the 100
1765
- // interactive default would silently truncate a long objective. The
1766
- // real backstops are the goal token/time budgets + maxStopBlocks.
1767
- maxTurns: resolveMaxTurns(this.config.maxTurns, normalizedGoal),
1768
- // Consecutive stop-block cap: config override > goal.maxStopBlocks >
1769
- // GOAL_DEFAULT_MAX_STOP_BLOCKS(25). The old hardcoded 8 was too tight
1770
- // for complex goals that legitimately get re-blocked while advancing.
1771
- maxStopBlocks: resolveMaxStopBlocks(this.config.maxStopBlocks, normalizedGoal),
1772
- // 25 (was 10): modern models routinely batch >10 parallel tool calls
1773
- // (e.g. reading a dozen files at once). At 10 the excess was silently
1774
- // dropped; the turn loop now also warns the model when it caps, but a
1775
- // higher ceiling avoids the round-trip in the common case. (B-3)
1776
- maxToolCallsPerTurn: this.config.maxToolCallsPerTurn ?? 25,
1777
- onStream: options?.onStream,
1778
- signal: options?.signal,
1779
- freshImageMessages: freshImageMessage ? [freshImageMessage] : undefined,
1780
- volatileContextMessages: dynamicContextMsg ? [dynamicContextMsg] : undefined,
1781
- // Goal mode: the active goal is surfaced to the on_stop handler via
1782
- // ctx.data.goal; the GoalStopHook (registered above) judges it.
1783
- goal: normalizedGoal,
1784
- // Heartbeat: flush turnCount + tokens to state.json after every turn
1785
- // so external observers (other CLI processes, /sid, the session list)
1786
- // see live progress instead of a stale snapshot from the last
1787
- // completed run.
1788
- onTurnBoundary: (turnCount) => {
1789
- session.state.turnCount = turnCount;
1790
- // baseline + this run's running total (idempotent per boundary,
1791
- // accumulates across runs; carries cacheRead/cacheCreation too).
1792
- session.state.tokenUsage = foldRunUsage(usageBaseline, modelFacade.getUsage());
1793
- // Surface the whole-session monotonic cache counts to the UI.
1794
- // Separate from turn-loop's authoritative per-response emit (which
1795
- // drives the live context reading and single-turn metric).
1796
- const cumulative = normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage);
1797
- const cumulativeHitRate = cumulativeCacheHitRate(cumulative);
1798
- options?.onStream?.({
1799
- type: "usage_update",
1800
- promptTokens: cumulative.cumulativePromptTokens,
1801
- promptTokensSource: "session_cumulative",
1802
- promptTokensConfidence: "high",
1803
- cumulativePromptTokens: cumulative.cumulativePromptTokens,
1804
- cumulativeCacheReadTokens: cumulative.cumulativeCacheReadTokens,
1805
- cumulativeCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
1806
- ...(cumulativeHitRate !== undefined
1807
- ? { cumulativeCacheHitRate: cumulativeHitRate }
1808
- : {}),
1809
- sessionPromptTokens: cumulative.cumulativePromptTokens,
1810
- sessionCacheReadTokens: cumulative.cumulativeCacheReadTokens,
1811
- sessionCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
1812
- });
1813
- if (this.config.costStore) {
1814
- session.state.costState = this.config.costStore.serialize();
1815
- }
1816
- this.sessionManager.saveState(session.state);
1817
- },
1818
- });
1819
- // Expose this run's loop for mid-run extension (TODO 3.1). Top-level only —
1820
- // a sub-agent's loop is its own concern and isn't user-extendable.
1821
- if (this.config.isSubAgent !== true)
1822
- this.activeTurnLoop = turnLoop;
1823
- // Expose this run's session bundle so a mid-run clearGoal() wipes the goal
1824
- // on the very instance this loop keeps saving (see field doc). Top-level
1825
- // only — sub-agents don't carry user-clearable persistent goals.
1826
- if (this.config.isSubAgent !== true)
1827
- this.activeRunSession = session;
1828
- const applyGoalTermination = (termination) => {
1829
- if (!termination || !persistedRunGoal)
1830
- return;
1831
- // Record the terminal identity even when a newer goal has already
1832
- // replaced it. Only clear activeGoal when it is still the run's goal.
1833
- session.state.goalTerminal = {
1834
- objective: persistedRunGoal.objective,
1835
- setAtMs: persistedRunGoal.setAtMs,
1836
- reason: termination,
1837
- terminatedAtMs: Date.now(),
1838
- };
1839
- if (isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
1840
- session.state.activeGoal = undefined;
1841
- }
1842
- this.sessionManager.saveState(session.state);
1843
- if (goalHookHandler) {
1844
- this.hooks.unregister("on_stop", goalHookHandler);
1845
- if (this.activeGoalHook === goalHookHandler)
1846
- this.activeGoalHook = null;
1847
- }
1848
- };
1849
- let result;
1850
- try {
1851
- result = await turnLoop.run(messages);
1852
- applyGoalTermination(result.goalTermination);
1853
- // ── Headless: drain background sub-agents before resolving ───────
1854
- // Unified background-work model (2026-06-17): the engine NO LONGER parks
1855
- // every run waiting on background work. Background work (sub-agents,
1856
- // video polls, shells) ends the turn, yields, and is picked up later by
1857
- // the server's notification-wakeup path (maybeWakeIdleSession). The
1858
- // INTERACTIVE path relies on that wakeup + a run-boundary re-check.
1859
- //
1860
- // HEADLESS is the exception: a one-shot `engine.run` whose caller takes
1861
- // `result.text` as THE answer (automation / SDK) has no later turn to
1862
- // pick up a wakeup — so it must wait, before resolving, until its own
1863
- // background SUB-AGENTS finish and summarize. Only sub-agents (their
1864
- // summary IS part of this run's result), NOT shells (a dev server never
1865
- // exits → would hang headless forever) and NOT video (a long render the
1866
- // one-shot run shouldn't block on). This replaces the old for(;;) park
1867
- // (s-mpvf4rsj-bb6e4639 invariant) for the headless case only.
1868
- const sid = session.state.sessionId;
1869
- const isTopLevel = this.config.isSubAgent !== true;
1870
- if (isTopLevel && this.isHeadless()) {
1871
- let aborted = options?.signal?.aborted === true;
1872
- // Loop: a summarize turn can spawn a NEW background sub-agent; keep
1873
- // draining + summarizing until none remain. turnCount accumulates, so
1874
- // the turn-loop's maxTurns still bounds runaway re-summarization.
1875
- for (;;) {
1876
- while (!aborted && asyncAgentRegistry.hasRunningForSession(sid)) {
1877
- aborted = await this.waitForBackgroundAgentChange(sid, options?.signal);
1878
- }
1879
- let pending = notificationQueue.drainAll(sid);
1880
- if (aborted && pending.length === 0) {
1881
- // Abort race: an agent calls markCompleted (registry notify) and only
1882
- // THEN enqueue (queue notify) as two separate statements. If the abort
1883
- // fired before that agent's completion `.then` ran, the while above
1884
- // exited on `aborted`, this drainAll caught nothing, and a naive
1885
- // `break` here would drop the agent's output. Give still-settling
1886
- // agents a bounded window to finish enqueuing, then drain once more.
1887
- // Each wait is timeout-bounded so a genuinely stuck (never-completing)
1888
- // agent can't hang abort cleanup forever — we'd rather lose nothing in
1889
- // the common case and not hang in the pathological one.
1890
- for (let i = 0; i < 20 && asyncAgentRegistry.hasRunningForSession(sid); i++) {
1891
- const changed = await this.waitForBackgroundAgentChangeOrTimeout(sid, 25);
1892
- if (!changed)
1893
- break; // timed out with no state change → stop waiting
1894
- }
1895
- pending = notificationQueue.drainAll(sid);
1896
- if (pending.length === 0)
1897
- break;
1898
- }
1899
- else if (pending.length === 0) {
1900
- break;
1901
- }
1902
- const injected = {
1903
- role: "user",
1904
- content: `<system-reminder>\n${buildNotificationMessage(pending)}\n</system-reminder>`,
1905
- };
1906
- if (aborted) {
1907
- // Mark injected: a synthetic notification, not the user's own input —
1908
- // the disk reader drops it on replay so no phantom user bubble.
1909
- session.transcript.appendMessage(injected.role, injected.content, { injected: true });
1910
- result = { ...result, messages: [...result.messages, injected] };
1911
- break;
1912
- }
1913
- result = await turnLoop.run([...result.messages, injected]);
1914
- applyGoalTermination(result.goalTermination);
1915
- }
1916
- }
1917
- }
1918
- finally {
1919
- // Run-scoped: drop the GoalStopHook so a later goal-less send on this
1920
- // long-lived engine doesn't keep blocking stops.
1921
- if (goalHookHandler)
1922
- this.hooks.unregister("on_stop", goalHookHandler);
1923
- if (this.activeGoalHook === goalHookHandler)
1924
- this.activeGoalHook = null;
1925
- if (this.activeTurnLoop === turnLoop)
1926
- this.activeTurnLoop = null;
1927
- if (this.activeRunSession === session)
1928
- this.activeRunSession = null;
1929
- // Run-scoped too: this handler is re-registered every run(), so it must be
1930
- // dropped here or it stacks duplicates that re-snapshot on every tool.
1931
- this.hooks.unregister("on_tool_start", fileHistoryHandler);
1932
- }
1933
- this.lastMessages = result.messages;
1934
- const cachedMessages = this.stripInjectedContextMessages(result.messages, userContextMsg, dynamicContextMsg);
1935
- this.compactedMessagesBySession.set(session.state.sessionId, cachedMessages);
1936
- logger.info("engine.done", {
1937
- sessionId: session.state.sessionId,
1938
- reason: result.reason,
1939
- turns: turnLoop.currentTurn,
1940
- tokens: modelFacade.getUsage().totalTokens,
1941
- });
1942
- recordSessionEnd(session.state.sessionId, {
1943
- reason: result.reason,
1944
- turns: turnLoop.currentTurn,
1945
- cost: modelFacade.getUsage(),
1946
- });
1947
- // Session-level hook: fired symmetrically with on_session_start once
1948
- // the turn loop has resolved (completion, error, or abort). Handlers
1949
- // are notify-only — any returned messages are dropped because the run
1950
- // is already over and there's no next turn to inject into.
1951
- await this.emitHook("on_session_end", {
1952
- sessionId: session.state.sessionId,
1953
- reason: result.reason,
1954
- turnCount: turnLoop.currentTurn,
1955
- });
1956
- // Fire-and-forget memory pipeline: extract durable memories from the
1957
- // transcript, save a session summary, and conditionally trigger
1958
- // auto-dream consolidation. Doesn't block the Engine result.
1959
- void this.runMemoryPipeline(session.transcript, session.state.sessionId, cwd, llmClient);
1960
- // Fire-and-forget session title generation — only after the FIRST turn.
1961
- // Reuses the already-resolved auxSummaryClient (aux model, cheap). Best-
1962
- // effort: failures never touch the run result. The renderer writes the
1963
- // title into the sidebar on receipt of the session_title stream event.
1964
- {
1965
- const messageEvents = session.transcript.getEvents("message");
1966
- const userMsgEvents = messageEvents.filter((e) => e.data.role === "user");
1967
- const userMsgCount = userMsgEvents.length;
1968
- const onStream = options?.onStream;
1969
- if (userMsgCount === 1 && onStream && result.text) {
1970
- const rawContent = userMsgEvents[0]?.data?.content;
1971
- const firstUserText = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent ?? "");
1972
- void buildSessionTitle(auxSummaryClient, firstUserText, result.text)
1973
- .then((title) => {
1974
- if (title) {
1975
- // Persist the title so it survives a localStorage wipe / disk
1976
- // rebuild — it used to live only in the renderer's localStorage
1977
- // index. This .then resolves AFTER the saveState below (:1892), so
1978
- // it must save again itself rather than rely on that write.
1979
- session.state.title = title;
1980
- this.sessionManager.saveState(session.state);
1981
- onStream({
1982
- type: "session_title",
1983
- sessionId: session.state.sessionId,
1984
- title,
1985
- });
1986
- }
1987
- })
1988
- .catch(() => { });
1989
- }
1990
- }
1991
- // Update session state. Persist the raw terminal reason as the status so
1992
- // callers can distinguish user-cancelled (aborted_streaming) from real
1993
- // failures (model_error, prompt_too_long, ...) — previously every
1994
- // non-completed outcome collapsed to "errored", which threw away the
1995
- // distinction and misled anyone reading state.json.
1996
- session.state.turnCount = turnLoop.currentTurn;
1997
- session.state.status = result.reason;
1998
- // Session-cumulative (baseline + this run) for persistence...
1999
- const usage = modelFacade.getUsage();
2000
- session.state.tokenUsage = foldRunUsage(usageBaseline, usage);
2001
- if (this.config.costStore) {
2002
- session.state.costState = this.config.costStore.serialize();
1243
+ if (options?.clientMessageId) {
1244
+ this.appendClientRunReceipt(session, options.clientMessageId, failed);
2003
1245
  }
2004
- this.sessionManager.saveState(session.state);
2005
- // Hook: agent end
2006
- await this.emitHook("on_agent_end", {
1246
+ return failed;
1247
+ });
1248
+ }
1249
+ /** A receipt write failure must not turn an already finalized model result
1250
+ * into a second synthetic failure (or reject the Engine.run contract). */
1251
+ appendClientRunReceipt(session, clientMessageId, result) {
1252
+ try {
1253
+ session.transcript.appendRunResult(clientMessageId, result);
1254
+ }
1255
+ catch (error) {
1256
+ logger.warn("engine.client_message.receipt_persist_failed", {
2007
1257
  sessionId: session.state.sessionId,
2008
- reason: result.reason,
2009
- turnCount: turnLoop.currentTurn,
1258
+ clientMessageId,
1259
+ error: error instanceof Error ? error.message : String(error),
2010
1260
  });
2011
- // Emit completion
2012
- options?.onStream?.({ type: "turn_complete", reason: result.reason });
2013
- return {
2014
- text: result.text,
2015
- reason: result.reason,
2016
- sessionId: session.state.sessionId,
2017
- turnCount: turnLoop.currentTurn,
2018
- usage: {
2019
- promptTokens: usage.totalPromptTokens,
2020
- completionTokens: usage.totalCompletionTokens,
2021
- totalTokens: usage.totalTokens,
2022
- },
2023
- };
2024
- });
1261
+ }
2025
1262
  }
2026
1263
  /**
2027
- * Run the end-of-session memory pipeline as a fire-and-forget background
2028
- * task. Extracts durable memories from the transcript, saves a session
2029
- * summary, and conditionally triggers auto-dream consolidation.
2030
- */
2031
- /**
2032
- * Resolve the LLM client for background/auxiliary work (memory extraction,
2033
- * auto-dream). When settings.defaults.auxText names a valid pool model, build
2034
- * (and cache) a dedicated client for it so per-turn book-keeping runs on a cheap
2035
- * fast model instead of the expensive primary. Falls back to `fallback` (the
2036
- * active run's client) when unset, unknown, or on any build failure — aux
2037
- * work is best-effort and must never break a run.
1264
+ * Terminal success path: forward to {@link finalizeRunSuccess} with the
1265
+ * engine-bound persistence / memory / hook closures filled in. Extracted from
1266
+ * the {@link runExclusive} skeleton so the terminal-state assembly reads as a
1267
+ * single call; behavior is unchanged.
2038
1268
  */
1269
+ finalizeRun(args) {
1270
+ const { session, result, firstGoalTermination, turnCount, getRunUsage, usageBaseline, userContextMsg, dynamicContextMsg, options, cwd, llmClient, auxSummaryClient, recordExternalBilledUsage, accounting, profile, getProfileReportedResults, } = args;
1271
+ return finalizeRunSuccess({
1272
+ session,
1273
+ result,
1274
+ firstGoalTermination,
1275
+ turnCount,
1276
+ getRunUsage,
1277
+ usageBaseline,
1278
+ userContextMsg,
1279
+ dynamicContextMsg,
1280
+ setCompactedMessages: (s, msgs) => this.compactedMessagesBySession.set(s, msgs),
1281
+ setLastMessages: (msgs) => {
1282
+ this.lastMessages = msgs;
1283
+ },
1284
+ options,
1285
+ emitHook: (event, payload, signal) => this.emitHook(event, payload, signal),
1286
+ cwd,
1287
+ llmClient,
1288
+ auxSummaryClient,
1289
+ recordExternalBilledUsage,
1290
+ runMemoryPipeline: (transcript, sessionId, runCwd, client, record) => this.runMemoryPipeline(transcript, sessionId, runCwd, client, record),
1291
+ updatePersistedSessionState: (s, patch) => this.updatePersistedSessionState(s, patch),
1292
+ persistFinalRunState: (state) => this.persistFinalRunState(state),
1293
+ markRunAccountingFinalized: () => accounting.markRunAccountingFinalized(),
1294
+ costStoreSerialize: this.config.costStore
1295
+ ? () => this.config.costStore.serialize()
1296
+ : undefined,
1297
+ profile,
1298
+ getProfileReportedResults,
1299
+ });
1300
+ }
2039
1301
  /**
2040
- * Build the SummarizeFn used for context compaction. Extracted so both the
2041
- * run path and forceCompact share one definition of the summarization call.
1302
+ * Build the stream callback that wraps the caller's `onStream`: it snapshots
1303
+ * TodoWrite `task_update` events for TaskGuard and persists `goal_progress`
1304
+ * events to the transcript before delegating. Extracted verbatim from the
1305
+ * {@link runExclusive} skeleton; the todo buffer and (not-yet-open) session
1306
+ * are reached through the `setLatestTodos` / `getSession` accessors.
2042
1307
  */
2043
- buildSummarizeFn(auxSummaryClient, recordCumulativeUsage) {
2044
- return async (prompt) => {
2045
- const summaryResponse = await auxSummaryClient.createMessage({
2046
- systemPrompt: "You are a conversation summarizer. Be concise and factual.",
2047
- messages: [{ role: "user", content: prompt }],
2048
- tools: [],
2049
- maxTokens: 1024,
2050
- // Auxiliary callno need to burn reasoning tokens. On DeepSeek V4
2051
- // this flips thinking off (~3x faster, fewer tokens); on every other
2052
- // OpenAI-compatible provider the field is ignored.
2053
- reasoning: { mode: "off" },
2054
- });
2055
- if (summaryResponse.usage) {
2056
- recordCumulativeUsage?.(summaryResponse.usage);
1308
+ buildWrappedOnStream(args) {
1309
+ const { userOnStream, getSession, setLatestTodos } = args;
1310
+ return (event) => {
1311
+ if (event.type === "task_update") {
1312
+ setLatestTodos(event.tasks);
1313
+ }
1314
+ // Persist goal progress so replay/history shows how many rounds the
1315
+ // goal ran. Display-only toMessages() ignores this type, so it never
1316
+ // re-enters the LLM context.
1317
+ if (event.type === "goal_progress") {
1318
+ getSession().transcript.append("goal_progress", {
1319
+ ...(event.goalId ? { goalId: event.goalId } : {}),
1320
+ status: event.status,
1321
+ round: event.round,
1322
+ ...(event.gaps ? { gaps: event.gaps } : {}),
1323
+ });
2057
1324
  }
2058
- return summaryResponse.text;
1325
+ userOnStream?.(event);
2059
1326
  };
2060
1327
  }
2061
- async resolveAuxClient(fallback) {
2062
- let auxKey;
2063
- try {
2064
- // Re-read from disk: settings may have been changed by the desktop
2065
- // (a separate process) since this worker last cached them. This runs
2066
- // once per run on the post-run background path, so the cost is fine.
2067
- const sm = this.getSettingsManager();
2068
- sm.invalidate();
2069
- // Unified store's defaults.auxText (a connection id = pool key) selects
2070
- // the aux model; resolveAuxKey returns it (or undefined).
2071
- auxKey = resolveAuxKey(sm.get());
2072
- }
2073
- catch {
2074
- return fallback;
2075
- }
2076
- if (!auxKey)
2077
- return fallback;
2078
- // Don't spin up a second client when the aux key resolves to the SAME
2079
- // client config as this engine's active model. Compare FULL LLM IDENTITY
2080
- // (model + reasoning + maxTokens + baseUrl + provider/providerKind) against
2081
- // this engine's own per-session config.llm NOT a separately-tracked active
2082
- // key, and NOT just the model NAME. Two distinct pool keys can share the same
2083
- // `model` string yet differ in reasoning/maxOutputTokens/baseUrl/apiKey/
2084
- // providerKey; de-duping on the name alone would wrongly route the user's
2085
- // chosen aux entry onto the primary's config. config.llm is isolated per
2086
- // session and always set for a real run, so this is correct even for desktop
2087
- // worker sessions built with a shared runtime (which never explicitly
2088
- // switchModel, so the old activeModelKey field was undefined and defeated the
2089
- // de-dup), AND immune to another session mutating the shared pool's activeKey.
2090
- const entry = this.modelPool.get(auxKey);
2091
- if (entry && sameLlmIdentity(this.modelPool.toLLMConfig(entry), this.config.llm)) {
2092
- return fallback;
2093
- }
2094
- if (this.auxClientCache?.key === auxKey)
2095
- return this.auxClientCache.client;
2096
- if (!entry) {
2097
- logger.warn("engine.aux_model_missing", { auxModelKey: auxKey });
2098
- return fallback;
2099
- }
2100
- try {
2101
- const client = await createLLMClient(this.modelPool.toLLMConfig(entry), this.config.clientDefaults);
2102
- this.auxClientCache = { key: auxKey, client };
2103
- return client;
2104
- }
2105
- catch (err) {
2106
- logger.warn("engine.aux_model_build_failed", {
2107
- auxModelKey: auxKey,
2108
- error: err.message,
1328
+ /**
1329
+ * Run the turn loop once, apply its goal termination, and — for a top-level
1330
+ * headless run only — drain background sub-agents before resolving. Extracted
1331
+ * verbatim from the body of the {@link runExclusive} try block; the cleanup
1332
+ * `finally` stays in the skeleton so the run-scoped hook/loop teardown is
1333
+ * guaranteed regardless of how this method returns or throws.
1334
+ */
1335
+ async runTurnLoopWithHeadlessDrain(args) {
1336
+ const { turnLoop, messages, applyGoalTermination, session, options } = args;
1337
+ let result = await turnLoop.run(messages);
1338
+ let firstGoalTermination = result.goalTermination;
1339
+ applyGoalTermination(result.goalTermination, result.goalTerminationRound);
1340
+ // ── Headless: drain background sub-agents before resolving ───────
1341
+ // Unified background-work model (2026-06-17): the engine NO LONGER parks
1342
+ // every run waiting on background work. Background work (sub-agents,
1343
+ // video polls, shells) ends the turn, yields, and is picked up later by
1344
+ // the server's notification-wakeup path (maybeWakeIdleSession). The
1345
+ // INTERACTIVE path relies on that wakeup + a run-boundary re-check.
1346
+ //
1347
+ // HEADLESS is the exception: a one-shot `engine.run` whose caller takes
1348
+ // `result.text` as THE answer (automation / SDK) has no later turn to
1349
+ // pick up a wakeup so it must wait, before resolving, until its own
1350
+ // background SUB-AGENTS finish and summarize. Only sub-agents (their
1351
+ // summary IS part of this run's result), NOT shells (a dev server never
1352
+ // exits would hang headless forever) and NOT video (a long render the
1353
+ // one-shot run shouldn't block on). This replaces the old for(;;) park
1354
+ // (s-mpvf4rsj-bb6e4639 invariant) for the headless case only.
1355
+ const sid = session.state.sessionId;
1356
+ const isTopLevel = this.config.isSubAgent !== true;
1357
+ if (isTopLevel && this.isHeadless()) {
1358
+ result = await drainHeadlessBackgroundAgents({
1359
+ sid,
1360
+ session,
1361
+ signal: options?.signal,
1362
+ initialResult: result,
1363
+ runTurnLoop: (msgs) => turnLoop.run(msgs),
1364
+ applyGoalTermination,
1365
+ waitForBackgroundAgentChange: (s, sig) => this.waitForBackgroundAgentChange(s, sig),
1366
+ waitForBackgroundAgentChangeOrTimeout: (s, ms) => this.waitForBackgroundAgentChangeOrTimeout(s, ms),
1367
+ getFirstGoalTermination: () => firstGoalTermination,
1368
+ setFirstGoalTermination: (t) => {
1369
+ firstGoalTermination = t;
1370
+ },
2109
1371
  });
2110
- return fallback;
2111
1372
  }
1373
+ return { result, firstGoalTermination };
2112
1374
  }
2113
- async runMemoryPipeline(transcript, sessionId, cwd, primaryClient) {
2114
- try {
2115
- // Background calls run on the auxiliary model when configured, so memory
2116
- // book-keeping doesn't burn the expensive primary model every turn.
2117
- // settings.memories.extractionModel (if set + valid) overrides the aux
2118
- // model specifically for memory extraction (TODO 8.1).
2119
- const llmClient = await this.resolveExtractionClient(primaryClient);
2120
- // Only run memory extraction for substantive sessions. The previous
2121
- // threshold of 4 user+assistant messages was low enough that two-line
2122
- // exchanges ("what's the time?" / "noon") triggered a full LLM
2123
- // extraction, which then padded the memory store with low-signal
2124
- // entries. 8 messages is roughly "more than a single back-and-forth"
2125
- // substantive enough to be worth a durable note.
2126
- const messages = transcript
2127
- .toMessages()
2128
- .filter((m) => m.role === "user" || m.role === "assistant");
2129
- if (messages.length < 8)
2130
- return;
2131
- // Memory orchestrator + dream-loop calls are auxiliary LLM calls
2132
- // that don't (and shouldn't) carry image payloads. Sanitize before
2133
- // stringify so we don't pump a 10 MB base64 string into the
2134
- // summarization prompt provider 400s on it, and it leaks bytes
2135
- // into a downstream cost-tracking path we don't audit as carefully
2136
- // as the primary turn.
2137
- const plainMessages = messages.map((m) => {
2138
- const safe = sanitizeContent(m.content);
2139
- return {
2140
- role: m.role,
2141
- content: typeof safe === "string" ? safe : JSON.stringify(safe),
2142
- };
2143
- });
2144
- const orchestrator = new MemoryOrchestrator({
2145
- callLLM: async (sysPrompt, userMsg) => {
2146
- // Use a lightweight auxiliary call (no tools, no streaming, no
2147
- // reasoning tokens).
2148
- const resp = await llmClient.createMessage({
2149
- systemPrompt: sysPrompt,
2150
- messages: [{ role: "user", content: userMsg }],
2151
- tools: [],
2152
- maxTokens: 1024,
2153
- recordUsage: false,
2154
- reasoning: { mode: "off" },
2155
- });
2156
- return resp.text;
2157
- },
2158
- runDream: async ({ systemPrompt, userPrompt, projectDir }) => this.runDreamLoop({ systemPrompt, userPrompt, projectDir, llmClient, sessionId }),
2159
- projectDir: cwd,
2160
- // settings.memories.maxCount caps memories accepted per extraction;
2161
- // autoExtract=false turns the extractor off (summaries/dream stay).
2162
- maxCount: this.readMemoriesConfig()?.maxCount,
2163
- autoExtract: this.readMemoriesConfig()?.autoExtract,
2164
- });
2165
- await orchestrator.run(plainMessages, sessionId);
1375
+ /**
1376
+ * Stamp the resolved session identity and session-scoped side-effect sinks
1377
+ * onto the run's {@link ToolContext}, now that the session bundle is open.
1378
+ * Extracted verbatim from the {@link runExclusive} skeleton.
1379
+ */
1380
+ stampRunToolContext(toolCtx, session, options) {
1381
+ // B2 / Gate 1: stamp the resolved sid onto the tool context so
1382
+ // session-scoped side effects (background-agent completion
1383
+ // notifications) attribute to the right session. toolCtx is created
1384
+ // before the session bundle is resolved (see ~line 635), so this is
1385
+ // the first point we can set it. After this assignment treat the
1386
+ // field follows the latest successfully injected user intent for the rest
1387
+ // of the run, so tools launched after a steer attribute their side effects
1388
+ // to that steer rather than this original submit.
1389
+ toolCtx.sessionId = session.state.sessionId;
1390
+ toolCtx.originClientMessageId = options?.clientMessageId;
1391
+ toolCtx.recordExternalFileChanges = (record) => {
1392
+ session.transcript.append("external_file_changes", { ...record });
1393
+ };
1394
+ toolCtx.setSessionWorkspace = (workspace, persistedRevision) => {
1395
+ // Enter/ExitWorktree passes the revision returned by its in-process
1396
+ // field update. The desktop bridge persists in another process before
1397
+ // returning, so repeat the idempotent workspace update here to obtain a
1398
+ // revision owned by this live bundle.
1399
+ const stateRevision = persistedRevision ??
1400
+ this.sessionManager.setSessionWorkspace(session.state.sessionId, workspace);
1401
+ Object.assign(session.state, { workspace, stateRevision });
1402
+ };
1403
+ if (this.config.isSubAgent !== true &&
1404
+ session.state.kind === "work" &&
1405
+ !isEphemeralSessionState(session.state)) {
1406
+ this.attachSessionMessageService(toolCtx, session, options);
2166
1407
  }
2167
- catch (err) {
2168
- // Memory pipeline is best-effort never surface errors to the user.
2169
- logger.warn("engine.memory_pipeline_failed", {
1408
+ }
1409
+ /** Attach a closed-set, host-routed Session message sender to this run. */
1410
+ attachSessionMessageService(toolCtx, session, options) {
1411
+ const sourceSessionId = session.state.sessionId;
1412
+ const sourceRoot = this.sessionManager.readSessionMainRoot(sourceSessionId);
1413
+ if (!sourceRoot)
1414
+ return;
1415
+ if (!this.sessionMessageRouter)
1416
+ return;
1417
+ const catalog = [];
1418
+ const seen = new Set();
1419
+ const rawTargets = Array.isArray(options?.sessionMessageTargets)
1420
+ ? [...options.sessionMessageTargets]
1421
+ : [];
1422
+ for (const raw of rawTargets.slice(0, 100)) {
1423
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
1424
+ continue;
1425
+ const candidate = raw;
1426
+ const sessionId = typeof candidate.sessionId === "string" ? candidate.sessionId : "";
1427
+ try {
1428
+ assertSafeSessionId(sessionId);
1429
+ }
1430
+ catch {
1431
+ continue;
1432
+ }
1433
+ if (seen.has(sessionId))
1434
+ continue;
1435
+ if (candidate.workspaceRoot !== sourceRoot)
1436
+ continue;
1437
+ const title = typeof candidate.title === "string" ? candidate.title.trim() : "";
1438
+ if (!title || title.length > 512)
1439
+ continue;
1440
+ const workspaceProfile = typeof candidate.workspaceProfile === "string"
1441
+ ? candidate.workspaceProfile.trim().slice(0, 256)
1442
+ : "";
1443
+ seen.add(sessionId);
1444
+ catalog.push({
2170
1445
  sessionId,
2171
- error: err.message,
1446
+ title,
1447
+ workspaceRoot: sourceRoot,
1448
+ ...(workspaceProfile ? { workspaceProfile } : {}),
2172
1449
  });
2173
1450
  }
1451
+ const targets = catalog.filter((target) => target.sessionId !== sourceSessionId);
1452
+ toolCtx.sessionMessages = {
1453
+ targets,
1454
+ send: async ({ targetSessionId, message }) => {
1455
+ const target = targets.find((candidate) => candidate.sessionId === targetSessionId);
1456
+ if (!target)
1457
+ throw new Error("target Session is not in the host-authorized project list");
1458
+ if (!message.trim())
1459
+ throw new Error("message is required");
1460
+ if (message.length > 48_000)
1461
+ throw new Error("message exceeds 48000 characters");
1462
+ await this.sessionMessageRouter({
1463
+ sourceSessionId,
1464
+ target,
1465
+ message,
1466
+ catalog,
1467
+ });
1468
+ return target;
1469
+ },
1470
+ };
2174
1471
  }
2175
1472
  /**
2176
- * Drive the auto-dream tool-call loop.
2177
- *
2178
- * Runs the LLM with a whitelisted subset of memory tools (MemoryList,
2179
- * MemoryRead, MemorySave, MemoryDelete). The loop is intentionally small
2180
- * and offline:
2181
- * - No streaming, no UI events — runs in the background after a session.
2182
- * - No permission prompts — UI isn't attached, so we hard-reject any
2183
- * attempt to Save/Delete in the "user" scope before dispatching. Dream
2184
- * scope is the LLM's workspace and goes through freely.
2185
- * - Capped at MAX_TURNS LLM round-trips and MAX_WRITES total
2186
- * mutations to bound damage on misbehavior.
2187
- *
2188
- * Returns true if the loop ran (with or without writes); false if we
2189
- * bailed before the first LLM call (e.g. registry missing the tools).
2190
- */
2191
- async runDreamLoop(opts) {
2192
- // The loop body now lives in services/dream-consolidation.ts so it can
2193
- // also be driven from the desktop host's manual "整理 / Dream" trigger.
2194
- // The orchestrator built systemPrompt/userPrompt from this engine's
2195
- // MemoryManager already; runDreamConsolidation rebuilds them from the same
2196
- // projectDir, so passing them here would be redundant — we just hand it the
2197
- // tool registry + a memory-scoped tool context.
2198
- const { ran } = await runDreamConsolidation({
2199
- llmClient: opts.llmClient,
1473
+ * Await the parallel prompt/context assembly (LLM client handshake, system
1474
+ * prompt, dynamic + user context messages), splice the hook-injected and
1475
+ * context messages into `messages`, publish this run's last-session snapshot,
1476
+ * and prime the {@link ContextManager}'s transcript path + replacement state.
1477
+ * Extracted verbatim from the {@link runExclusive} skeleton.
1478
+ */
1479
+ async assembleRunPrompts(args) {
1480
+ const { session, messages, hookMessages, promptComposer, toolDefs, llmClientPromise, contextManager, profile, profileParams, } = args;
1481
+ const [llmClient, baseSystemPrompt, dynamicContextMsg] = await Promise.all([
1482
+ llmClientPromise,
1483
+ // System prompt is now the STABLE prefix only — skills + git status moved
1484
+ // out to a trailing per-turn message so they no longer bust the cache.
1485
+ promptComposer.buildSystemPrompt(toolDefs),
1486
+ promptComposer.buildDynamicContextMessage(),
1487
+ ]);
1488
+ const fullSystemPrompt = composeRunSystemPrompt({
1489
+ baseSystemPrompt,
1490
+ profile,
1491
+ profileParams,
1492
+ });
1493
+ const userContextMsg = promptComposer.buildUserContextMessage();
1494
+ assembleRunMessages({
1495
+ messages,
1496
+ userContextMsg,
1497
+ hookMessages,
1498
+ dynamicContextMsg,
1499
+ });
1500
+ this.lastSessionId = session.state.sessionId;
1501
+ this.lastMessages = messages;
1502
+ // Wire up LLM summarization for context compaction
1503
+ // Uses a lightweight call without tools
1504
+ contextManager.setTranscriptPath(session.transcript.getFilePath());
1505
+ // Re-derive frozen persistence decisions from the messages we just
1506
+ // loaded. Skipped on cold start (messages == [userContextMsg] only).
1507
+ // Critical for resume — otherwise a result that was persisted last
1508
+ // run would be evaluated fresh and might get a different replacement
1509
+ // string than the one already in the message, breaking idempotency.
1510
+ contextManager.initReplacementStateFromMessages(messages);
1511
+ // Two summarizers with DIFFERENT quality needs (see the run loop wiring for
1512
+ // the aux-model summarizer set up alongside the ModelFacade).
1513
+ return { llmClient, fullSystemPrompt, dynamicContextMsg, userContextMsg };
1514
+ }
1515
+ /**
1516
+ * Seed the run's {@link ContextManager}, emit the early `session_started`
1517
+ * event, replay the last TodoWrite snapshot on resume, kick off the LLM client
1518
+ * handshake, and build the permission-gated {@link ToolExecutor}. Extracted
1519
+ * verbatim from the {@link runExclusive} skeleton; the todo snapshot is read
1520
+ * and written through the `getLatestTodos` / `setLatestTodos` accessors so the
1521
+ * outer wrapped-onStream and TaskGuard keep observing the same buffer.
1522
+ */
1523
+ wireRunContextAndPermission(args) {
1524
+ const { session, sid, options, cwd, toolCtx, runPermissionMode, messages, getLatestTodos, setLatestTodos, } = args;
1525
+ const { contextManager, ctxSeed } = createRunContextManager({
1526
+ maxTokens: this.resolveMaxContextTokens(),
1527
+ ratios: this.resolveContextRatios(),
1528
+ persistedAnchor: session.state.contextUsageAnchor,
1529
+ llmProvider: this.config.llm.provider,
1530
+ llmModel: this.config.llm.model,
1531
+ messages,
1532
+ needsCtxSeed: !this.ctxSeedSent.has(sid),
1533
+ });
1534
+ this.lastContextManager = contextManager;
1535
+ if (!this.ctxSeedSent.has(sid))
1536
+ this.ctxSeedSent.add(sid);
1537
+ // Tell the client the sid *now* instead of waiting for run() to resolve.
1538
+ // The user wants `/sid` to work mid-turn; without this, the client only
1539
+ // learns the sid when the run completes.
1540
+ options?.onStream?.({
1541
+ type: "session_started",
1542
+ sessionId: sid,
1543
+ promptTokens: ctxSeed.tokens,
1544
+ promptTokensSource: ctxSeed.source,
1545
+ promptTokensConfidence: ctxSeed.confidence,
1546
+ });
1547
+ // Replay the last TodoWrite snapshot on resume so the UI's pinned
1548
+ // task panel re-hydrates without the LLM needing to call TodoWrite
1549
+ // again. Scans the resumed transcript newest-first (and tolerates
1550
+ // legacy TaskCreate/Update events for sessions recorded against
1551
+ // the pre-2026-05-24 API). New sessions have no transcript yet so
1552
+ // readLastTodoSnapshot returns null and nothing is emitted.
1553
+ if (options?.sessionId) {
1554
+ const snap = readLastTodoSnapshot(session.transcript.getEvents());
1555
+ if (snap && snap.length > 0) {
1556
+ setLatestTodos(snap);
1557
+ options?.onStream?.({ type: "task_update", tasks: snap });
1558
+ }
1559
+ }
1560
+ // Kick off LLM client creation early (network handshake)
1561
+ const llmClientPromise = createLLMClient(this.config.llm, this.config.clientDefaults);
1562
+ // MCP connection below may keep us from awaiting this promise for a while.
1563
+ // Observe rejection immediately so a fast client-init failure cannot become
1564
+ // an unhandledRejection during that gap; Promise.all still receives the
1565
+ // original promise and routes the same error through the lifecycle catch.
1566
+ void llmClientPromise.catch(() => { });
1567
+ const mode = runPermissionMode;
1568
+ const { toolExecutor } = buildRunPermissionPipeline({
1569
+ permissionController: this.permissionController,
1570
+ mode,
1571
+ cwd,
1572
+ approvalRouter: toolCtx.approvalRouter,
1573
+ sessionId: session.state.sessionId,
1574
+ toolRegistry: this.toolRegistry,
1575
+ hooks: this.hooks,
1576
+ toolCtx,
1577
+ signal: options?.signal,
1578
+ readOnlySession: this.config.readOnlySession === true,
1579
+ headless: this.config.headless === true,
1580
+ getLatestTodos: () => getLatestTodos(),
1581
+ onApprovalPhase: (waiting, toolName) => {
1582
+ options?.onAgentProgress?.({
1583
+ type: "phase",
1584
+ phase: waiting ? "waiting-permission" : "tool",
1585
+ toolName,
1586
+ });
1587
+ },
1588
+ emitNotificationHook: (payload) => {
1589
+ void this.emitHook("notification", payload);
1590
+ },
1591
+ });
1592
+ return { contextManager, llmClientPromise, toolExecutor };
1593
+ }
1594
+ /**
1595
+ * Resolve the run sandbox, construct the child sub-agent spawner (the sole
1596
+ * `new Engine(...)` call path, kept in engine.ts so the protocol bypass guard
1597
+ * stays satisfied), and assemble the per-run {@link ToolContext}. Extracted
1598
+ * verbatim from the {@link runExclusive} skeleton; the session bundle is read
1599
+ * lazily via `getSession` because it is opened after this wiring runs.
1600
+ */
1601
+ async wireRunSandboxToolContext(args) {
1602
+ const { options, cwd, runPermissionMode, runPlanMode, profile, profileParams, sessionProfileOverrides, profileMemoryDir, getSession, reportResult, } = args;
1603
+ // Resolve before constructing the child spawner: a parent sandbox may come
1604
+ // solely from project/user settings rather than config.sandbox, while a
1605
+ // child intentionally skips project settings. Passing the complete
1606
+ // effective config is what makes undefined role sandbox mean inherit.
1607
+ const sandboxConfig = this.runEnvironmentResolver.resolveSandboxConfig(cwd);
1608
+ // Build the per-Engine ToolContext that will be threaded through every
1609
+ // tool call. Replaces the old module-level singleton setters used by
1610
+ // built-ins and product capabilities.
1611
+ const subAgentSpawner = createSubAgentSpawner({
1612
+ parentConfig: this.config,
1613
+ parentSandbox: sandboxConfig,
1614
+ presetName: this.preset.name,
1615
+ cwd,
1616
+ permissionMode: runPermissionMode,
1617
+ modelPool: this.modelPool,
1618
+ parentStream: options?.onStream,
1619
+ appendParentSubagent: (agentId, description) => {
1620
+ getSession().transcript.appendSubagent(agentId, undefined, description);
1621
+ },
1622
+ sessionExists: (sessionId) => this.sessionManager.exists(sessionId),
1623
+ getSessionParentId: (sessionId) => this.sessionManager.readParentSessionId(sessionId),
1624
+ childRunner: {
1625
+ createChild: (config) => new Engine(config),
1626
+ runChild: async (config, childTask, childOptions) => {
1627
+ const child = new Engine(config);
1628
+ return child.run(childTask, childOptions);
1629
+ },
1630
+ },
1631
+ });
1632
+ // A2: explicit sandbox modes (seatbelt, bwrap) must fail closed
1633
+ // per standard §S4. resolveSandboxBackend throws when an explicit
1634
+ // mode is unavailable on this host; we let it propagate. The
1635
+ // previous behavior — catching the throw inside the hot turn and
1636
+ // silently downgrading to "off" — was the leak A2 closes. The
1637
+ // `auto` mode handles its own downgrade with a one-time warning
1638
+ // inside resolveSandboxBackend; explicit modes do not.
1639
+ //
1640
+ // Backend is cached per runtime/engine so the capability probe runs once
1641
+ // per (mode, cwd) instead of every turn.
1642
+ const sandboxBackend = await this.runEnvironmentResolver.resolveSandbox(cwd);
1643
+ // Observability: surface what sandbox actually applied this run — the
1644
+ // configured mode vs the resolved backend (auto may downgrade to off when
1645
+ // no OS backend is available) + the network policy. Without this you can't
1646
+ // tell whether shell commands were isolated /网络放没放. One line per run.
1647
+ logger.info("sandbox.resolved", {
1648
+ mode: sandboxConfig.mode,
1649
+ backend: sandboxBackend.name,
1650
+ isolated: sandboxBackend.name !== "off",
1651
+ network: sandboxConfig.network,
1652
+ cwd,
1653
+ });
1654
+ const toolCtx = buildRunToolContext({
1655
+ base: this.buildToolContext(cwd, sessionProfileOverrides, profileMemoryDir),
1656
+ options,
1657
+ configApprovalRouter: this.config.approvalRouter,
1658
+ runPermissionMode,
1659
+ runPlanMode,
1660
+ subAgentSpawner,
1661
+ agentDefinitions: this.getAgentDefinitions(cwd, sessionProfileOverrides),
1662
+ sandbox: sandboxBackend.name === "off"
1663
+ ? sandboxBackend
1664
+ : { ...sandboxBackend, network: sandboxConfig.network },
1665
+ cwd,
1666
+ shellEnv: this.runEnvironmentResolver.readShellEnv(cwd),
1667
+ profile,
1668
+ profileParams,
1669
+ reportResult,
1670
+ });
1671
+ return toolCtx;
1672
+ }
1673
+ /**
1674
+ * Wire every run-scoped dependency the turn loop needs: usage accounting +
1675
+ * summarizer, the {@link ModelFacade}, the file-history hook, the
1676
+ * `on_agent_start` hook, goal resolution / arming, the {@link TurnLoop} itself,
1677
+ * and the goal-termination applier. Extracted verbatim from the
1678
+ * {@link runExclusive} skeleton; the goal slots, judge-context buffer and usage
1679
+ * baseline are fully local here — only the values the try/finally + finalize
1680
+ * phases consume cross back out.
1681
+ */
1682
+ async wireRunLoop(args) {
1683
+ const { session, sid, task, cwd, options, toolCtx, toolExecutor, contextManager, llmClient, auxSummaryClient, fullSystemPrompt, toolDefs, claimClientMessageId, releaseClientMessageId, freshImageMessage, dynamicContextMsg, } = args;
1684
+ // eslint-disable-next-line prefer-const
1685
+ let turnLoop;
1686
+ const accounting = createRunUsageAccounting({
1687
+ session,
1688
+ sid,
1689
+ resumeState: (s) => this.sessionManager.resume(s).state,
1690
+ updatePersistedSessionState: (s, patch) => this.updatePersistedSessionState(s, patch),
1691
+ costStore: this.config.costStore,
1692
+ recordGoalJudgeUsage: (usage) => turnLoop.recordGoalJudgeUsage(usage),
1693
+ });
1694
+ const { recordCumulativeUsage, recordExternalBilledUsage } = accounting;
1695
+ contextManager.setSummarizeFn(this.buildSummarizeFn(llmClient, recordExternalBilledUsage));
1696
+ const { modelFacade, getRunUsage } = wireRunModelFacade({
1697
+ llmClient,
1698
+ auxSummaryClient,
1699
+ transcript: session.transcript,
1700
+ accounting,
1701
+ });
1702
+ // Session-cumulative usage baseline: the LLM client is recreated per run
1703
+ // (its getUsage() counts only THIS run), so to accumulate across runs we
1704
+ // capture the persisted total at run start and fold this run's usage onto
1705
+ // it (see foldRunUsage). Snapshot now, before any turn boundary fires.
1706
+ const usageBaseline = { ...session.state.tokenUsage };
1707
+ const sessionDir = join(this.config.sessionStorageDir ?? sessionsRoot(), session.state.sessionId);
1708
+ const fileHistoryHook = registerFileHistoryHook({
1709
+ hooks: this.hooks,
1710
+ sessionDir,
1711
+ cwd,
1712
+ getTurnSeq: () => session.state.turnSeq,
1713
+ contributions: this.capabilities.flatMap((capability) => [...(capability.fileHistory ?? [])]),
1714
+ });
1715
+ // Hook: agent start
1716
+ await this.emitHook("on_agent_start", {
1717
+ sessionId: session.state.sessionId,
1718
+ task,
1719
+ model: this.config.llm.model,
1720
+ }, options?.signal);
1721
+ // Goal mode: register a GoalStopHook for the lifetime of THIS run so the
1722
+ // turn loop keeps going until the session model judges the goal met.
1723
+ // Registered per-run (and cleared in `finally`) so a later goal-less
1724
+ // send doesn't inherit a stale goal. The judge runs on the primary
1725
+ // session client; auxSummaryClient remains dedicated to low-consequence
1726
+ // summaries/titles and retains defaults.auxText routing/fallback behavior.
1727
+ // Normalize the raw goal (string | GoalConfig) once at the run boundary;
1728
+ // everything inward uses the GoalConfig. normalizeGoal() returns undefined
1729
+ // when there's effectively no goal (empty objective).
1730
+ //
1731
+ // PERSISTENT GOAL (CC /goal style): a goal set on one send survives across
1732
+ // later sends and manual interrupts until met or cleared. Goal completion
1733
+ // is a high-consequence decision, so V1 routes it to the primary session
1734
+ // client, which is the model expected to interpret the supplied execution
1735
+ // evidence. defaults.auxText remains in force for summaries, titles and
1736
+ // other auxiliary work through auxSummaryClient.
1737
+ // Resolution:
1738
+ // 1. options.goal — this send explicitly sets/replaces the goal.
1739
+ // 2. session.state.goalLifecycle — a goal set on an earlier send.
1740
+ // 3. config.goal — engine-level default (rare; e.g. headless).
1741
+ // When (1) supplies a goal that differs from the stored one we REPLACE the
1742
+ // persisted active goal (one active goal per session) and announce it. A
1743
+ // bare send with no options.goal inherits the stored active goal so the
1744
+ // model keeps working toward it — that's what makes it persistent.
1745
+ const goalSlots = {
1746
+ getActiveRuntimeGoal: () => this.activeRuntimeGoal,
1747
+ setActiveRuntimeGoal: (g) => {
1748
+ this.activeRuntimeGoal = g;
1749
+ },
1750
+ getActivePersistedRunGoal: () => this.activePersistedRunGoal,
1751
+ setActivePersistedRunGoal: (g) => {
1752
+ this.activePersistedRunGoal = g;
1753
+ },
1754
+ getActiveGoalHook: () => this.activeGoalHook,
1755
+ setActiveGoalHook: (h) => {
1756
+ this.activeGoalHook = h;
1757
+ },
1758
+ setActiveGoalHookAttached: (a) => {
1759
+ this.activeGoalHookAttached = a;
1760
+ },
1761
+ };
1762
+ const { normalizedGoal, persistedRunGoal } = resolveRunGoal({
1763
+ options,
1764
+ session,
1765
+ sessionManager: this.sessionManager,
1766
+ configGoal: this.config.goal,
1767
+ isSubAgent: this.config.isSubAgent === true,
1768
+ sid,
1769
+ onStream: options?.onStream,
1770
+ });
1771
+ let latestGoalJudgeContext;
1772
+ const goalHookHandler = armRunGoalHook({
1773
+ slots: goalSlots,
1774
+ hooks: this.hooks,
1775
+ llmClient,
1776
+ isSubAgent: this.config.isSubAgent === true,
1777
+ normalizedGoal,
1778
+ persistedRunGoal,
1779
+ session,
1780
+ sessionManager: this.sessionManager,
1781
+ persistGoalTerminal: (state, goal, reason) => this.persistGoalTerminal(state, goal, reason),
1782
+ getJudgeContext: () => latestGoalJudgeContext,
1783
+ recordCumulativeUsage,
1784
+ recordGoalJudgeUsage: (usage) => turnLoop.recordGoalJudgeUsage(usage),
1785
+ });
1786
+ turnLoop = this.buildTurnLoop({
1787
+ modelFacade,
1788
+ toolExecutor,
1789
+ contextManager,
1790
+ session,
1791
+ fullSystemPrompt,
1792
+ toolDefs,
1793
+ sid,
1794
+ options,
1795
+ cwd,
1796
+ claimClientMessageId,
1797
+ releaseClientMessageId,
1798
+ toolCtx,
1799
+ persistedRunGoal,
1800
+ goalHookHandler,
1801
+ normalizedGoal,
1802
+ freshImageMessage,
1803
+ dynamicContextMsg,
1804
+ usageBaseline,
1805
+ getRunUsage,
1806
+ recordCumulativeUsage,
1807
+ publishGoalJudgeContext: (context) => {
1808
+ latestGoalJudgeContext = context;
1809
+ },
1810
+ });
1811
+ toolCtx.recordBilledUsage = recordExternalBilledUsage;
1812
+ // Expose this run's loop for mid-run extension (TODO 3.1). Top-level only —
1813
+ // a sub-agent's loop is its own concern and isn't user-extendable.
1814
+ if (this.config.isSubAgent !== true)
1815
+ this.activeTurnLoop = turnLoop;
1816
+ // Expose this run's session bundle so a mid-run clearGoal() wipes the goal
1817
+ // on the very instance this loop keeps saving (see field doc). Top-level
1818
+ // only — sub-agents don't carry user-clearable persistent goals.
1819
+ if (this.config.isSubAgent !== true)
1820
+ this.activeRunSession = session;
1821
+ const applyGoalTermination = createGoalTerminationApplier({
1822
+ slots: goalSlots,
1823
+ hooks: this.hooks,
1824
+ session,
1825
+ persistedRunGoal,
1826
+ goalHookHandler,
1827
+ persistGoalTerminalOutcome: (state, goal, t) => this.persistGoalTerminalOutcome(state, goal, t),
1828
+ readActiveGoal: (s) => this.sessionManager.readActiveGoal(s),
1829
+ onStream: options?.onStream,
1830
+ });
1831
+ return {
1832
+ turnLoop,
1833
+ applyGoalTermination,
1834
+ goalHookHandler,
1835
+ fileHistoryHook,
1836
+ getRunUsage,
1837
+ recordExternalBilledUsage,
1838
+ accounting,
1839
+ usageBaseline,
1840
+ };
1841
+ }
1842
+ /**
1843
+ * Record the session start and fire the once-per-run `on_session_start` and
1844
+ * per-turn `user_prompt_submit` / `agent_direction_submit` hooks, applying any
1845
+ * `updatedPrompt` rewrite in place on `messages`. Extracted verbatim from the
1846
+ * {@link runExclusive} skeleton; returns the combined hook-injected messages
1847
+ * for {@link assembleRunMessages}.
1848
+ */
1849
+ async runSessionStartHooks(args) {
1850
+ const { session, task, cwd, runPermissionMode, resumedFromDisk, options, taskText, messages } = args;
1851
+ recordSessionStart(session.state.sessionId, {
1852
+ // Strip <codeshell-image> base64 payloads before they reach
1853
+ // <repo>/log/. Reader still sees the marker + byte count, just
1854
+ // not the bytes. Transcript persistence keeps the full payload.
1855
+ task: sanitizeTaskString(task),
1856
+ cwd,
1857
+ model: this.config.llm.model,
1858
+ provider: this.config.llm.provider,
1859
+ permissionMode: runPermissionMode,
1860
+ resumed: resumedFromDisk,
1861
+ });
1862
+ // Session-level hook: fired once per Engine.run() entry, regardless of
1863
+ // cold-start vs resume. Handlers can return `messages` to inject a
1864
+ // <system-reminder> at the head of the conversation (between
1865
+ // userContext and the new user prompt). Used by the built-in
1866
+ // superpowers injector to surface the `using-superpowers` ruleset.
1867
+ const sessionStartHook = await this.emitHook("on_session_start", {
1868
+ sessionId: session.state.sessionId,
1869
+ cwd,
1870
+ resumed: resumedFromDisk,
1871
+ source: resumedFromDisk ? "resume" : "startup",
1872
+ }, options?.signal);
1873
+ // Per-turn hook: fired every time a new user prompt enters the loop.
1874
+ // Equivalent to CC's UserPromptSubmit. Handlers can inject lightweight
1875
+ // reminders that should accompany each user turn (e.g. "skills
1876
+ // available — check before acting").
1877
+ const promptSubmitHook = await this.emitHook(options?.agentDirection ? "agent_direction_submit" : "user_prompt_submit", {
1878
+ sessionId: session.state.sessionId,
1879
+ // Pass the text-only portion. Handlers reading the prompt for keyword
1880
+ // detection / classification (e.g. superpowers' "did the user ask
1881
+ // about X?") don't gain anything from megabytes of base64 inlined here,
1882
+ // and silently leaking attachment bytes through hooks is the kind of
1883
+ // exfiltration risk a curious user-installed shell hook shouldn't carry.
1884
+ prompt: taskText,
1885
+ resumed: resumedFromDisk,
1886
+ ...(options?.agentDirection
1887
+ ? {
1888
+ source: "agent-direction",
1889
+ authority: "agent",
1890
+ envelopeIds: options.agentDirection.envelopeIds,
1891
+ correlationIds: options.agentDirection.correlationIds,
1892
+ }
1893
+ : {}),
1894
+ }, options?.signal);
1895
+ // updatedPrompt: handler rewrote the user's prompt text. Replace the
1896
+ // last user message we just pushed (cold-start: line ~511; resume:
1897
+ // line ~500). Original prompt is in the transcript already — we log
1898
+ // the rewrite so audit chains know a hook touched user input.
1899
+ if (typeof promptSubmitHook.updatedPrompt === "string") {
1900
+ const lastIdx = messages.length - 1;
1901
+ const last = messages[lastIdx];
1902
+ if (last && last.role === "user" && typeof last.content === "string") {
1903
+ logger.info("hook.updated_prompt", {
1904
+ sessionId: session.state.sessionId,
1905
+ originalChars: last.content.length,
1906
+ updatedChars: promptSubmitHook.updatedPrompt.length,
1907
+ });
1908
+ messages[lastIdx] = { role: "user", content: promptSubmitHook.updatedPrompt };
1909
+ }
1910
+ }
1911
+ return [...(sessionStartHook.messages ?? []), ...(promptSubmitHook.messages ?? [])];
1912
+ }
1913
+ /**
1914
+ * Resolve goal visibility, disabled skill/plugin lists, build the
1915
+ * {@link PromptComposer}, connect MCP, and assemble the visibility-filtered
1916
+ * tool defs for this run. Extracted verbatim from the {@link runExclusive}
1917
+ * skeleton; the intermediate visibility/disabled/mcp values are fully local to
1918
+ * this method — only the composer and tool defs cross back out.
1919
+ */
1920
+ async wireRunTooling(args) {
1921
+ const { options, session, cwd, toolCtx, profile, profileParams, runWorkspaceProfile, profileMemoryDir, sessionProfileOverrides, runPlanMode, } = args;
1922
+ const visibilityExplicitGoal = normalizeGoal(options?.goal);
1923
+ const visibilityLifecycle = session.state.goalLifecycle;
1924
+ const visibilityStoredGoal = visibilityLifecycle && isGoalLifecycleCurrent(visibilityLifecycle)
1925
+ ? goalConfigFromLifecycle(visibilityLifecycle)
1926
+ : undefined;
1927
+ const visibilityDefaultGoal = normalizeGoal(this.config.goal);
1928
+ const hasRunnableGoal = this.config.isSubAgent !== true &&
1929
+ ((visibilityExplicitGoal !== undefined && visibilityExplicitGoal.paused !== true) ||
1930
+ (visibilityStoredGoal !== undefined && visibilityStoredGoal.paused !== true) ||
1931
+ (visibilityDefaultGoal !== undefined && visibilityDefaultGoal.paused !== true));
1932
+ const { disabledSkills, disabledPlugins } = this.readDisabledLists(cwd, sessionProfileOverrides);
1933
+ const promptComposer = new PromptComposer(buildPromptComposerConfig({
1934
+ cwd,
1935
+ model: this.config.llm.model,
1936
+ preset: this.preset,
1937
+ customSystemPrompt: this.config.customSystemPrompt,
1938
+ appendSystemPrompt: [this.config.appendSystemPrompt, profile?.systemPromptAppend]
1939
+ .filter(Boolean)
1940
+ .join("\n\n") || undefined,
1941
+ responseLanguage: this.config.responseLanguage,
1942
+ userProfile: this.config.userProfile,
1943
+ workspaceProfile: runWorkspaceProfile,
1944
+ // Read from the Session's own persisted state, so every turn — not just
1945
+ // the first — carries the standing brief.
1946
+ sessionBrief: session.state.sessionBrief,
1947
+ profileMemoryDir,
1948
+ instructionCompatFileNames: compatFileNamesFrom(this.config.instructions),
1949
+ instructionBoundaryFinder: (scanCwd) => resolveInstructionBoundary(scanCwd, this.capabilities),
1950
+ disabledSkills,
1951
+ disabledPlugins,
1952
+ skillAllowlist: this.config.skillAllowlist,
1953
+ memoriesMaxAgeDays: this.readMemoriesConfig()?.maxAge,
1954
+ goalToolState: { hasGoal: hasRunnableGoal },
1955
+ capabilityPromptSections: this.capabilityPromptSections,
1956
+ dynamicContextProviders: this.capabilityDynamicContextProviders,
1957
+ getSettingsManager: () => this.getSettingsManager(),
1958
+ toolCatalog: this.toolCatalog,
1959
+ }));
1960
+ const mcpServers = this.config.mcpServers ?? {};
1961
+ const mcpDisabled = profile?.disableMcp === true;
1962
+ await connectRunMcp({
1963
+ mcpServers,
1964
+ mcpDisabled,
1965
+ getManager: () => this.mcpManager,
1966
+ setManager: (m) => {
1967
+ this.mcpManager = m;
1968
+ },
1969
+ runtimePool: this.runtime?.mcpPool,
1970
+ toolRegistry: this.toolRegistry,
1971
+ engineForConnect: this,
1972
+ emitNotificationHook: (payload) => {
1973
+ void this.emitHook("notification", payload);
1974
+ },
1975
+ });
1976
+ // Parallelize slow initialization:
1977
+ // 1. createLLMClient — network handshake (started earlier)
1978
+ // 2. buildSystemPrompt — cacheable prompt assembled from generic sections
1979
+ // 3. buildSystemContext — reads environment context
1980
+ // Inject the live available-agent-types listing into the Agent tool's
1981
+ // description. The registry is per-engine (loaded from .code-shell/agents
1982
+ // for this cwd), so it can't live in the static tool def — without this
1983
+ // the model never learns the reusable roles exist and spawns nameless
1984
+ // ad-hoc agents instead (the Core A/B/C incident).
1985
+ // The Agent tool is always available: with configured roles, an omitted
1986
+ // agent_type falls back to one of them (see resolveAgentTypeOverrides); with
1987
+ // no roles configured it runs a true ephemeral agent, so workflows that need
1988
+ // sub-agents (e.g. superpowers) work in any project.
1989
+ // Availability guard (tool-visibility): a gated builtin (WebSearch needs a
1990
+ // search provider, GenerateImage needs an OpenAI provider) is hidden from
1991
+ // the toolDefs the model sees when its credential isn't configured for this
1992
+ // cwd. Recomputed every message, so configuring a key takes effect on the
1993
+ // NEXT message without a restart. Tools with no guard entry are always kept.
1994
+ const toolDefs = assembleRunToolDefs({
2200
1995
  toolRegistry: this.toolRegistry,
2201
- toolContext: this.buildToolContext(),
2202
- projectDir: opts.projectDir,
2203
- sessionId: opts.sessionId,
1996
+ toolCtx,
1997
+ guardCwd: toolCtx.cwd,
1998
+ hasRunnableGoal,
1999
+ settingsScope: this.config.settingsScope ?? "project",
2000
+ builtinToolHost: this.config.builtinToolHost,
2001
+ isSubAgent: this.config.isSubAgent === true,
2002
+ behaviorProfileId: profile?.id ?? options?.behaviorMode,
2003
+ profileMeta: profile?.buildVisibilityMeta?.(profileParams),
2004
+ builtinOverride: this.readBuiltinOverride(toolCtx.cwd, sessionProfileOverrides),
2005
+ mcpServers: this.config.mcpServers ?? {},
2006
+ mcpDisabled,
2007
+ featureFlags: this.readFeatureFlags(),
2008
+ toolGuards: this.toolGuards,
2009
+ toolRewriters: this.toolRewriters,
2010
+ toolFeatureFlags: TOOL_FEATURE_FLAGS,
2011
+ applyBuiltinOverrideVisibility,
2012
+ profileAllowedToolNames: profile?.allowedToolNames,
2013
+ runPlanMode,
2014
+ });
2015
+ return { promptComposer, toolDefs };
2016
+ }
2017
+ /**
2018
+ * Assemble the run-scoped {@link TurnLoop} (its dependency object + option
2019
+ * object) from the per-run locals gathered in {@link runExclusive}. Extracted
2020
+ * verbatim from the orchestration skeleton so `runExclusive` stays a readable
2021
+ * sequence of phase calls; the buffered-compaction `let` lives entirely inside
2022
+ * here now, and the only value crossing back out is the goal-judge context,
2023
+ * published through the `publishGoalJudgeContext` callback.
2024
+ */
2025
+ buildTurnLoop(args) {
2026
+ const { modelFacade, toolExecutor, contextManager, session, fullSystemPrompt, toolDefs, sid, options, cwd, claimClientMessageId, releaseClientMessageId, toolCtx, persistedRunGoal, goalHookHandler, normalizedGoal, freshImageMessage, dynamicContextMsg, usageBaseline, getRunUsage, recordCumulativeUsage, publishGoalJudgeContext, } = args;
2027
+ // Surface compaction events to the UI so the user knows when context was trimmed.
2028
+ // Buffer the most recent event so TurnLoop can drain it and emit the
2029
+ // post_compact hook on the next turn (ContextManager itself doesn't
2030
+ // know about HookRegistry — the buffer is the seam).
2031
+ let pendingCompactInfo = null;
2032
+ contextManager.setOnCompact((info) => {
2033
+ pendingCompactInfo = info;
2034
+ options?.onStream?.({ type: "context_compact", ...info });
2035
+ });
2036
+ // Run turn loop
2037
+ const turnLoop = new TurnLoop({
2038
+ model: modelFacade,
2039
+ toolExecutor,
2040
+ contextManager,
2041
+ hooks: this.hooks,
2042
+ transcript: session.transcript,
2043
+ systemPrompt: fullSystemPrompt,
2044
+ tools: toolDefs,
2045
+ sessionId: sid,
2046
+ isSubAgent: this.config.isSubAgent === true,
2047
+ consumePendingCompactInfo: () => {
2048
+ const info = pendingCompactInfo;
2049
+ pendingCompactInfo = null;
2050
+ return info;
2051
+ },
2052
+ consumeSteer: (source) => this.consumeSteer(sid, source),
2053
+ consumeAgentDirections: this.config.isSubAgent === true
2054
+ ? () => notificationQueue.drain(sid, (envelope) => envelope.kind === "direction" &&
2055
+ envelope.runtimeGeneration === options?.runtimeGeneration)
2056
+ : undefined,
2057
+ onAgentControlState: this.config.isSubAgent === true
2058
+ ? (state) => {
2059
+ this.agentControlStateListener?.(state);
2060
+ if (state === "model") {
2061
+ options?.onAgentProgress?.({ type: "phase", phase: "model" });
2062
+ }
2063
+ else if (state === "tool-batch") {
2064
+ options?.onAgentProgress?.({ type: "phase", phase: "tool" });
2065
+ }
2066
+ }
2067
+ : undefined,
2068
+ onAgentDirectionsDelivered: this.config.isSubAgent === true
2069
+ ? (envelopeIds) => this.agentDirectionsDeliveredListener?.(envelopeIds)
2070
+ : undefined,
2071
+ restoreSteer: (items) => this.restoreSteer(sid, items),
2072
+ buildSteerUserMessageContent: async (item) => {
2073
+ const steerImageInput = await prepareRunImageInput({
2074
+ task: item.text,
2075
+ cwd,
2076
+ llm: this.config.llm,
2077
+ sessionId: sid,
2078
+ attachments: item.attachments,
2079
+ });
2080
+ if (!steerImageInput.ok) {
2081
+ throw new Error(steerImageInput.result.text);
2082
+ }
2083
+ return buildRunUserMessageContent(steerImageInput.parsedTask, cwd, steerImageInput.taskText);
2084
+ },
2085
+ claimClientMessageId: (clientMessageId, source) => claimClientMessageId(session, clientMessageId, source),
2086
+ releaseClientMessageId,
2087
+ setOriginClientMessageId: (clientMessageId) => {
2088
+ toolCtx.originClientMessageId = clientMessageId;
2089
+ },
2090
+ recordCumulativeUsage,
2091
+ onAgentUsage: (usage) => options?.onAgentProgress?.({ type: "usage", usage }),
2092
+ recordCacheReadDiagnostics: (sample) => {
2093
+ this.recordCacheReadDiagnostics(sid, sample);
2094
+ },
2095
+ recordContextUsageAnchor: (anchor) => {
2096
+ session.state.contextUsageAnchor = {
2097
+ ...anchor,
2098
+ provider: this.config.llm.provider,
2099
+ model: this.config.llm.model,
2100
+ };
2101
+ },
2102
+ // Clear the persisted goal for a self-reported completion / confirmed
2103
+ // cancel. Clears the in-RAM session's activeGoal (so THIS run's later
2104
+ // turns don't re-arm) AND persists it, and drops the in-flight stop
2105
+ // hook so nothing re-blocks the stop we're about to return.
2106
+ clearPersistedGoal: (reason) => {
2107
+ const runGoal = this.activePersistedRunGoal ?? persistedRunGoal;
2108
+ if (runGoal && !this.persistGoalTerminal(session.state, runGoal, reason)) {
2109
+ return false;
2110
+ }
2111
+ if (goalHookHandler) {
2112
+ this.hooks.unregister("on_stop", goalHookHandler);
2113
+ if (this.activeGoalHook === goalHookHandler) {
2114
+ this.activeGoalHook = null;
2115
+ this.activeGoalHookAttached = false;
2116
+ this.activeRuntimeGoal = null;
2117
+ this.activePersistedRunGoal = null;
2118
+ }
2119
+ }
2120
+ return true;
2121
+ },
2122
+ publishGoalJudgeContext: (context) => {
2123
+ publishGoalJudgeContext(context);
2124
+ },
2125
+ // A background_notification yield parks the run until the Session is
2126
+ // woken by the completion notification. Only a top-level interactive
2127
+ // session can be woken (server refuses headless; sub-agent sessions
2128
+ // are not in chatManager) — everywhere else honouring the yield would
2129
+ // end the run early and orphan the background result, so the loop
2130
+ // never sees the request and the model keeps its full turn.
2131
+ ...(this.isHeadless() || this.config.isSubAgent === true
2132
+ ? {}
2133
+ : {
2134
+ peekToolRunYield: () => toolCtx.runYield?.peek?.(),
2135
+ consumeToolRunYield: () => toolCtx.runYield?.consume(),
2136
+ }),
2137
+ ctxOverheadStore: {
2138
+ get: (s) => this.ctxOverheadBySid.get(s) ?? 0,
2139
+ set: (s, n) => {
2140
+ this.ctxOverheadBySid.set(s, n);
2141
+ },
2142
+ },
2143
+ }, {
2144
+ // Goal mode raises the turn ceiling: an unattended goal run keeps
2145
+ // getting re-blocked by the stop-hook until it's done, and the 100
2146
+ // interactive default would silently truncate a long objective. The
2147
+ // real backstops are the goal token/time budgets + maxStopBlocks.
2148
+ maxTurns: resolveMaxTurns(this.config.maxTurns, normalizedGoal),
2149
+ // Consecutive stop-block cap: config override > goal.maxStopBlocks >
2150
+ // GOAL_DEFAULT_MAX_STOP_BLOCKS(25). The old hardcoded 8 was too tight
2151
+ // for complex goals that legitimately get re-blocked while advancing.
2152
+ maxStopBlocks: resolveMaxStopBlocks(this.config.maxStopBlocks, normalizedGoal),
2153
+ // 25 (was 10): modern models routinely batch >10 parallel tool calls
2154
+ // (e.g. reading a dozen files at once). At 10 the excess was silently
2155
+ // dropped; the turn loop now also warns the model when it caps, but a
2156
+ // higher ceiling avoids the round-trip in the common case. (B-3)
2157
+ maxToolCallsPerTurn: this.config.maxToolCallsPerTurn ?? 25,
2158
+ onStream: options?.onStream,
2159
+ signal: options?.signal,
2160
+ freshImageMessages: freshImageMessage ? [freshImageMessage] : undefined,
2161
+ volatileContextMessages: dynamicContextMsg ? [dynamicContextMsg] : undefined,
2162
+ // Goal mode: the active goal is surfaced to the on_stop handler via
2163
+ // ctx.data.goal; the GoalStopHook (registered above) judges it.
2164
+ goal: normalizedGoal,
2165
+ // Heartbeat: flush turnCount + tokens to state.json after every turn
2166
+ // so external observers (other CLI processes, /sid, the session list)
2167
+ // see live progress instead of a stale snapshot from the last
2168
+ // completed run.
2169
+ onTurnBoundary: (turnCount) => {
2170
+ session.state.turnCount = turnCount;
2171
+ // baseline + this run's running total (idempotent per boundary,
2172
+ // accumulates across runs; carries cacheRead/cacheCreation too).
2173
+ session.state.tokenUsage = foldRunUsage(usageBaseline, getRunUsage());
2174
+ // Surface the whole-session monotonic cache counts to the UI.
2175
+ // Separate from turn-loop's authoritative per-response emit (which
2176
+ // drives the live context reading and single-turn metric).
2177
+ const cumulative = normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage);
2178
+ const cumulativeHitRate = cumulativeCacheHitRate(cumulative);
2179
+ options?.onStream?.({
2180
+ type: "usage_update",
2181
+ promptTokens: cumulative.cumulativePromptTokens,
2182
+ promptTokensSource: "session_cumulative",
2183
+ promptTokensConfidence: "high",
2184
+ cumulativePromptTokens: cumulative.cumulativePromptTokens,
2185
+ cumulativeCacheReadTokens: cumulative.cumulativeCacheReadTokens,
2186
+ cumulativeCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
2187
+ ...(cumulativeHitRate !== undefined
2188
+ ? { cumulativeCacheHitRate: cumulativeHitRate }
2189
+ : {}),
2190
+ sessionPromptTokens: cumulative.cumulativePromptTokens,
2191
+ sessionCacheReadTokens: cumulative.cumulativeCacheReadTokens,
2192
+ sessionCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
2193
+ });
2194
+ if (this.config.costStore) {
2195
+ session.state.costState = this.config.costStore.serialize();
2196
+ }
2197
+ this.persistRunProgress(session.state);
2198
+ },
2204
2199
  });
2205
- return ran;
2200
+ return turnLoop;
2201
+ }
2202
+ buildSummarizeFn(auxSummaryClient, recordCumulativeUsage) {
2203
+ return this.auxiliaryPipeline.buildSummarizeFn(auxSummaryClient, recordCumulativeUsage);
2204
+ }
2205
+ async resolveAuxClient(fallback) {
2206
+ return this.auxiliaryPipeline.resolveAuxClient(fallback);
2207
+ }
2208
+ async runMemoryPipeline(transcript, sessionId, cwd, primaryClient, recordBilledUsage) {
2209
+ return this.auxiliaryPipeline.runMemoryPipeline(transcript, sessionId, cwd, primaryClient, recordBilledUsage);
2206
2210
  }
2207
2211
  getToolRegistry() {
2208
2212
  return this.toolRegistry;
2209
2213
  }
2214
+ /** Registry suitable for constructing EngineRuntime; excludes local capabilities. */
2215
+ getRuntimeToolRegistry() {
2216
+ return this.runtimeToolRegistry;
2217
+ }
2210
2218
  /**
2211
2219
  * Switch the active model by pool key. Takes effect on the next run() call.
2212
2220
  * Returns the new model entry.
@@ -2216,14 +2224,18 @@ export class Engine {
2216
2224
  * only live in memory and every restart reverts to the previously persisted
2217
2225
  * defaults.text.
2218
2226
  */
2219
- switchModel(key) {
2227
+ switchModel(key, opts) {
2220
2228
  const entry = this.modelPool.switch(key);
2221
2229
  // LLMConfig is pure model identity now — rotate it wholesale. Cross-model
2222
2230
  // runtime knobs (temperature/timeout/retryMaxAttempts/imageDetail) live on
2223
2231
  // this.config.clientDefaults and survive the switch untouched.
2224
2232
  const nextLlm = this.modelPool.toLLMConfig(entry);
2225
2233
  this.config = { ...this.config, llm: nextLlm };
2226
- this.persistActiveModel(entry);
2234
+ // persist: false is the per-session path (ChatSession) — switching one
2235
+ // session's model must not rewrite settings.defaults.text, the boot
2236
+ // default every future session inherits.
2237
+ if (opts?.persist !== false)
2238
+ this.persistActiveModel(entry);
2227
2239
  return entry;
2228
2240
  }
2229
2241
  /**
@@ -2240,9 +2252,14 @@ export class Engine {
2240
2252
  // Persist to disk so a reload / next run picks up the reset.
2241
2253
  if (this.sessionManager.exists(sessionId)) {
2242
2254
  try {
2243
- const bundle = this.sessionManager.resume(sessionId);
2244
- bundle.state.tokenUsage = { ...zero };
2245
- this.sessionManager.saveState(bundle.state);
2255
+ if (this.activeRunSession?.state.sessionId === sessionId) {
2256
+ this.sessionManager.saveStateOrUpdateFields(this.activeRunSession.state, {
2257
+ tokenUsage: { ...zero },
2258
+ });
2259
+ }
2260
+ else {
2261
+ this.sessionManager.updateSessionState(sessionId, { tokenUsage: { ...zero } });
2262
+ }
2246
2263
  }
2247
2264
  catch {
2248
2265
  // Session not resumable (never persisted yet) — the in-memory reset
@@ -2322,6 +2339,94 @@ export class Engine {
2322
2339
  getSessionManager() {
2323
2340
  return this.sessionManager;
2324
2341
  }
2342
+ /**
2343
+ * Apply a field-level disk update and rebase this Engine's matching live
2344
+ * bundle onto the returned revision so its next whole-state CAS can proceed.
2345
+ */
2346
+ updatePersistedSessionState(sessionId, partial) {
2347
+ const stateRevision = this.sessionManager.updateSessionState(sessionId, partial);
2348
+ if (this.activeRunSession?.state.sessionId !== sessionId)
2349
+ return;
2350
+ Object.assign(this.activeRunSession.state, partial, { stateRevision });
2351
+ }
2352
+ /**
2353
+ * Adopt the exact persisted snapshot that won a Goal-control CAS without
2354
+ * discarding counters which the current run has advanced since its previous
2355
+ * heartbeat. Merely copying the new stateRevision is unsafe: the next
2356
+ * whole-state save would then be allowed to publish stale title/workspace
2357
+ * metadata over a concurrent field-level writer.
2358
+ */
2359
+ rebaseActiveRunAfterGoalUpdate(live, persisted) {
2360
+ const runOwned = {
2361
+ status: live.status,
2362
+ summary: live.summary,
2363
+ tokenUsage: live.tokenUsage,
2364
+ contextUsageAnchor: live.contextUsageAnchor,
2365
+ cumulativePromptTokens: live.cumulativePromptTokens,
2366
+ cumulativeCacheReadTokens: live.cumulativeCacheReadTokens,
2367
+ cumulativeCacheCreationTokens: live.cumulativeCacheCreationTokens,
2368
+ turnCount: live.turnCount,
2369
+ turnSeq: live.turnSeq,
2370
+ completedThroughEventId: live.completedThroughEventId,
2371
+ completedSnapshotVersion: live.completedSnapshotVersion,
2372
+ invokedSkills: live.invokedSkills,
2373
+ costState: live.costState,
2374
+ };
2375
+ // Optional fields deleted by another writer must disappear locally too;
2376
+ // clear before assigning rather than leaving stale own-properties behind.
2377
+ for (const key of Object.keys(live)) {
2378
+ delete live[key];
2379
+ }
2380
+ Object.assign(live, persisted, runOwned);
2381
+ }
2382
+ persistGoalTerminal(state, goal, reason) {
2383
+ return this.persistGoalTerminalOutcome(state, goal, reason) !== "failed";
2384
+ }
2385
+ persistGoalTerminalOutcome(state, goal, reason) {
2386
+ const outcome = this.sessionManager.saveGoalTerminalOutcome(state, goal, reason);
2387
+ if (outcome === "failed") {
2388
+ logger.warn("session.goal_terminal_persist_failed", {
2389
+ sessionId: state.sessionId,
2390
+ goalId: goal.goalId,
2391
+ reason,
2392
+ });
2393
+ }
2394
+ return outcome;
2395
+ }
2396
+ persistFinalRunState(state) {
2397
+ const finalFields = {
2398
+ status: state.status,
2399
+ turnCount: state.turnCount,
2400
+ turnSeq: state.turnSeq,
2401
+ tokenUsage: state.tokenUsage,
2402
+ cumulativePromptTokens: state.cumulativePromptTokens,
2403
+ cumulativeCacheReadTokens: state.cumulativeCacheReadTokens,
2404
+ cumulativeCacheCreationTokens: state.cumulativeCacheCreationTokens,
2405
+ contextUsageAnchor: state.contextUsageAnchor,
2406
+ costState: state.costState,
2407
+ completedSnapshotVersion: state.completedSnapshotVersion,
2408
+ completedThroughEventId: state.completedThroughEventId,
2409
+ };
2410
+ if (!this.sessionManager.saveStateOrUpdateFields(state, finalFields)) {
2411
+ logger.warn("session.final_state_persist_failed", { sessionId: state.sessionId });
2412
+ }
2413
+ }
2414
+ persistRunProgress(state) {
2415
+ const progressFields = {
2416
+ status: state.status,
2417
+ turnCount: state.turnCount,
2418
+ turnSeq: state.turnSeq,
2419
+ tokenUsage: state.tokenUsage,
2420
+ cumulativePromptTokens: state.cumulativePromptTokens,
2421
+ cumulativeCacheReadTokens: state.cumulativeCacheReadTokens,
2422
+ cumulativeCacheCreationTokens: state.cumulativeCacheCreationTokens,
2423
+ contextUsageAnchor: state.contextUsageAnchor,
2424
+ costState: state.costState,
2425
+ };
2426
+ if (!this.sessionManager.saveStateOrUpdateFields(state, progressFields)) {
2427
+ logger.warn("session.run_progress_persist_failed", { sessionId: state.sessionId });
2428
+ }
2429
+ }
2325
2430
  getConfig() {
2326
2431
  return this.config;
2327
2432
  }
@@ -2347,7 +2452,7 @@ export class Engine {
2347
2452
  * the main user-visible preset effect and it IS hot. The toolRegistry's
2348
2453
  * builtin tool SET, however, is ctor-frozen (and may be shared via runtime):
2349
2454
  * it is NOT rebuilt here. So a preset change that alters the builtin tool set
2350
- * (e.g. general terminal-coding adds LSP/Brief) only takes effect on the
2455
+ * (e.g. switching to a capability-contributed preset adds tools) only takes effect on the
2351
2456
  * next session restart; we log a warning when that case is detected.
2352
2457
  *
2353
2458
  * disk-default-vs-slice caveat (#8): the patch carries pure DISK-default
@@ -2369,13 +2474,14 @@ export class Engine {
2369
2474
  // (rebuilt per turn from this.preset) reflects the new preset's system
2370
2475
  // prompt / behavior. Only when the preset actually changed.
2371
2476
  if (patch.preset !== undefined && patch.preset !== prevPresetName) {
2372
- const nextPreset = resolveAgentPreset(this.config.preset);
2477
+ const nextPreset = resolveAgentPreset(this.config.preset, this.capabilities);
2373
2478
  // The builtin tool SET is ctor-frozen and may be shared via runtime — we
2374
2479
  // do NOT rebuild it here. If the new preset implies a different builtin
2375
2480
  // tool set, that part of the change only lands on session restart.
2376
2481
  const prevTools = resolveBuiltinToolNames({
2377
2482
  preset: prevPresetName,
2378
2483
  host: this.config.builtinToolHost,
2484
+ capabilities: this.capabilities,
2379
2485
  })
2380
2486
  .slice()
2381
2487
  .sort()
@@ -2383,6 +2489,7 @@ export class Engine {
2383
2489
  const nextTools = resolveBuiltinToolNames({
2384
2490
  preset: nextPreset.name,
2385
2491
  host: this.config.builtinToolHost,
2492
+ capabilities: this.capabilities,
2386
2493
  })
2387
2494
  .slice()
2388
2495
  .sort()
@@ -2425,7 +2532,7 @@ export class Engine {
2425
2532
  * Read a session's persisted active goal WITHOUT resuming it (cheap — reads
2426
2533
  * only state.json via SessionManager.readActiveGoal). The desktop host calls
2427
2534
  * this on session load to re-surface the goal block + its Cancel button: a
2428
- * persistent goal lives only in state.activeGoal and is never replayed from
2535
+ * persistent goal lives only in state.goalLifecycle and is never replayed from
2429
2536
  * the transcript, so after a reload of an aborted goal run the UI would
2430
2537
  * otherwise show nothing (the "goal 还在但页面不显示、取消不了" bug). Returns
2431
2538
  * undefined when the session is unknown or has no active goal.
@@ -2433,43 +2540,185 @@ export class Engine {
2433
2540
  getGoal(sessionId) {
2434
2541
  return this.sessionManager.readActiveGoal(sessionId);
2435
2542
  }
2543
+ /** True when the current run was built with Goal prompt/tools and can resume in place. */
2544
+ canResumeGoalInPlace(sessionId) {
2545
+ return (this.activeTurnLoop !== null &&
2546
+ this.activeRunSession?.state.sessionId === sessionId &&
2547
+ this.activeRuntimeGoal !== null &&
2548
+ this.activeGoalHook !== null);
2549
+ }
2550
+ /**
2551
+ * Edit or pause/resume a persisted goal. Mid-run edits use the same step-gap
2552
+ * delivery seam as Steer: the current model/tool call is not aborted, and the
2553
+ * updated objective is injected before the next model step. Pausing detaches
2554
+ * the goal judge and stops this run before another model request is started.
2555
+ */
2556
+ updateGoal(sessionId, patch) {
2557
+ if (!sessionId || !this.sessionManager.exists(sessionId))
2558
+ return undefined;
2559
+ const live = this.activeRunSession?.state.sessionId === sessionId ? this.activeRunSession : null;
2560
+ const liveLifecycle = live?.state.goalLifecycle;
2561
+ const before = liveLifecycle && isGoalLifecycleCurrent(liveLifecycle)
2562
+ ? goalConfigFromLifecycle(liveLifecycle)
2563
+ : this.sessionManager.readActiveGoal(sessionId);
2564
+ if (!before)
2565
+ return undefined;
2566
+ if (patch.expectedGoalId !== undefined && before.goalId !== patch.expectedGoalId) {
2567
+ return undefined;
2568
+ }
2569
+ if (patch.expectedRevision !== undefined && (before.revision ?? 1) !== patch.expectedRevision) {
2570
+ return undefined;
2571
+ }
2572
+ const updated = this.sessionManager.updateActiveGoal(sessionId, {
2573
+ ...patch,
2574
+ expectedGoalId: patch.expectedGoalId ?? before.goalId,
2575
+ expectedRevision: patch.expectedRevision ?? before.revision ?? 1,
2576
+ });
2577
+ if (!updated)
2578
+ return undefined;
2579
+ const next = updated.goal;
2580
+ if (live) {
2581
+ this.rebaseActiveRunAfterGoalUpdate(live.state, updated.state);
2582
+ }
2583
+ const resumesDormantGoal = this.activeTurnLoop !== null &&
2584
+ live !== null &&
2585
+ before.paused === true &&
2586
+ next.paused !== true &&
2587
+ this.activeRuntimeGoal === null &&
2588
+ this.activeGoalHook !== null;
2589
+ if (resumesDormantGoal) {
2590
+ // This ordinary run was constructed while the persisted Goal was paused:
2591
+ // its system prompt, visible tools and ToolContext are goal-less and
2592
+ // cannot be safely hot-swapped. Stop it at the next step boundary; the
2593
+ // protocol's conditional resume turn will rebuild a fully Goal-capable
2594
+ // run from the now-unpaused persisted state.
2595
+ this.activeTurnLoop.updateGoal(undefined);
2596
+ }
2597
+ const controlsThisRun = this.activeTurnLoop !== null &&
2598
+ live !== null &&
2599
+ isSameGoalVersion(before, this.activeRuntimeGoal ?? undefined);
2600
+ if (controlsThisRun) {
2601
+ if (this.activeRuntimeGoal) {
2602
+ Object.assign(this.activeRuntimeGoal, next);
2603
+ if (next.paused !== true)
2604
+ delete this.activeRuntimeGoal.paused;
2605
+ }
2606
+ if (this.activePersistedRunGoal) {
2607
+ Object.assign(this.activePersistedRunGoal, next);
2608
+ if (next.paused !== true)
2609
+ delete this.activePersistedRunGoal.paused;
2610
+ }
2611
+ if (next.paused === true) {
2612
+ if (this.activeGoalHook && this.activeGoalHookAttached) {
2613
+ this.hooks.unregister("on_stop", this.activeGoalHook);
2614
+ this.activeGoalHookAttached = false;
2615
+ }
2616
+ this.activeTurnLoop.updateGoal(undefined);
2617
+ }
2618
+ else {
2619
+ const objectiveChanged = before.objective !== next.objective;
2620
+ const resumed = before.paused === true;
2621
+ this.activeTurnLoop.updateGoal(next, objectiveChanged
2622
+ ? `目标已编辑。新的目标:${next.objective}`
2623
+ : resumed
2624
+ ? `目标已恢复:${next.objective}`
2625
+ : undefined, {
2626
+ maxTurns: resolveMaxTurns(this.config.maxTurns, next),
2627
+ maxStopBlocks: resolveMaxStopBlocks(this.config.maxStopBlocks, next),
2628
+ });
2629
+ if (this.activeGoalHook && !this.activeGoalHookAttached) {
2630
+ this.hooks.register("on_stop", this.activeGoalHook, 0, "goal-stop");
2631
+ this.activeGoalHookAttached = true;
2632
+ }
2633
+ }
2634
+ }
2635
+ return next;
2636
+ }
2436
2637
  /**
2437
2638
  * Clear a session's persisted active goal (CC `/goal clear`). Works whether
2438
2639
  * the session is idle or its goal run is in flight: it wipes
2439
- * `state.activeGoal` (so the next bare send won't re-inherit it) and, if a
2640
+ * `state.goalLifecycle` (so the next bare send won't re-inherit it) and, if a
2440
2641
  * goal hook is currently registered for this engine, unregisters it so an
2441
2642
  * in-flight run can stop instead of being re-blocked by the now-cleared goal.
2442
2643
  * Returns true if a goal was actually cleared. Idempotent — clearing a
2443
2644
  * session with no active goal is a no-op returning false.
2444
2645
  */
2445
- clearGoal(sessionId) {
2646
+ clearGoal(sessionId, expected) {
2446
2647
  if (!this.sessionManager.exists(sessionId))
2447
2648
  return false;
2448
- // Prefer the LIVE run's bundle when it's this session: clearing its
2449
- // in-RAM state.activeGoal is what stops the run loop from writing the goal
2450
- // back on its next saveState. A fresh resume() copy would be cleared and
2451
- // persisted, but the running loop's own detached bundle still holds the
2452
- // goal and resurrects it — the stale-write-back race. Falls back to a
2453
- // resumed copy when no run of this session is currently in flight.
2649
+ // Prefer the LIVE run's bundle when it's this session so the domain update
2650
+ // rebases the exact state object used by subsequent progress writes.
2454
2651
  const live = this.activeRunSession && this.activeRunSession.state.sessionId === sessionId
2455
2652
  ? this.activeRunSession
2456
2653
  : null;
2457
2654
  const session = live ?? this.sessionManager.resume(sessionId);
2458
- const had = session.state.activeGoal !== undefined;
2655
+ const lifecycle = session.state.goalLifecycle;
2656
+ const currentGoal = lifecycle && isGoalLifecycleCurrent(lifecycle)
2657
+ ? goalConfigFromLifecycle(lifecycle)
2658
+ : undefined;
2659
+ const had = currentGoal !== undefined;
2660
+ let controlsThisRun = false;
2661
+ if (had &&
2662
+ ((expected?.goalId !== undefined && currentGoal?.goalId !== expected.goalId) ||
2663
+ (expected?.revision !== undefined && (currentGoal?.revision ?? 1) !== expected.revision))) {
2664
+ return false;
2665
+ }
2459
2666
  if (had) {
2460
- session.state.activeGoal = undefined;
2461
- this.sessionManager.saveState(session.state);
2667
+ const clearedGoal = currentGoal;
2668
+ controlsThisRun =
2669
+ this.activeTurnLoop !== null &&
2670
+ live !== null &&
2671
+ isSameGoalVersion(clearedGoal, this.activeRuntimeGoal ?? undefined);
2672
+ if (!this.persistGoalTerminal(session.state, clearedGoal, "user_cleared"))
2673
+ return false;
2674
+ // A cross-writer edit may have won between the expected-version check
2675
+ // above and saveGoalTerminal's conflict merge. In that case the old
2676
+ // revision's tombstone is durable but the newer active revision remains;
2677
+ // report a stale delete, and never stop/detach the run that owns it.
2678
+ if (session.state.goalLifecycle && isGoalLifecycleCurrent(session.state.goalLifecycle)) {
2679
+ return false;
2680
+ }
2681
+ // A paused Goal inherited by an ordinary run is only dormant persisted
2682
+ // state; deleting it must not cancel that unrelated conversation.
2683
+ if (controlsThisRun)
2684
+ this.activeTurnLoop.updateGoal(undefined);
2462
2685
  }
2463
2686
  // If THIS session's goal run is in flight, drop its stop hook so the
2464
2687
  // current run can terminate (the closure-held goal would otherwise keep
2465
2688
  // re-blocking). The run's own `finally` also unregisters; double-unregister
2466
2689
  // is safe (set delete is idempotent).
2467
- if (this.activeGoalHook && this.lastSessionId === sessionId) {
2468
- this.hooks.unregister("on_stop", this.activeGoalHook);
2690
+ if (had &&
2691
+ this.activeGoalHook &&
2692
+ this.lastSessionId === sessionId &&
2693
+ (controlsThisRun || this.activeRuntimeGoal === null)) {
2694
+ if (this.activeGoalHookAttached)
2695
+ this.hooks.unregister("on_stop", this.activeGoalHook);
2469
2696
  this.activeGoalHook = null;
2697
+ this.activeGoalHookAttached = false;
2698
+ this.activeRuntimeGoal = null;
2699
+ this.activePersistedRunGoal = null;
2470
2700
  }
2471
2701
  return had;
2472
2702
  }
2703
+ /**
2704
+ * Persist a workspace pointer through the Engine that owns the live bundle.
2705
+ * Host-side workspace actions use this RPC-facing seam so advancing the disk
2706
+ * revision also rebases the active run before its next progress write.
2707
+ */
2708
+ setSessionWorkspace(sessionId, workspace) {
2709
+ if (!sessionId || !this.sessionManager.exists(sessionId))
2710
+ return null;
2711
+ try {
2712
+ const stateRevision = this.sessionManager.setSessionWorkspace(sessionId, workspace);
2713
+ if (this.activeRunSession?.state.sessionId === sessionId) {
2714
+ Object.assign(this.activeRunSession.state, { workspace, stateRevision });
2715
+ }
2716
+ return workspace;
2717
+ }
2718
+ catch {
2719
+ return null;
2720
+ }
2721
+ }
2473
2722
  /**
2474
2723
  * Reset a session's workspace pointer back to its main root. If the session is
2475
2724
  * actively running, mutate that live SessionBundle first so the run's next
@@ -2478,32 +2727,13 @@ export class Engine {
2478
2727
  releaseSessionWorkspace(sessionId) {
2479
2728
  if (!sessionId || !this.sessionManager.exists(sessionId))
2480
2729
  return null;
2481
- const mainRoot = this.sessionManager.readCwd(sessionId) ??
2730
+ const mainRoot = this.sessionManager.readSessionMainRoot(sessionId) ??
2482
2731
  (this.activeRunSession?.state.sessionId === sessionId
2483
2732
  ? this.activeRunSession.state.cwd
2484
2733
  : undefined);
2485
2734
  if (!mainRoot)
2486
2735
  return null;
2487
- const workspace = { root: mainRoot, kind: "main" };
2488
- if (this.activeRunSession?.state.sessionId === sessionId) {
2489
- this.activeRunSession.state.workspace = workspace;
2490
- }
2491
- try {
2492
- const bundle = this.activeRunSession?.state.sessionId === sessionId
2493
- ? this.activeRunSession
2494
- : this.sessionManager.resume(sessionId);
2495
- bundle.state.workspace = workspace;
2496
- this.sessionManager.saveState(bundle.state);
2497
- }
2498
- catch {
2499
- try {
2500
- this.sessionManager.setSessionWorkspace(sessionId, workspace);
2501
- }
2502
- catch {
2503
- return null;
2504
- }
2505
- }
2506
- return workspace;
2736
+ return this.setSessionWorkspace(sessionId, { root: mainRoot, kind: "main" });
2507
2737
  }
2508
2738
  injectContext(sessionId, content) {
2509
2739
  const session = this.sessionManager.resume(sessionId);
@@ -2528,6 +2758,42 @@ export class Engine {
2528
2758
  const session = this.sessionManager.resume(effectiveSessionId);
2529
2759
  const sourceMessages = this.compactedMessagesBySession.get(effectiveSessionId) ?? session.transcript.toMessages();
2530
2760
  const before = estimateTokens(sourceMessages);
2761
+ const contextManager = await this.prepareContextManagerForSession(effectiveSessionId, session, sourceMessages, "force_compact");
2762
+ // Manual /compact emits its UI boundary at the protocol layer from the
2763
+ // final before/after result. Capture the tier here, but avoid reusing a
2764
+ // stale run callback retained on lastContextManager, which could otherwise
2765
+ // double-emit.
2766
+ let compactStrategy;
2767
+ contextManager.setOnCompact((info) => {
2768
+ if (info.after < info.before)
2769
+ compactStrategy = info.strategy;
2770
+ });
2771
+ const compacted = await contextManager.forceSummarize(sourceMessages);
2772
+ const after = estimateTokens(compacted);
2773
+ this.compactedMessagesBySession.set(effectiveSessionId, compacted);
2774
+ this.lastSessionId = effectiveSessionId;
2775
+ this.lastMessages = compacted;
2776
+ return {
2777
+ before,
2778
+ after,
2779
+ strategy: after >= before ? "no compaction needed" : (compactStrategy ?? "compacted"),
2780
+ };
2781
+ }
2782
+ /**
2783
+ * Lazily build (or reuse) the ContextManager for a resumed session and wire a
2784
+ * PRIMARY-model summarizeFn onto it. Shared by forceCompact and
2785
+ * archiveTurnRange: both need a summarizer on a session that may have been
2786
+ * resumed-but-never-run (so no run wired one), and both bill compaction usage
2787
+ * against the session.
2788
+ *
2789
+ * The PRIMARY model is used deliberately — automatic background compaction
2790
+ * routes to the cheap aux model, but a user-/host-initiated compaction or
2791
+ * range archival is low-frequency and demands fidelity (a dropped decision =
2792
+ * the conversation "forgets"). Client-construction failure is logged and
2793
+ * swallowed so the caller degrades gracefully (forceSummarize falls back to
2794
+ * snip/window; summarizeRange returns the input untouched).
2795
+ */
2796
+ async prepareContextManagerForSession(effectiveSessionId, session, sourceMessages, op) {
2531
2797
  let contextManager = this.lastContextManager;
2532
2798
  if (!contextManager || this.lastSessionId !== effectiveSessionId) {
2533
2799
  contextManager = new ContextManager({
@@ -2538,88 +2804,86 @@ export class Engine {
2538
2804
  contextManager.initReplacementStateFromMessages(sourceMessages);
2539
2805
  this.lastContextManager = contextManager;
2540
2806
  }
2541
- // Manual /compact emits its UI boundary at the protocol layer from the
2542
- // final before/after result. Capture the tier here, but avoid reusing a
2543
- // stale run callback retained on lastContextManager, which could otherwise
2544
- // double-emit.
2545
- let compactStrategy;
2546
- contextManager.setOnCompact((info) => {
2547
- if (info.after < info.before)
2548
- compactStrategy = info.strategy;
2549
- });
2550
- // Manual /compact = maximum compaction NOW. The automatic ladder waits for
2551
- // compactAtRatio (0.85 * window), so on a 1M-window model an 800k text-only
2552
- // conversation sits under the gate and manage() only runs a no-op micro.
2553
- // Wire a summarizeFn (the run path does this per-run; a cold forceCompact on
2554
- // a resumed-but-never-run session has none) and call forceSummarize, which
2555
- // ignores the ratio gate and always summarizes (falling back to snip/window).
2556
- //
2557
- // Use the PRIMARY model, not the aux model. Automatic background compaction
2558
- // routes to aux to keep the high-frequency path cheap, but summarization is
2559
- // a high-fidelity task (drop a decision and the conversation "forgets"), and
2560
- // a manual /compact is a low-frequency, user-initiated request for quality.
2561
- // The aux model is sized for tiny outputs (titles, memory extraction), so
2562
- // downgrading the one compaction the user explicitly asked for is backwards.
2563
2807
  try {
2564
2808
  const primaryClient = await createLLMClient(this.config.llm, this.config.clientDefaults);
2565
2809
  Object.assign(session.state, normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage));
2566
2810
  const recordCompactUsage = (usage) => {
2567
- const next = addCumulativeUsage(session.state, usage);
2568
- Object.assign(session.state, next);
2569
- this.sessionManager.saveState(session.state);
2811
+ this.sessionManager.recordAuxiliaryUsage(effectiveSessionId, usage, this.config.costStore?.serialize());
2812
+ const latest = this.sessionManager.resume(effectiveSessionId).state;
2813
+ const next = normalizeCumulativeUsageCounters(latest, latest.tokenUsage);
2814
+ Object.assign(session.state, next, {
2815
+ tokenUsage: latest.tokenUsage,
2816
+ costState: latest.costState,
2817
+ stateRevision: latest.stateRevision,
2818
+ });
2570
2819
  return next;
2571
2820
  };
2572
2821
  contextManager.setSummarizeFn(this.buildSummarizeFn(primaryClient, recordCompactUsage));
2573
2822
  }
2574
2823
  catch (err) {
2575
- logger.warn("engine.force_compact_client_failed", {
2824
+ logger.warn(`engine.${op}_client_failed`, {
2576
2825
  error: err.message,
2577
2826
  });
2578
2827
  }
2579
- const compacted = await contextManager.forceSummarize(sourceMessages);
2580
- const after = estimateTokens(compacted);
2581
- this.compactedMessagesBySession.set(effectiveSessionId, compacted);
2582
- this.lastSessionId = effectiveSessionId;
2583
- this.lastMessages = compacted;
2584
- return {
2585
- before,
2586
- after,
2587
- strategy: after >= before ? "no compaction needed" : (compactStrategy ?? "compacted"),
2588
- };
2828
+ return contextManager;
2589
2829
  }
2590
- stripInjectedContextMessages(messages, userContextMsg, dynamicContextMsg) {
2591
- const withoutDynamicContext = dynamicContextMsg
2592
- ? messages.filter((msg) => msg !== dynamicContextMsg)
2593
- : [...messages];
2594
- if (!userContextMsg || messages[0] !== userContextMsg) {
2595
- return withoutDynamicContext;
2596
- }
2597
- return withoutDynamicContext.slice(1);
2830
+ /**
2831
+ * Archive a caller-chosen contiguous message-index window `[range.start,
2832
+ * range.end)` of a session into a single anchored summary, leaving everything
2833
+ * outside the window untouched, and cache the result so a later
2834
+ * forceCompact/resume reads the archived history. This is a generic
2835
+ * range-archival facade over ContextManager.summarizeRange — the caller
2836
+ * decides which span to collapse (the range is a half-open message-index
2837
+ * window, matching summarizeRange). Returns token stats before/after; equal
2838
+ * before/after means the window was empty or the summary was rejected.
2839
+ */
2840
+ async archiveTurnRange(sessionId, range) {
2841
+ const effectiveSessionId = sessionId || this.lastSessionId;
2842
+ if (!effectiveSessionId)
2843
+ return { before: 0, after: 0 };
2844
+ const session = this.sessionManager.resume(effectiveSessionId);
2845
+ const sourceMessages = this.compactedMessagesBySession.get(effectiveSessionId) ?? session.transcript.toMessages();
2846
+ const before = estimateTokens(sourceMessages);
2847
+ const contextManager = await this.prepareContextManagerForSession(effectiveSessionId, session, sourceMessages, "archive_range");
2848
+ // Range archival is initiated deliberately, not by a pressure heuristic;
2849
+ // don't let a stale run callback retained on lastContextManager double-emit.
2850
+ contextManager.setOnCompact(() => { });
2851
+ const archived = await contextManager.summarizeRange(sourceMessages, range);
2852
+ const after = estimateTokens(archived);
2853
+ this.compactedMessagesBySession.set(effectiveSessionId, archived);
2854
+ this.lastSessionId = effectiveSessionId;
2855
+ this.lastMessages = archived;
2856
+ return { before, after };
2598
2857
  }
2599
- recordCacheReadDiagnostics(sessionId, usage) {
2600
- const current = usage.cacheReadTokens;
2601
- if (current === undefined || !Number.isFinite(current))
2602
- return;
2603
- const previous = this.lastCacheReadBySid.get(sessionId);
2604
- this.lastCacheReadBySid.delete(sessionId);
2605
- this.lastCacheReadBySid.set(sessionId, current);
2606
- if (this.lastCacheReadBySid.size > CACHE_READ_DIAGNOSTIC_MAX_SESSIONS) {
2607
- const oldestSessionId = this.lastCacheReadBySid.keys().next().value;
2608
- if (oldestSessionId !== undefined)
2609
- this.lastCacheReadBySid.delete(oldestSessionId);
2610
- }
2611
- if (previous === undefined || previous < CACHE_READ_DROP_MIN_PREVIOUS_TOKENS)
2858
+ recordCacheReadDiagnostics(sessionId, sample) {
2859
+ const result = this.promptCacheDiagnostics.record(sessionId, sample);
2860
+ if (result.kind === "scope_changed") {
2861
+ logger.info("engine.cache_scope_changed", {
2862
+ sessionId,
2863
+ cacheScopeHash: sample.fingerprint.cacheScopeHash,
2864
+ });
2612
2865
  return;
2613
- const dropRatio = previous > 0 ? current / previous : 1;
2614
- if (current <= CACHE_READ_DROP_MAX_CURRENT_TOKENS && dropRatio <= CACHE_READ_DROP_RATIO) {
2615
- logger.warn("engine.cache_read_drop", {
2866
+ }
2867
+ if (result.kind === "schema_changed") {
2868
+ logger.info("engine.cache_diagnostic_schema_changed", {
2616
2869
  sessionId,
2617
- previousCacheReadTokens: previous,
2618
- currentCacheReadTokens: current,
2619
- dropRatio,
2620
- hint: "Prompt cache read tokens dropped sharply. Check for changed cacheable prefix, stale dynamic context in history, tool/schema changes, or provider cache eviction.",
2870
+ version: sample.fingerprint.version,
2621
2871
  });
2872
+ return;
2622
2873
  }
2874
+ if (result.kind !== "drop")
2875
+ return;
2876
+ logger.warn("engine.cache_read_drop", {
2877
+ sessionId,
2878
+ previousCacheReadTokens: result.previous.cacheReadTokens,
2879
+ currentCacheReadTokens: result.current.cacheReadTokens,
2880
+ dropRatio: result.dropRatio,
2881
+ cause: result.attribution.cause,
2882
+ changedPrefixes: result.attribution.changedPrefixes,
2883
+ previousPrefix: result.previous.fingerprint,
2884
+ currentPrefix: result.current.fingerprint,
2885
+ hint: promptCacheDropHint(result.attribution),
2886
+ });
2623
2887
  }
2624
2888
  getSettingsManager() {
2625
2889
  if (!this.settingsManager) {
@@ -2634,7 +2898,7 @@ export class Engine {
2634
2898
  this.getSettingsManager().saveUserSetting(key, value);
2635
2899
  }
2636
2900
  /**
2637
- * Read a settings value by dotted key (e.g. "arena.participants").
2901
+ * Read a settings value by dotted key (e.g. "capabilities.foo.enabled").
2638
2902
  * Returns undefined if any segment is missing.
2639
2903
  */
2640
2904
  readSetting(key) {
@@ -2648,91 +2912,17 @@ export class Engine {
2648
2912
  }
2649
2913
  return target;
2650
2914
  }
2651
- buildPermissionConfig(mode, cwd) {
2652
- const rules = [...this.preset.defaultPermissionRules];
2653
- // Memory tools: dream scope is the LLM's own workspace, so save/delete
2654
- // there go through without prompting. user-scope save/delete have no
2655
- // explicit allow rule here, so default-mode classifier fallback asks the
2656
- // user to confirm modifications. RegisteredTool.permissionDefault is only
2657
- // UI/metadata and is not read by the classifier.
2658
- rules.push({
2659
- tool: "MemorySave",
2660
- argsPattern: { scope: "^dream$" },
2661
- decision: "allow",
2662
- reason: "Dream scope is the LLM's auto-consolidation workspace",
2663
- });
2664
- rules.push({
2665
- tool: "MemoryDelete",
2666
- argsPattern: { scope: "^dream$" },
2667
- decision: "allow",
2668
- reason: "Dream scope is the LLM's auto-consolidation workspace",
2669
- });
2670
- if (mode === "acceptEdits" || mode === "bypassPermissions") {
2671
- rules.push({ tool: "Write", decision: "allow" });
2672
- rules.push({ tool: "Edit", decision: "allow" });
2673
- }
2674
- if (mode === "bypassPermissions") {
2675
- rules.push({ tool: "Bash", decision: "allow" });
2676
- }
2677
- try {
2678
- const settingsManager = new SettingsManager(cwd, this.config.settingsScope ?? "project", this.config.projectTrusted !== false);
2679
- const settings = settingsManager.get();
2680
- if (settings.permissions?.rules?.length) {
2681
- rules.unshift(...settings.permissions.rules);
2682
- }
2683
- }
2684
- catch {
2685
- // Settings not available — defaults only
2686
- }
2687
- let backend;
2688
- if (this.config.approvalBackend) {
2689
- backend =
2690
- mode === "auto"
2691
- ? new AutoApprovalBackend(this.config.approvalBackend)
2692
- : this.config.approvalBackend;
2693
- }
2694
- else if (mode === "auto") {
2695
- backend = new AutoApprovalBackend();
2696
- }
2697
- else {
2698
- // If a host installed an InteractiveApprovalBackend prompt fn
2699
- // (agent-server-stdio does this on boot via setInteractiveApprovalFn),
2700
- // use it so the UI gets a chance to approve/deny. Without this,
2701
- // every `ask` permission silently fell through to deny-all and
2702
- // the user saw "Permission denied by user" with NO modal — exactly
2703
- // the bug that motivated this fix.
2704
- const interactive = getInteractiveApprovalBackend();
2705
- if (interactive.hasPromptFn()) {
2706
- backend = interactive;
2707
- }
2708
- else {
2709
- backend = new HeadlessApprovalBackend(mode === "bypassPermissions"
2710
- ? "approve-all"
2711
- : mode === "dontAsk"
2712
- ? "deny-all"
2713
- : "deny-all");
2714
- }
2715
- }
2716
- return { rules, backend };
2717
- }
2718
2915
  /**
2719
- * Switch permission mode at runtime. Takes effect immediately for any
2720
- * in-flight ToolExecutor (which holds a reference to the same classifier),
2721
- * and the new mode is used for any subsequent run() calls.
2916
+ * Switch permission mode at runtime. Idle updates apply immediately; busy
2917
+ * updates are committed atomically when the current run settles, so its
2918
+ * classifier and ToolContext retain the immutable start-of-run snapshot.
2722
2919
  * Session-only — does not persist to settings.
2723
2920
  */
2724
2921
  setPermissionMode(mode) {
2725
- this.config = { ...this.config, permissionMode: mode };
2726
- this.permissionMode = mode;
2727
- this.planMode = mode === "plan";
2728
- if (this.activePermission) {
2729
- const cwd = this.config.cwd ?? process.cwd();
2730
- const { rules, backend } = this.buildPermissionConfig(mode, cwd);
2731
- this.activePermission.reconfigure(mode, backend, rules);
2732
- }
2922
+ this.permissionController.setPermissionMode(mode);
2733
2923
  }
2734
2924
  getPermissionMode() {
2735
- return this.config.permissionMode ?? "acceptEdits";
2925
+ return this.permissionController.getPermissionMode();
2736
2926
  }
2737
2927
  /**
2738
2928
  * Extend the in-flight run's turn ceiling and/or goal budgets (TODO 3.1 —
@@ -2752,24 +2942,14 @@ export class Engine {
2752
2942
  * rule set buildPermissionConfig does, without constructing a backend.
2753
2943
  */
2754
2944
  getPermissionRules() {
2755
- return this.buildPermissionConfig(this.getPermissionMode(), this.config.cwd ?? process.cwd())
2756
- .rules;
2945
+ return this.permissionController.getPermissionRules();
2757
2946
  }
2758
2947
  /**
2759
2948
  * Toggle plan mode directly. Called by the Plan tool (Task 7) via ToolContext.engine.
2760
2949
  * Also syncs permissionMode to keep both fields consistent.
2761
2950
  */
2762
2951
  setPlanMode(value) {
2763
- if (value) {
2764
- this.setPermissionMode("plan");
2765
- }
2766
- else if (this.permissionMode === "plan") {
2767
- // Leaving plan mode: drop back to the default.
2768
- this.setPermissionMode("acceptEdits");
2769
- }
2770
- else {
2771
- this.planMode = value;
2772
- }
2952
+ this.permissionController.setPlanMode(value);
2773
2953
  }
2774
2954
  /**
2775
2955
  * Block until a background agent's state changes (finishes / its result is
@@ -2837,9 +3017,9 @@ export class Engine {
2837
3017
  * directory is read once rather than every turn. A new cwd (e.g. via
2838
3018
  * run({ cwd })) reloads.
2839
3019
  */
2840
- getAgentDefinitions(cwd) {
2841
- const disabledAgents = this.readDisabledAgents(cwd);
2842
- const disabledPlugins = this.readDisabledLists().disabledPlugins;
3020
+ getAgentDefinitions(cwd, explicitProfileOverrides) {
3021
+ const disabledAgents = this.readDisabledAgents(cwd, explicitProfileOverrides);
3022
+ const disabledPlugins = this.readDisabledLists(cwd, explicitProfileOverrides).disabledPlugins;
2843
3023
  const disabledKey = [...disabledAgents, "::", ...disabledPlugins].slice().sort().join(" ");
2844
3024
  if (this.agentDefsCache?.cwd !== cwd || this.agentDefsCache.disabledKey !== disabledKey) {
2845
3025
  this.agentDefsCache = {
@@ -2859,14 +3039,12 @@ export class Engine {
2859
3039
  * (getForScope) so inherit survives. No cwd / no overlay → baseline
2860
3040
  * unchanged. Mirrors readDisabledLists (skills/plugins).
2861
3041
  */
2862
- readDisabledAgents(cwd) {
3042
+ readDisabledAgents(cwd, explicitProfileOverrides) {
2863
3043
  try {
2864
3044
  const sm = this.getSettingsManager();
2865
3045
  const settings = sm.get();
2866
3046
  const baseline = Array.isArray(settings.disabledAgents) ? settings.disabledAgents : [];
2867
- const overrides = cwd
2868
- ? sm.getForScope("project", cwd).capabilityOverrides
2869
- : undefined;
3047
+ const overrides = effectiveProjectOverrides(sm, cwd, explicitProfileOverrides);
2870
3048
  return effectiveDisabledList(baseline, overrides?.agents);
2871
3049
  }
2872
3050
  catch {
@@ -2880,12 +3058,11 @@ export class Engine {
2880
3058
  * are already narrowed by resolveChildToolScope. No cwd / error → undefined,
2881
3059
  * so the caller's baseline builtin lists pass through unchanged.
2882
3060
  */
2883
- readBuiltinOverride(cwd) {
3061
+ readBuiltinOverride(cwd, explicitProfileOverrides) {
2884
3062
  if (this.config.isSubAgent === true || !cwd)
2885
3063
  return undefined;
2886
3064
  try {
2887
- const overrides = this.getSettingsManager().getForScope("project", cwd)
2888
- .capabilityOverrides;
3065
+ const overrides = effectiveProjectOverrides(this.getSettingsManager(), cwd, explicitProfileOverrides);
2889
3066
  return overrides?.builtin;
2890
3067
  }
2891
3068
  catch {
@@ -2897,171 +3074,32 @@ export class Engine {
2897
3074
  * overlays turn-specific fields like sandbox and subAgentSpawner) and
2898
3075
  * by tests that want a ToolContext without a full run() cycle.
2899
3076
  */
2900
- resolveSandboxWithoutRuntime(config, cwd) {
2901
- const key = sandboxCacheKey(config, cwd);
2902
- let cached = this.sandboxCache.get(key);
2903
- if (!cached) {
2904
- cached = resolveSandboxBackend(config, cwd);
2905
- // Mirror EngineRuntime.resolveSandbox: don't cache a rejection, or an
2906
- // explicit-mode probe that throws stays sticky until process restart even
2907
- // after the user fixes the config.
2908
- cached.catch(() => {
2909
- if (this.sandboxCache.get(key) === cached)
2910
- this.sandboxCache.delete(key);
3077
+ buildToolContext(cwd = this.config.cwd ?? process.cwd(), explicitProfileOverrides, profileMemoryDir) {
3078
+ const { disabledSkills, disabledPlugins } = this.readDisabledLists(cwd, explicitProfileOverrides);
3079
+ const capabilityServices = Object.fromEntries(this.capabilities.flatMap((capability) => {
3080
+ if (!capability.createToolService)
3081
+ return [];
3082
+ const service = capability.createToolService({
3083
+ isSubAgent: this.config.isSubAgent === true,
3084
+ settings: this.getSettingsManager(),
3085
+ resolveSandbox: (cwd) => this.runEnvironmentResolver.resolveSandbox(cwd),
3086
+ readShellEnv: (cwd) => this.runEnvironmentResolver.readShellEnv(cwd),
3087
+ getSessionManager: () => this.sessionManager,
2911
3088
  });
2912
- this.sandboxCache.set(key, cached);
2913
- }
2914
- return cached;
2915
- }
2916
- resolveSandboxConfigForCwd(cwd) {
2917
- // Priority: config.sandbox → project settings.sandbox → global → per-run
2918
- // default. Read UNMERGED per-scope (getForScope) so a project that wrote no
2919
- // sandbox genuinely follows global, rather than inheriting global's mode and
2920
- // looking like it set one. Fixes "项目级配了不生效" + the scope model.
2921
- let projectSandbox;
2922
- let globalSandbox;
2923
- try {
2924
- const sm = this.getSettingsManager();
2925
- if (this.config.isSubAgent !== true) {
2926
- projectSandbox = sm.getForScope("project", cwd).sandbox;
2927
- }
2928
- globalSandbox = sm.getForScope("user").sandbox;
2929
- }
2930
- catch {
2931
- // settings unavailable → fall through to per-run default
2932
- }
2933
- return resolveSandboxConfig(this.config.sandbox, projectSandbox, globalSandbox, this.config.headless === true);
2934
- }
2935
- /**
2936
- * Build the shell env layered onto the Bash tool / background shells (see
2937
- * mergeShellEnv). Three user-configured sources, merged lowest → highest:
2938
- *
2939
- * 1. project `localEnvironment.env` — the per-project "local environment"
2940
- * panel (DATABASE_URL etc.); the floor, so a project's own panel values
2941
- * can be overridden by an explicit top-level `env`.
2942
- * 2. global top-level `env` — ~/.code-shell/settings.json; the
2943
- * canonical home for API keys (OPENAI_API_KEY) a skill script reads —
2944
- * configure once, every project's skills get it.
2945
- * 3. project top-level `env` — .code-shell/settings.json; a project
2946
- * that wants to override a global key wins.
2947
- *
2948
- * Each scope is read UNMERGED so the layering here is the single source of
2949
- * precedence (getForScope merges nothing). Returns undefined when no layer
2950
- * contributes a key, so the caller passes it through unchanged for projects
2951
- * that configure none.
2952
- *
2953
- * Sub-agents: a sub-agent is the user's OWN agent doing the user's work
2954
- * (mirrors Claude Code, where sub-agents inherit the parent environment), so
2955
- * it now reads the SAME env as the parent. The sub-agent branch is kept as an
2956
- * explicit seam (`filterSubagentEnv`) rather than removed — a future policy
2957
- * could narrow what a sub-agent sees (e.g. drop credential secrets) by
2958
- * changing that one hook; today it passes everything through unchanged.
2959
- * A no-cwd context still gets nothing (there is genuinely no project to read).
2960
- *
2961
- * None of these is filtered through the deny regex (mergeShellEnv): the user
2962
- * put them there deliberately. The allowlist/deny machinery only guards the
2963
- * host's process.env from a tainted model exfiltrating it via `env | curl`.
2964
- */
2965
- readShellEnv(cwd) {
2966
- if (!cwd)
2967
- return undefined;
2968
- const merged = {};
2969
- const layer = (env) => {
2970
- if (!env)
2971
- return;
2972
- for (const [k, v] of Object.entries(env)) {
2973
- if (typeof v === "string")
2974
- merged[k] = v;
2975
- }
2976
- };
2977
- try {
2978
- // The fully-merged settings already apply the scope guard (a 'project'
2979
- // scope never reads the host ~/.code-shell) and the user < project <
2980
- // local precedence — so the top-level `env` map read from here is global
2981
- // values overridden by project values, exactly as specified. We layer
2982
- // localEnvironment.env *under* it as the floor.
2983
- const settings = this.getSettingsManager().get();
2984
- layer(settings.localEnvironment?.env); // floor
2985
- // Credentials flagged "expose as env var" (Credential.exposeAsEnv). This
2986
- // is the wiring that was missing — the UI/store recorded the flag but no
2987
- // code ever injected the secret, so `$FIGMA_TOKEN` was always empty.
2988
- // Scope mirrors settingsScope so a project-scoped engine never surfaces
2989
- // the host user's credentials (same isolation contract as top-level env).
2990
- // Placed below settings.env so an explicit `env` entry can still override.
2991
- const credScope = (this.config.settingsScope ?? "project") === "full" ? "full" : "project";
2992
- layer(getCredentialAccess().envExposures(cwd, credScope));
2993
- layer(settings.env); // top-level env (global ⊕ project) wins
2994
- }
2995
- catch {
2996
- return undefined;
2997
- }
2998
- const result = this.config.isSubAgent === true ? this.filterSubagentEnv(merged) : merged;
2999
- return Object.keys(result).length > 0 ? result : undefined;
3000
- }
3001
- /**
3002
- * Policy seam for what a sub-agent's shell sees. A sub-agent inherits the
3003
- * parent environment by default (mirrors Claude Code), so this is the
3004
- * identity function today. It exists so a future policy can narrow the set
3005
- * (e.g. strip credential `exposeAsEnv` secrets, or allowlist by name) in ONE
3006
- * place instead of scattering `isSubAgent` checks through readShellEnv.
3007
- */
3008
- filterSubagentEnv(env) {
3009
- return env;
3010
- }
3011
- /**
3012
- * Read the project's `localEnvironment.setupScripts` for this cwd (the raw
3013
- * per-platform map). Used by EnterWorktree to run setup once in a freshly
3014
- * created worktree. Returns undefined for sub-agents / no cwd (same minimal
3015
- * surface as readShellEnv). The platform selection + run live in
3016
- * git/worktree.ts; this only fetches the configured scripts.
3017
- */
3018
- readWorktreeSetupScripts(cwd) {
3019
- if (this.config.isSubAgent === true || !cwd)
3020
- return undefined;
3021
- try {
3022
- const scoped = this.getSettingsManager().getForScope("project", cwd);
3023
- return scoped.localEnvironment?.setupScripts;
3024
- }
3025
- catch {
3026
- return undefined;
3027
- }
3028
- }
3029
- readWorktreeBranchPrefix(cwd) {
3030
- if (this.config.isSubAgent === true || !cwd)
3031
- return undefined;
3032
- try {
3033
- const settings = this.getSettingsManager().get();
3034
- return settings.worktree?.branchPrefix;
3035
- }
3036
- catch {
3037
- return undefined;
3038
- }
3039
- }
3040
- async resolveWorktreeSetupSandbox(cwd) {
3041
- if (!cwd)
3042
- return undefined;
3043
- const sandboxConfig = this.resolveSandboxConfigForCwd(cwd);
3044
- const sandboxBackend = this.runtime
3045
- ? await this.runtime.resolveSandbox(sandboxConfig, cwd)
3046
- : await this.resolveSandboxWithoutRuntime(sandboxConfig, cwd);
3047
- return sandboxBackend.name === "off"
3048
- ? sandboxBackend
3049
- : { ...sandboxBackend, network: sandboxConfig.network };
3050
- }
3051
- readWorktreeSetupShellEnv(cwd) {
3052
- return this.readShellEnv(cwd);
3053
- }
3054
- buildToolContext() {
3055
- const { disabledSkills, disabledPlugins } = this.readDisabledLists();
3089
+ return [[capability.id, service]];
3090
+ }));
3056
3091
  const ctx = {
3057
- shellEnv: this.readShellEnv(this.config.cwd),
3058
- cwd: this.config.cwd ?? process.cwd(),
3092
+ shellEnv: this.runEnvironmentResolver.readShellEnv(cwd),
3093
+ cwd,
3094
+ profileMemoryDir,
3059
3095
  llmConfig: this.config.llm,
3060
3096
  modelPool: this.modelPool,
3061
3097
  toolRegistry: this.toolRegistry,
3098
+ capabilityServices,
3062
3099
  askUser: this.config.askUser,
3063
3100
  browser: this.config.browserBridge,
3064
3101
  workspace: this.config.workspaceBridge,
3102
+ panels: this.config.panelBridge,
3065
3103
  injectCredentialToBrowser: this.config.injectCredentialToBrowser,
3066
3104
  isSubAgent: this.config.isSubAgent === true,
3067
3105
  // Credential tools narrow their disk reads to this scope: a project/
@@ -3098,7 +3136,7 @@ export class Engine {
3098
3136
  * reads — the prompt composer and the tool context will always see
3099
3137
  * the same snapshot.
3100
3138
  */
3101
- readDisabledLists() {
3139
+ readDisabledLists(cwd = this.config.cwd, explicitProfileOverrides) {
3102
3140
  if (this.config.isSubAgent === true) {
3103
3141
  return { disabledSkills: [], disabledPlugins: [], disabledPluginHooks: [] };
3104
3142
  }
@@ -3106,7 +3144,7 @@ export class Engine {
3106
3144
  // capabilityOverrides over the global baseline + the no-repo whitelist
3107
3145
  // inversion. Extracted so the MCP merge consumers (engineFactory /
3108
3146
  // diskDefaultsFrom) fold identically — see that module's doc.
3109
- return computeEffectiveDisabledLists(this.getSettingsManager(), this.config.cwd);
3147
+ return computeEffectiveDisabledLists(this.getSettingsManager(), cwd, explicitProfileOverrides);
3110
3148
  }
3111
3149
  /**
3112
3150
  * Public view of the folded disabled lists, for hosts that need the
@@ -3148,40 +3186,7 @@ export class Engine {
3148
3186
  * falls back to its built-in defaults.
3149
3187
  */
3150
3188
  readMemoriesConfig() {
3151
- try {
3152
- const settings = this.getSettingsManager().get();
3153
- return settings.memories;
3154
- }
3155
- catch {
3156
- return undefined;
3157
- }
3158
- }
3159
- /**
3160
- * LLM client for memory extraction (TODO 8.1). Prefers
3161
- * settings.memories.extractionModel when it names a valid pool model;
3162
- * otherwise falls back to the aux client (which itself falls back to the
3163
- * passed primary). Build failures fall back too — extraction is best-effort.
3164
- */
3165
- async resolveExtractionClient(primaryClient) {
3166
- const key = this.readMemoriesConfig()?.extractionModel;
3167
- if (key) {
3168
- const entry = this.modelPool.get(key);
3169
- if (entry) {
3170
- try {
3171
- return await createLLMClient(this.modelPool.toLLMConfig(entry), this.config.clientDefaults);
3172
- }
3173
- catch (err) {
3174
- logger.warn("engine.extraction_model_build_failed", {
3175
- extractionModel: key,
3176
- error: err.message,
3177
- });
3178
- }
3179
- }
3180
- else {
3181
- logger.warn("engine.extraction_model_missing", { extractionModel: key });
3182
- }
3183
- }
3184
- return this.resolveAuxClient(primaryClient);
3189
+ return this.auxiliaryPipeline.readMemoriesConfig();
3185
3190
  }
3186
3191
  }
3187
3192
  /**