@cjhyy/code-shell-core 0.7.1 → 0.8.1

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