@cjhyy/code-shell-core 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (621) hide show
  1. package/README.md +36 -23
  2. package/dist/agent/agent-definition-registry.d.ts +2 -2
  3. package/dist/agent/agent-definition-registry.js +47 -10
  4. package/dist/agent/agent-definition.d.ts +5 -0
  5. package/dist/agent/agent-definition.js +14 -0
  6. package/dist/automation/index.d.ts +7 -4
  7. package/dist/automation/index.js +4 -2
  8. package/dist/automation/runner.js +3 -1
  9. package/dist/automation/scheduler.d.ts +57 -1
  10. package/dist/automation/scheduler.js +123 -23
  11. package/dist/automation/store.d.ts +6 -2
  12. package/dist/automation/store.js +7 -3
  13. package/dist/capabilities/index.d.ts +109 -0
  14. package/dist/capabilities/index.js +80 -0
  15. package/dist/capability-control/disabled-lists.d.ts +2 -1
  16. package/dist/capability-control/disabled-lists.js +3 -5
  17. package/dist/capability-control/overlay.d.ts +14 -1
  18. package/dist/capability-control/overlay.js +40 -0
  19. package/dist/capability-control/service.js +6 -4
  20. package/dist/cli/agent-server-stdio.d.ts +1 -1
  21. package/dist/cli/agent-server-stdio.js +66 -6
  22. package/dist/cli/agent-server-tcp.js +20 -5
  23. package/dist/context/compaction.d.ts +39 -3
  24. package/dist/context/compaction.js +151 -39
  25. package/dist/context/manager.d.ts +20 -4
  26. package/dist/context/manager.js +42 -7
  27. package/dist/context/token-counter.js +5 -3
  28. package/dist/context/tool-result-storage.d.ts +6 -0
  29. package/dist/context/tool-result-storage.js +25 -4
  30. package/dist/credentials/access.d.ts +13 -2
  31. package/dist/credentials/access.js +99 -4
  32. package/dist/credentials/index.d.ts +3 -2
  33. package/dist/credentials/index.js +2 -1
  34. package/dist/credentials/oauth.d.ts +9 -1
  35. package/dist/credentials/oauth.js +80 -1
  36. package/dist/credentials/store.d.ts +16 -2
  37. package/dist/credentials/store.js +33 -11
  38. package/dist/credentials/types.d.ts +62 -1
  39. package/dist/credentials/types.js +16 -1
  40. package/dist/credentials/use-credential-tool.js +15 -1
  41. package/dist/engine/auxiliary-pipeline.d.ts +40 -0
  42. package/dist/engine/auxiliary-pipeline.js +181 -0
  43. package/dist/{settings → engine}/disk-defaults.d.ts +2 -2
  44. package/dist/{settings → engine}/disk-defaults.js +5 -2
  45. package/dist/engine/dynamic-tool-defs.d.ts +4 -0
  46. package/dist/engine/dynamic-tool-defs.js +4 -0
  47. package/dist/engine/engine.d.ts +272 -184
  48. package/dist/engine/engine.js +1869 -1864
  49. package/dist/engine/file-history-hook.d.ts +11 -0
  50. package/dist/engine/file-history-hook.js +31 -0
  51. package/dist/engine/goal-judge-context.d.ts +9 -0
  52. package/dist/engine/goal-judge-context.js +234 -0
  53. package/dist/engine/injected-context-cache.d.ts +6 -0
  54. package/dist/engine/injected-context-cache.js +13 -0
  55. package/dist/engine/input-attachments.d.ts +1 -1
  56. package/dist/engine/model-facade.d.ts +3 -3
  57. package/dist/engine/model-facade.js +15 -14
  58. package/dist/engine/permission-controller.d.ts +55 -0
  59. package/dist/engine/permission-controller.js +148 -0
  60. package/dist/engine/prompt-cache-diagnostics.d.ts +58 -0
  61. package/dist/engine/prompt-cache-diagnostics.js +178 -0
  62. package/dist/engine/run-accounting.d.ts +41 -0
  63. package/dist/engine/run-accounting.js +126 -0
  64. package/dist/engine/run-context.d.ts +44 -0
  65. package/dist/engine/run-context.js +88 -0
  66. package/dist/engine/run-environment.d.ts +30 -0
  67. package/dist/engine/run-environment.js +77 -0
  68. package/dist/engine/run-finalize.d.ts +58 -0
  69. package/dist/engine/run-finalize.js +245 -0
  70. package/dist/engine/run-goal.d.ts +58 -0
  71. package/dist/engine/run-goal.js +188 -0
  72. package/dist/engine/run-image-input.d.ts +1 -1
  73. package/dist/engine/run-image-input.js +5 -3
  74. package/dist/engine/run-session-open.d.ts +41 -0
  75. package/dist/engine/run-session-open.js +186 -0
  76. package/dist/engine/run-setup.d.ts +42 -0
  77. package/dist/engine/run-setup.js +58 -0
  78. package/dist/engine/run-tooling.d.ts +125 -0
  79. package/dist/engine/run-tooling.js +283 -0
  80. package/dist/engine/run-types.d.ts +139 -0
  81. package/dist/engine/run-types.js +12 -0
  82. package/dist/engine/run-workspace.d.ts +50 -0
  83. package/dist/engine/run-workspace.js +114 -0
  84. package/dist/engine/session-title.d.ts +2 -1
  85. package/dist/engine/session-title.js +4 -1
  86. package/dist/engine/steer-queue.d.ts +1 -1
  87. package/dist/engine/subagent-spawner.d.ts +32 -0
  88. package/dist/engine/subagent-spawner.js +287 -0
  89. package/dist/engine/tool-summary.d.ts +2 -2
  90. package/dist/engine/tool-summary.js +2 -2
  91. package/dist/engine/turn-loop.d.ts +67 -10
  92. package/dist/engine/turn-loop.js +538 -97
  93. package/dist/engine/types.d.ts +35 -4
  94. package/dist/engine/types.js +1 -1
  95. package/dist/{engine/goal.d.ts → goal/lifecycle.d.ts} +80 -6
  96. package/dist/goal/lifecycle.js +482 -0
  97. package/dist/hooks/events.d.ts +26 -9
  98. package/dist/hooks/events.js +0 -3
  99. package/dist/hooks/goal-stop-hook.d.ts +28 -29
  100. package/dist/hooks/goal-stop-hook.js +344 -144
  101. package/dist/hooks/registry.js +4 -1
  102. package/dist/hooks/shell-runner.d.ts +12 -1
  103. package/dist/hooks/shell-runner.js +160 -9
  104. package/dist/index.d.ts +51 -110
  105. package/dist/index.extension.d.ts +51 -0
  106. package/dist/index.extension.js +39 -0
  107. package/dist/index.internal.d.ts +86 -0
  108. package/dist/index.internal.js +92 -0
  109. package/dist/index.js +37 -125
  110. package/dist/links/cli.d.ts +48 -0
  111. package/dist/links/cli.js +617 -0
  112. package/dist/links/http.d.ts +40 -0
  113. package/dist/links/http.js +166 -0
  114. package/dist/links/index.d.ts +4 -0
  115. package/dist/links/index.js +3 -0
  116. package/dist/links/link-action-tool.d.ts +5 -0
  117. package/dist/links/link-action-tool.js +241 -0
  118. package/dist/links/providers.d.ts +8 -0
  119. package/dist/links/providers.js +665 -0
  120. package/dist/links/types.d.ts +44 -0
  121. package/dist/llm/client-base.d.ts +7 -0
  122. package/dist/llm/client-base.js +72 -10
  123. package/dist/llm/model-pool.d.ts +1 -1
  124. package/dist/llm/model-pool.js +1 -1
  125. package/dist/llm/providers/anthropic.d.ts +1 -0
  126. package/dist/llm/providers/anthropic.js +9 -0
  127. package/dist/llm/providers/openai.d.ts +1 -0
  128. package/dist/llm/providers/openai.js +17 -0
  129. package/dist/llm/types.d.ts +12 -5
  130. package/dist/model-catalog/index.js +15 -2
  131. package/dist/model-catalog/resolve.d.ts +13 -1
  132. package/dist/model-catalog/resolve.js +37 -2
  133. package/dist/model-catalog/types.d.ts +74 -48
  134. package/dist/model-catalog/types.js +16 -5
  135. package/dist/model-catalog/upsert.d.ts +3 -1
  136. package/dist/model-catalog/upsert.js +9 -0
  137. package/dist/onboarding.d.ts +0 -1
  138. package/dist/onboarding.js +1 -22
  139. package/dist/panel-apps/bindings.d.ts +24 -0
  140. package/dist/panel-apps/bindings.js +69 -0
  141. package/dist/panel-apps/github-archive.d.ts +21 -0
  142. package/dist/panel-apps/github-archive.js +98 -0
  143. package/dist/panel-apps/index.d.ts +4 -0
  144. package/dist/panel-apps/index.js +4 -0
  145. package/dist/panel-apps/installer.d.ts +68 -0
  146. package/dist/panel-apps/installer.js +622 -0
  147. package/dist/panel-apps/manifest.d.ts +364 -0
  148. package/dist/panel-apps/manifest.js +189 -0
  149. package/dist/panel-apps/paths.d.ts +13 -0
  150. package/dist/panel-apps/paths.js +38 -0
  151. package/dist/panel-apps/registry.d.ts +51 -0
  152. package/dist/panel-apps/registry.js +99 -0
  153. package/dist/panel-apps/runtime.d.ts +80 -0
  154. package/dist/panel-apps/runtime.js +154 -0
  155. package/dist/plugins/gitOps.js +8 -1
  156. package/dist/plugins/installedPlugins.d.ts +2 -1
  157. package/dist/plugins/installedPlugins.js +146 -8
  158. package/dist/plugins/installer/codex/convertAgents.d.ts +4 -1
  159. package/dist/plugins/installer/codex/convertAgents.js +62 -8
  160. package/dist/plugins/installer/codex/convertCommands.d.ts +5 -5
  161. package/dist/plugins/installer/codex/convertCommands.js +35 -12
  162. package/dist/plugins/installer/codex/convertHooks.d.ts +10 -0
  163. package/dist/plugins/installer/codex/convertHooks.js +82 -0
  164. package/dist/plugins/installer/codex/convertSkills.js +41 -6
  165. package/dist/plugins/installer/install.d.ts +7 -1
  166. package/dist/plugins/installer/install.js +21 -87
  167. package/dist/plugins/installer/installFromArchive.d.ts +26 -4
  168. package/dist/plugins/installer/installFromArchive.js +44 -27
  169. package/dist/plugins/installer/installFromNpm.d.ts +31 -0
  170. package/dist/plugins/installer/installFromNpm.js +273 -0
  171. package/dist/plugins/installer/loadPluginAgents.js +17 -4
  172. package/dist/plugins/installer/loadPluginMcp.d.ts +14 -0
  173. package/dist/plugins/installer/loadPluginMcp.js +245 -35
  174. package/dist/plugins/installer/normalizeManifest.d.ts +13 -0
  175. package/dist/plugins/installer/normalizeManifest.js +306 -0
  176. package/dist/plugins/installer/npmTar.d.ts +13 -0
  177. package/dist/plugins/installer/npmTar.js +321 -0
  178. package/dist/plugins/installer/parseSource.d.ts +11 -0
  179. package/dist/plugins/installer/parseSource.js +48 -0
  180. package/dist/plugins/installer/preview.d.ts +101 -0
  181. package/dist/plugins/installer/preview.js +336 -0
  182. package/dist/plugins/installer/projectPluginSource.d.ts +14 -0
  183. package/dist/plugins/installer/projectPluginSource.js +140 -0
  184. package/dist/plugins/installer/types.d.ts +775 -0
  185. package/dist/plugins/installer/types.js +100 -0
  186. package/dist/plugins/installer/unzip.d.ts +11 -0
  187. package/dist/plugins/installer/unzip.js +123 -9
  188. package/dist/plugins/installer/update.d.ts +2 -2
  189. package/dist/plugins/installer/update.js +43 -4
  190. package/dist/plugins/loadPluginHooks.d.ts +19 -2
  191. package/dist/plugins/loadPluginHooks.js +97 -86
  192. package/dist/plugins/pluginAutomationTemplates.d.ts +18 -0
  193. package/dist/plugins/pluginAutomationTemplates.js +50 -0
  194. package/dist/plugins/pluginCatalog.d.ts +45 -0
  195. package/dist/plugins/pluginCatalog.js +87 -0
  196. package/dist/plugins/pluginCommandHook.d.ts +10 -4
  197. package/dist/plugins/pluginCommandHook.js +133 -18
  198. package/dist/plugins/pluginCommandsLoader.d.ts +19 -0
  199. package/dist/plugins/pluginCommandsLoader.js +181 -7
  200. package/dist/plugins/pluginContent.d.ts +10 -1
  201. package/dist/plugins/pluginContent.js +87 -30
  202. package/dist/plugins/pluginHookApproval.d.ts +30 -0
  203. package/dist/plugins/pluginHookApproval.js +160 -0
  204. package/dist/plugins/pluginHookIntegrity.d.ts +69 -0
  205. package/dist/plugins/pluginHookIntegrity.js +361 -0
  206. package/dist/plugins/pluginInstaller.js +71 -12
  207. package/dist/plugins/pluginMcpApproval.d.ts +19 -0
  208. package/dist/plugins/pluginMcpApproval.js +110 -0
  209. package/dist/plugins/pluginMcpIntegrity.d.ts +21 -0
  210. package/dist/plugins/pluginMcpIntegrity.js +74 -0
  211. package/dist/plugins/types.d.ts +30 -0
  212. package/dist/plugins/varRewrite.d.ts +9 -5
  213. package/dist/plugins/varRewrite.js +26 -17
  214. package/dist/preset/index.d.ts +11 -8
  215. package/dist/preset/index.js +62 -203
  216. package/dist/product/define.js +9 -3
  217. package/dist/product/types.d.ts +0 -2
  218. package/dist/profile/activation.d.ts +36 -0
  219. package/dist/profile/activation.js +57 -0
  220. package/dist/profile/catalog-store.d.ts +77 -0
  221. package/dist/profile/catalog-store.js +346 -0
  222. package/dist/profile/catalog.d.ts +54 -0
  223. package/dist/profile/catalog.js +152 -0
  224. package/dist/profile/index.d.ts +7 -0
  225. package/dist/profile/index.js +7 -0
  226. package/dist/profile/requirements.d.ts +76 -0
  227. package/dist/profile/requirements.js +117 -0
  228. package/dist/profile/resolve.d.ts +11 -0
  229. package/dist/profile/resolve.js +41 -0
  230. package/dist/profile/store.d.ts +17 -0
  231. package/dist/profile/store.js +167 -0
  232. package/dist/profile/types.d.ts +321 -0
  233. package/dist/profile/types.js +104 -0
  234. package/dist/prompt/composer.d.ts +46 -7
  235. package/dist/prompt/composer.js +78 -48
  236. package/dist/prompt/instruction-scanner.d.ts +6 -5
  237. package/dist/prompt/instruction-scanner.js +8 -23
  238. package/dist/prompt/section-loader.d.ts +2 -2
  239. package/dist/prompt/section-loader.js +7 -4
  240. package/dist/prompt/sections/browser.md +10 -9
  241. package/dist/prompt/sections/harness-base.md +9 -0
  242. package/dist/protocol/chat-session-manager.d.ts +77 -3
  243. package/dist/protocol/chat-session-manager.js +194 -18
  244. package/dist/protocol/chat-session.d.ts +72 -9
  245. package/dist/protocol/chat-session.js +151 -16
  246. package/dist/protocol/client.d.ts +37 -4
  247. package/dist/protocol/client.js +118 -6
  248. package/dist/protocol/index.d.ts +2 -2
  249. package/dist/protocol/index.js +2 -2
  250. package/dist/protocol/mobile-remote-types.d.ts +406 -0
  251. package/dist/protocol/mobile-remote-types.js +1 -0
  252. package/dist/protocol/server.d.ts +152 -14
  253. package/dist/protocol/server.js +1574 -229
  254. package/dist/protocol/types.d.ts +182 -32
  255. package/dist/protocol/types.js +18 -0
  256. package/dist/run/ArtifactTracker.d.ts +6 -1
  257. package/dist/run/ArtifactTracker.js +29 -21
  258. package/dist/run/EngineRunner.d.ts +2 -0
  259. package/dist/run/EngineRunner.js +1 -0
  260. package/dist/run/RunManager.d.ts +26 -0
  261. package/dist/run/RunManager.js +82 -11
  262. package/dist/run/factory.d.ts +3 -0
  263. package/dist/run/factory.js +2 -0
  264. package/dist/runtime/safe-spawn.d.ts +6 -0
  265. package/dist/runtime/safe-spawn.js +8 -13
  266. package/dist/services/auto-dream.d.ts +13 -1
  267. package/dist/services/auto-dream.js +56 -8
  268. package/dist/services/dream-consolidation.d.ts +3 -0
  269. package/dist/services/dream-consolidation.js +14 -2
  270. package/dist/services/index.d.ts +1 -1
  271. package/dist/services/index.js +1 -1
  272. package/dist/services/memory-orchestrator.js +6 -2
  273. package/dist/services/oauth.d.ts +34 -10
  274. package/dist/services/oauth.js +236 -98
  275. package/dist/services/session-memory.d.ts +4 -4
  276. package/dist/services/session-memory.js +13 -10
  277. package/dist/session/memory.d.ts +26 -7
  278. package/dist/session/memory.js +79 -21
  279. package/dist/session/session-manager.d.ts +208 -25
  280. package/dist/session/session-manager.js +1232 -116
  281. package/dist/session/session-message.d.ts +22 -0
  282. package/dist/session/session-message.js +1 -0
  283. package/dist/session/transcript.d.ts +76 -6
  284. package/dist/session/transcript.js +278 -7
  285. package/dist/settings/feature-flags.d.ts +40 -0
  286. package/dist/settings/feature-flags.js +40 -0
  287. package/dist/settings/manager.d.ts +39 -1
  288. package/dist/settings/manager.js +102 -41
  289. package/dist/settings/migrate-config.js +4 -2
  290. package/dist/settings/schema.d.ts +511 -177
  291. package/dist/settings/schema.js +98 -25
  292. package/dist/skills/scanner.d.ts +6 -2
  293. package/dist/skills/scanner.js +160 -9
  294. package/dist/sources/adapter.d.ts +14 -0
  295. package/dist/sources/adapter.js +7 -0
  296. package/dist/sources/adapters/local-files.d.ts +10 -0
  297. package/dist/sources/adapters/local-files.js +127 -0
  298. package/dist/sources/adapters/mcp-resource.d.ts +17 -0
  299. package/dist/sources/adapters/mcp-resource.js +41 -0
  300. package/dist/sources/adapters/mock.d.ts +3 -0
  301. package/dist/sources/adapters/mock.js +29 -0
  302. package/dist/sources/binding.d.ts +6 -0
  303. package/dist/sources/binding.js +24 -0
  304. package/dist/sources/catalog.d.ts +6 -0
  305. package/dist/sources/catalog.js +64 -0
  306. package/dist/sources/context-summary.d.ts +8 -0
  307. package/dist/sources/context-summary.js +13 -0
  308. package/dist/sources/credential-status.d.ts +2 -0
  309. package/dist/sources/credential-status.js +13 -0
  310. package/dist/sources/index.d.ts +10 -0
  311. package/dist/sources/index.js +10 -0
  312. package/dist/sources/resolve.d.ts +28 -0
  313. package/dist/sources/resolve.js +47 -0
  314. package/dist/sources/truncate-utf8.d.ts +7 -0
  315. package/dist/sources/truncate-utf8.js +18 -0
  316. package/dist/sources/types.d.ts +71 -0
  317. package/dist/sources/types.js +26 -0
  318. package/dist/testing/fetch-stub.d.ts +32 -0
  319. package/dist/testing/fetch-stub.js +24 -0
  320. package/dist/themes/image.d.ts +18 -0
  321. package/dist/themes/image.js +117 -0
  322. package/dist/themes/index.d.ts +4 -0
  323. package/dist/themes/index.js +4 -0
  324. package/dist/themes/installer.d.ts +50 -0
  325. package/dist/themes/installer.js +290 -0
  326. package/dist/themes/manifest.d.ts +102 -0
  327. package/dist/themes/manifest.js +86 -0
  328. package/dist/themes/paths.d.ts +14 -0
  329. package/dist/themes/paths.js +33 -0
  330. package/dist/tool-system/browser-bridge.d.ts +53 -11
  331. package/dist/tool-system/browser-bridge.js +8 -9
  332. package/dist/tool-system/builtin/agent-heartbeat.d.ts +2 -2
  333. package/dist/tool-system/builtin/agent-heartbeat.js +20 -6
  334. package/dist/tool-system/builtin/agent-notifications.d.ts +127 -72
  335. package/dist/tool-system/builtin/agent-notifications.js +291 -101
  336. package/dist/tool-system/builtin/agent-output-file.js +1 -3
  337. package/dist/tool-system/builtin/agent-progress.d.ts +6 -0
  338. package/dist/tool-system/builtin/agent-progress.js +83 -0
  339. package/dist/tool-system/builtin/agent-registry.d.ts +38 -2
  340. package/dist/tool-system/builtin/agent-registry.js +178 -9
  341. package/dist/tool-system/builtin/agent-transcript-translator.js +1 -1
  342. package/dist/tool-system/builtin/agent.d.ts +2 -0
  343. package/dist/tool-system/builtin/agent.js +279 -34
  344. package/dist/tool-system/builtin/background-jobs.d.ts +63 -7
  345. package/dist/tool-system/builtin/background-jobs.js +111 -15
  346. package/dist/tool-system/builtin/background-work.d.ts +12 -1
  347. package/dist/tool-system/builtin/background-work.js +12 -1
  348. package/dist/tool-system/builtin/bash.d.ts +3 -5
  349. package/dist/tool-system/builtin/bash.js +14 -6
  350. package/dist/tool-system/builtin/browser-tools.d.ts +8 -5
  351. package/dist/tool-system/builtin/browser-tools.js +89 -23
  352. package/dist/tool-system/builtin/edit-model-catalog.d.ts +6 -0
  353. package/dist/tool-system/builtin/edit-model-catalog.js +172 -12
  354. package/dist/tool-system/builtin/edit.d.ts +2 -1
  355. package/dist/tool-system/builtin/edit.js +14 -10
  356. package/dist/tool-system/builtin/file-cache.d.ts +2 -0
  357. package/dist/tool-system/builtin/file-cache.js +4 -0
  358. package/dist/tool-system/builtin/generate-image.js +17 -10
  359. package/dist/tool-system/builtin/generate-video.d.ts +4 -0
  360. package/dist/tool-system/builtin/generate-video.js +164 -28
  361. package/dist/tool-system/builtin/glob.d.ts +2 -1
  362. package/dist/tool-system/builtin/glob.js +28 -3
  363. package/dist/tool-system/builtin/grep.d.ts +1 -0
  364. package/dist/tool-system/builtin/grep.js +82 -17
  365. package/dist/tool-system/builtin/image-uploader.js +7 -1
  366. package/dist/tool-system/builtin/index.d.ts +57 -18
  367. package/dist/tool-system/builtin/index.js +280 -167
  368. package/dist/tool-system/builtin/memory.d.ts +6 -0
  369. package/dist/tool-system/builtin/memory.js +36 -15
  370. package/dist/tool-system/builtin/panel.d.ts +9 -0
  371. package/dist/tool-system/builtin/panel.js +314 -0
  372. package/dist/tool-system/builtin/read.js +5 -1
  373. package/dist/tool-system/builtin/send-message-to-session.d.ts +6 -0
  374. package/dist/tool-system/builtin/send-message-to-session.js +67 -0
  375. package/dist/tool-system/builtin/sources.d.ts +8 -0
  376. package/dist/tool-system/builtin/sources.js +137 -0
  377. package/dist/tool-system/builtin/tool-search.js +7 -2
  378. package/dist/tool-system/builtin/update-automation-memory.js +2 -0
  379. package/dist/tool-system/builtin/video-providers.d.ts +12 -15
  380. package/dist/tool-system/builtin/video-providers.js +1 -0
  381. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  382. package/dist/tool-system/builtin/web-fetch.js +60 -15
  383. package/dist/tool-system/builtin/web-search.js +1 -3
  384. package/dist/tool-system/builtin/write.d.ts +2 -1
  385. package/dist/tool-system/builtin/write.js +14 -4
  386. package/dist/tool-system/capability-module.d.ts +93 -0
  387. package/dist/tool-system/capability-module.js +53 -0
  388. package/dist/tool-system/context.d.ts +94 -24
  389. package/dist/tool-system/executor.js +99 -58
  390. package/dist/tool-system/external-tool-exposure.d.ts +60 -0
  391. package/dist/tool-system/external-tool-exposure.js +304 -0
  392. package/dist/tool-system/mcp-manager.d.ts +17 -1
  393. package/dist/tool-system/mcp-manager.js +72 -7
  394. package/dist/tool-system/mcp-tool-policy.d.ts +9 -0
  395. package/dist/tool-system/mcp-tool-policy.js +34 -0
  396. package/dist/tool-system/panel-bridge.d.ts +53 -0
  397. package/dist/tool-system/panel-bridge.js +1 -0
  398. package/dist/tool-system/path-policy.d.ts +19 -0
  399. package/dist/tool-system/path-policy.js +62 -1
  400. package/dist/tool-system/permission.d.ts +69 -3
  401. package/dist/tool-system/permission.js +429 -30
  402. package/dist/tool-system/registry.d.ts +15 -3
  403. package/dist/tool-system/registry.js +82 -40
  404. package/dist/tool-system/sandbox/index.js +3 -8
  405. package/dist/tool-system/sandbox/seatbelt.js +2 -6
  406. package/dist/tool-system/session-tool-host.d.ts +139 -0
  407. package/dist/tool-system/session-tool-host.js +167 -0
  408. package/dist/tool-system/testing/tool-registry-harness.d.ts +31 -0
  409. package/dist/tool-system/testing/tool-registry-harness.js +51 -0
  410. package/dist/tool-system/validation.d.ts +14 -7
  411. package/dist/tool-system/validation.js +701 -7
  412. package/dist/types.d.ts +201 -20
  413. package/dist/{cc-orchestrator → utils}/cwd-normalize.d.ts +1 -0
  414. package/dist/{cc-orchestrator → utils}/cwd-normalize.js +1 -0
  415. package/dist/utils/file-mutex.d.ts +65 -0
  416. package/dist/utils/file-mutex.js +145 -0
  417. package/dist/utils/json.d.ts +2 -4
  418. package/dist/utils/json.js +2 -4
  419. package/dist/utils/lockfile.d.ts +30 -1
  420. package/dist/utils/lockfile.js +2 -2
  421. package/dist/utils/secret-scrubber.d.ts +8 -0
  422. package/dist/utils/secret-scrubber.js +227 -0
  423. package/dist/utils/toolDisplay.js +1 -1
  424. package/package.json +20 -5
  425. package/THIRD_PARTY_NOTICES.md +0 -206
  426. package/dist/arena/arena.d.ts +0 -43
  427. package/dist/arena/arena.js +0 -333
  428. package/dist/arena/context/context-tools.d.ts +0 -16
  429. package/dist/arena/context/context-tools.js +0 -272
  430. package/dist/arena/context/within-root.d.ts +0 -7
  431. package/dist/arena/context/within-root.js +0 -15
  432. package/dist/arena/detect-mode.d.ts +0 -20
  433. package/dist/arena/detect-mode.js +0 -78
  434. package/dist/arena/digest-builder.d.ts +0 -25
  435. package/dist/arena/digest-builder.js +0 -120
  436. package/dist/arena/index.d.ts +0 -29
  437. package/dist/arena/index.js +0 -29
  438. package/dist/arena/iterate/convergence.d.ts +0 -25
  439. package/dist/arena/iterate/convergence.js +0 -103
  440. package/dist/arena/iterate/formats/index.d.ts +0 -22
  441. package/dist/arena/iterate/formats/index.js +0 -283
  442. package/dist/arena/iterate/index.d.ts +0 -11
  443. package/dist/arena/iterate/index.js +0 -9
  444. package/dist/arena/iterate/iterative-arena.d.ts +0 -23
  445. package/dist/arena/iterate/iterative-arena.js +0 -237
  446. package/dist/arena/iterate/parse.d.ts +0 -42
  447. package/dist/arena/iterate/parse.js +0 -123
  448. package/dist/arena/iterate/phases/argue.d.ts +0 -22
  449. package/dist/arena/iterate/phases/argue.js +0 -165
  450. package/dist/arena/iterate/phases/revise.d.ts +0 -16
  451. package/dist/arena/iterate/phases/revise.js +0 -62
  452. package/dist/arena/iterate/phases/tournament.d.ts +0 -34
  453. package/dist/arena/iterate/phases/tournament.js +0 -113
  454. package/dist/arena/iterate/tools/web-tools.d.ts +0 -13
  455. package/dist/arena/iterate/tools/web-tools.js +0 -54
  456. package/dist/arena/iterate/types.d.ts +0 -152
  457. package/dist/arena/iterate/types.js +0 -8
  458. package/dist/arena/ledger.d.ts +0 -47
  459. package/dist/arena/ledger.js +0 -159
  460. package/dist/arena/lenses/architecture.d.ts +0 -5
  461. package/dist/arena/lenses/architecture.js +0 -22
  462. package/dist/arena/lenses/engineering.d.ts +0 -5
  463. package/dist/arena/lenses/engineering.js +0 -22
  464. package/dist/arena/lenses/general.d.ts +0 -5
  465. package/dist/arena/lenses/general.js +0 -20
  466. package/dist/arena/lenses/index.d.ts +0 -16
  467. package/dist/arena/lenses/index.js +0 -47
  468. package/dist/arena/lenses/product.d.ts +0 -5
  469. package/dist/arena/lenses/product.js +0 -22
  470. package/dist/arena/model-presets.d.ts +0 -23
  471. package/dist/arena/model-presets.js +0 -44
  472. package/dist/arena/phases/adjudication.d.ts +0 -24
  473. package/dist/arena/phases/adjudication.js +0 -141
  474. package/dist/arena/phases/build-consensus.d.ts +0 -29
  475. package/dist/arena/phases/build-consensus.js +0 -83
  476. package/dist/arena/phases/claim-registry.d.ts +0 -26
  477. package/dist/arena/phases/claim-registry.js +0 -60
  478. package/dist/arena/phases/cross-review.d.ts +0 -45
  479. package/dist/arena/phases/cross-review.js +0 -218
  480. package/dist/arena/phases/debate-rounds.d.ts +0 -27
  481. package/dist/arena/phases/debate-rounds.js +0 -159
  482. package/dist/arena/phases/participant-research.d.ts +0 -38
  483. package/dist/arena/phases/participant-research.js +0 -314
  484. package/dist/arena/phases/planning-detail-expansion.d.ts +0 -38
  485. package/dist/arena/phases/planning-detail-expansion.js +0 -119
  486. package/dist/arena/planner.d.ts +0 -27
  487. package/dist/arena/planner.js +0 -311
  488. package/dist/arena/providers/docs.d.ts +0 -7
  489. package/dist/arena/providers/docs.js +0 -111
  490. package/dist/arena/providers/git.d.ts +0 -8
  491. package/dist/arena/providers/git.js +0 -174
  492. package/dist/arena/providers/index.d.ts +0 -32
  493. package/dist/arena/providers/index.js +0 -132
  494. package/dist/arena/providers/none.d.ts +0 -7
  495. package/dist/arena/providers/none.js +0 -11
  496. package/dist/arena/providers/repo.d.ts +0 -7
  497. package/dist/arena/providers/repo.js +0 -258
  498. package/dist/arena/render/session.d.ts +0 -17
  499. package/dist/arena/render/session.js +0 -190
  500. package/dist/arena/render/terminal.d.ts +0 -34
  501. package/dist/arena/render/terminal.js +0 -286
  502. package/dist/arena/strategies/discussion.d.ts +0 -25
  503. package/dist/arena/strategies/discussion.js +0 -143
  504. package/dist/arena/strategies/index.d.ts +0 -15
  505. package/dist/arena/strategies/index.js +0 -28
  506. package/dist/arena/strategies/language-wrapper.d.ts +0 -17
  507. package/dist/arena/strategies/language-wrapper.js +0 -102
  508. package/dist/arena/strategies/lens-wrapper.d.ts +0 -16
  509. package/dist/arena/strategies/lens-wrapper.js +0 -236
  510. package/dist/arena/strategies/planning.d.ts +0 -30
  511. package/dist/arena/strategies/planning.js +0 -225
  512. package/dist/arena/strategies/review.d.ts +0 -26
  513. package/dist/arena/strategies/review.js +0 -168
  514. package/dist/arena/strategies/utils.d.ts +0 -36
  515. package/dist/arena/strategies/utils.js +0 -603
  516. package/dist/arena/tools/selector.d.ts +0 -17
  517. package/dist/arena/tools/selector.js +0 -61
  518. package/dist/arena/transitions.d.ts +0 -53
  519. package/dist/arena/transitions.js +0 -97
  520. package/dist/arena/types.d.ts +0 -514
  521. package/dist/arena/types.js +0 -27
  522. package/dist/cc-orchestrator/agent-adapter.d.ts +0 -54
  523. package/dist/cc-orchestrator/agent-adapter.js +0 -143
  524. package/dist/cc-orchestrator/cc-capability.d.ts +0 -19
  525. package/dist/cc-orchestrator/cc-capability.js +0 -53
  526. package/dist/cc-orchestrator/codex-session-discovery.d.ts +0 -24
  527. package/dist/cc-orchestrator/codex-session-discovery.js +0 -191
  528. package/dist/cc-orchestrator/codex-session-history.d.ts +0 -38
  529. package/dist/cc-orchestrator/codex-session-history.js +0 -247
  530. package/dist/cc-orchestrator/external-agent-bindings.d.ts +0 -27
  531. package/dist/cc-orchestrator/external-agent-bindings.js +0 -150
  532. package/dist/cc-orchestrator/external-agent-changes.d.ts +0 -19
  533. package/dist/cc-orchestrator/external-agent-changes.js +0 -231
  534. package/dist/cc-orchestrator/external-agent-driver.d.ts +0 -19
  535. package/dist/cc-orchestrator/external-agent-driver.js +0 -284
  536. package/dist/cc-orchestrator/external-agent-session-store.d.ts +0 -23
  537. package/dist/cc-orchestrator/external-agent-session-store.js +0 -146
  538. package/dist/cc-orchestrator/index.d.ts +0 -8
  539. package/dist/cc-orchestrator/index.js +0 -8
  540. package/dist/cc-orchestrator/relevance-judge.d.ts +0 -15
  541. package/dist/cc-orchestrator/relevance-judge.js +0 -29
  542. package/dist/cc-orchestrator/session-discovery.d.ts +0 -46
  543. package/dist/cc-orchestrator/session-discovery.js +0 -125
  544. package/dist/cc-orchestrator/session-history.d.ts +0 -54
  545. package/dist/cc-orchestrator/session-history.js +0 -150
  546. package/dist/cron/cron-runtime.d.ts +0 -2
  547. package/dist/cron/cron-runtime.js +0 -2
  548. package/dist/cron/cron-store.d.ts +0 -2
  549. package/dist/cron/cron-store.js +0 -2
  550. package/dist/cron/scheduler.d.ts +0 -7
  551. package/dist/cron/scheduler.js +0 -7
  552. package/dist/engine/goal.js +0 -206
  553. package/dist/external-agents/config.d.ts +0 -2
  554. package/dist/external-agents/config.js +0 -15
  555. package/dist/external-agents/types.d.ts +0 -31
  556. package/dist/git/parse-log.d.ts +0 -13
  557. package/dist/git/parse-log.js +0 -21
  558. package/dist/git/utils.d.ts +0 -49
  559. package/dist/git/utils.js +0 -161
  560. package/dist/git/worktree/crud.d.ts +0 -69
  561. package/dist/git/worktree/crud.js +0 -206
  562. package/dist/git/worktree/diff.d.ts +0 -14
  563. package/dist/git/worktree/diff.js +0 -82
  564. package/dist/git/worktree/git-exec.d.ts +0 -7
  565. package/dist/git/worktree/git-exec.js +0 -51
  566. package/dist/git/worktree/index.d.ts +0 -5
  567. package/dist/git/worktree/index.js +0 -5
  568. package/dist/git/worktree/query.d.ts +0 -42
  569. package/dist/git/worktree/query.js +0 -121
  570. package/dist/git/worktree/slug.d.ts +0 -11
  571. package/dist/git/worktree/slug.js +0 -58
  572. package/dist/git/worktree.d.ts +0 -1
  573. package/dist/git/worktree.js +0 -5
  574. package/dist/lsp/client.d.ts +0 -41
  575. package/dist/lsp/client.js +0 -186
  576. package/dist/lsp/manager.d.ts +0 -40
  577. package/dist/lsp/manager.js +0 -155
  578. package/dist/lsp/root-path.d.ts +0 -9
  579. package/dist/lsp/root-path.js +0 -12
  580. package/dist/lsp/servers.d.ts +0 -16
  581. package/dist/lsp/servers.js +0 -60
  582. package/dist/prompt/sections/coding.md +0 -35
  583. package/dist/quota/credentials.d.ts +0 -3
  584. package/dist/quota/credentials.js +0 -80
  585. package/dist/quota/index.d.ts +0 -36
  586. package/dist/quota/index.js +0 -155
  587. package/dist/quota/types.d.ts +0 -48
  588. package/dist/quota/types.js +0 -13
  589. package/dist/review/review-prompt.d.ts +0 -28
  590. package/dist/review/review-prompt.js +0 -81
  591. package/dist/state.d.ts +0 -160
  592. package/dist/state.js +0 -267
  593. package/dist/tool-system/builtin/apply-patch/applier.d.ts +0 -26
  594. package/dist/tool-system/builtin/apply-patch/applier.js +0 -308
  595. package/dist/tool-system/builtin/apply-patch/backup-targets.d.ts +0 -10
  596. package/dist/tool-system/builtin/apply-patch/backup-targets.js +0 -30
  597. package/dist/tool-system/builtin/apply-patch/index.d.ts +0 -20
  598. package/dist/tool-system/builtin/apply-patch/index.js +0 -105
  599. package/dist/tool-system/builtin/apply-patch/parser.d.ts +0 -17
  600. package/dist/tool-system/builtin/apply-patch/parser.js +0 -212
  601. package/dist/tool-system/builtin/apply-patch/seek-sequence.d.ts +0 -18
  602. package/dist/tool-system/builtin/apply-patch/seek-sequence.js +0 -123
  603. package/dist/tool-system/builtin/apply-patch/types.d.ts +0 -49
  604. package/dist/tool-system/builtin/apply-patch/types.js +0 -13
  605. package/dist/tool-system/builtin/arena.d.ts +0 -31
  606. package/dist/tool-system/builtin/arena.js +0 -415
  607. package/dist/tool-system/builtin/brief.d.ts +0 -6
  608. package/dist/tool-system/builtin/brief.js +0 -41
  609. package/dist/tool-system/builtin/check-quota.d.ts +0 -15
  610. package/dist/tool-system/builtin/check-quota.js +0 -34
  611. package/dist/tool-system/builtin/drive-claude-code.d.ts +0 -51
  612. package/dist/tool-system/builtin/drive-claude-code.js +0 -698
  613. package/dist/tool-system/builtin/lsp.d.ts +0 -7
  614. package/dist/tool-system/builtin/lsp.js +0 -144
  615. package/dist/tool-system/builtin/notebook-edit.d.ts +0 -7
  616. package/dist/tool-system/builtin/notebook-edit.js +0 -127
  617. package/dist/tool-system/builtin/worktree.d.ts +0 -11
  618. package/dist/tool-system/builtin/worktree.js +0 -345
  619. /package/dist/{external-agents → links}/types.js +0 -0
  620. /package/dist/{engine/session-usage.d.ts → session/usage.d.ts} +0 -0
  621. /package/dist/{engine/session-usage.js → session/usage.js} +0 -0
@@ -15,18 +15,66 @@
15
15
  */
16
16
  import { Methods, ErrorCodes, createResponse, createErrorResponse, createNotification, isRequest, } from "./types.js";
17
17
  import { diskDefaultsFrom } from "../engine/engine.js";
18
- import { isProtectedSettingKey } from "../settings/manager.js";
19
- import { setInteractiveApprovalFn } from "../tool-system/permission.js";
20
- import { getArenaStatus } from "../tool-system/builtin/arena.js";
21
- import { agentNotificationBus, notificationQueue, buildNotificationMessage, } from "../tool-system/builtin/agent-notifications.js";
18
+ import { isProtectedSettingKey, SettingsManager } from "../settings/manager.js";
19
+ import { getApprovalRouter, getInteractiveApprovalBackend, } from "../tool-system/permission.js";
20
+ import { agentNotificationBus, notificationQueue, buildNotificationMessage, notificationEnvelopeToLegacyStreamEvent, } from "../tool-system/builtin/agent-notifications.js";
22
21
  import { backgroundShellManager } from "../runtime/background-shell.js";
23
22
  import { backgroundJobRegistry } from "../tool-system/builtin/background-jobs.js";
24
23
  import { listBackgroundWorkForUI } from "../tool-system/builtin/background-work.js";
25
24
  import { logger } from "../logging/logger.js";
26
25
  import { nanoid } from "nanoid";
27
- import { SessionManager } from "../session/session-manager.js";
26
+ import { assertSafeSessionId, SessionManager } from "../session/session-manager.js";
28
27
  import { redactLlmConfig, maskSecretValue } from "./redact.js";
29
28
  import { redactSecrets } from "../logging/sanitize-messages.js";
29
+ import { computeEffectiveDisabledLists } from "../capability-control/disabled-lists.js";
30
+ import { describePluginCommands, expandPluginCommandBody, scanPluginCommands, MAX_PLUGIN_COMMAND_ARGUMENT_CHARS, } from "../plugins/pluginCommandsLoader.js";
31
+ function isValidRunAttachment(value) {
32
+ if (!value || typeof value !== "object")
33
+ return false;
34
+ const attachment = value;
35
+ const hasPath = [attachment.path, attachment.relPath, attachment.absPath].some((path) => typeof path === "string" && path.trim().length > 0);
36
+ return (typeof attachment.id === "string" &&
37
+ attachment.id.length > 0 &&
38
+ typeof attachment.sessionId === "string" &&
39
+ attachment.sessionId.length > 0 &&
40
+ (attachment.kind === "image" ||
41
+ attachment.kind === "file" ||
42
+ attachment.kind === "directory") &&
43
+ hasPath);
44
+ }
45
+ function runInputError(params) {
46
+ if (typeof params.task !== "string")
47
+ return "task must be a string";
48
+ const hasAttachment = Array.isArray(params.attachments) && params.attachments.some(isValidRunAttachment);
49
+ if (params.task.trim().length === 0 && !hasAttachment) {
50
+ return "task or a valid attachment is required";
51
+ }
52
+ if (params.displayText !== undefined &&
53
+ (typeof params.displayText !== "string" ||
54
+ params.displayText.trim().length === 0 ||
55
+ params.displayText.length > 20_000)) {
56
+ return "displayText must be a non-empty string up to 20000 characters";
57
+ }
58
+ if (params.injected !== undefined && typeof params.injected !== "boolean") {
59
+ return "injected must be a boolean";
60
+ }
61
+ if (params.behaviorMode !== undefined &&
62
+ (typeof params.behaviorMode !== "string" || params.behaviorMode.length === 0)) {
63
+ return `invalid behavior mode: ${String(params.behaviorMode)}`;
64
+ }
65
+ if (params.kind !== undefined && (typeof params.kind !== "string" || params.kind.length === 0)) {
66
+ return `invalid session kind: ${String(params.kind)}`;
67
+ }
68
+ if (params.profileParams !== undefined &&
69
+ (!params.profileParams ||
70
+ typeof params.profileParams !== "object" ||
71
+ Array.isArray(params.profileParams))) {
72
+ return "profileParams must be an object";
73
+ }
74
+ // Domain-specific run-param validation (e.g. behavior-mode vocabularies and
75
+ // extension-owned param shapes) lives in ExtensionModule.validateRunParams.
76
+ return null;
77
+ }
30
78
  const COMPACT_STREAM_STRATEGIES = new Set([
31
79
  "micro",
32
80
  "summary",
@@ -34,14 +82,146 @@ const COMPACT_STREAM_STRATEGIES = new Set([
34
82
  "snip",
35
83
  "emergency",
36
84
  "compacted",
85
+ // Range archival (archive_range query) emits its boundary through this map;
86
+ // without "range" here it would be silently downgraded to "compacted".
87
+ "range",
37
88
  ]);
38
89
  function toCompactStreamStrategy(strategy) {
39
90
  return COMPACT_STREAM_STRATEGIES.has(strategy)
40
91
  ? strategy
41
92
  : "compacted";
42
93
  }
94
+ const INTERNAL_PENDING_TOOLS = new Set([
95
+ "__browser_action__",
96
+ "__credential_action__",
97
+ "__workspace_action__",
98
+ ]);
99
+ function internalCancellation(failure, reason) {
100
+ return { __internalCancelled: true, failure, reason };
101
+ }
102
+ function asInternalCancellation(value) {
103
+ return value &&
104
+ typeof value === "object" &&
105
+ value.__internalCancelled === true
106
+ ? value
107
+ : undefined;
108
+ }
109
+ /** Human-facing tail appended to a host-loopback failure detail. */
110
+ const HOST_LOOPBACK_FAILURE_DETAIL = {
111
+ denied: "declined by the user",
112
+ cancelled: "cancelled because the turn was stopped",
113
+ session_closed: "cancelled because the session closed",
114
+ owner_lost: "unavailable because the approving client disconnected",
115
+ timed_out: "timed out waiting for the host",
116
+ malformed: "returned a malformed result",
117
+ };
118
+ /**
119
+ * Cap on host-supplied failure text forwarded to the model.
120
+ *
121
+ * The rest of the Panel path is carefully bounded (512KB JSON results, 500-char
122
+ * tool descriptions, ≤16 tools); the failure path must be too. Host text reaches
123
+ * the model verbatim, so an unbounded echo is both a context-budget hazard and an
124
+ * injection surface. Collapsed to one line so a multi-line payload can't fake
125
+ * structure in the tool result.
126
+ */
127
+ const MAX_HOST_LOOPBACK_DETAIL_CHARS = 500;
128
+ function boundedHostDetail(text) {
129
+ const oneLine = text.replace(/\s+/gu, " ").trim();
130
+ return oneLine.length <= MAX_HOST_LOOPBACK_DETAIL_CHARS
131
+ ? oneLine
132
+ : `${oneLine.slice(0, MAX_HOST_LOOPBACK_DETAIL_CHARS)}…`;
133
+ }
134
+ /**
135
+ * Recover the classified failure detail a host-loopback request settled with, if
136
+ * any. Bridges call this before falling back to "malformed result": a cancelled /
137
+ * denied / timed-out request is a KNOWN terminal state, and mislabelling it as
138
+ * malformed blames the host for something the user (or a closing session) did.
139
+ */
140
+ function hostLoopbackDetail(result) {
141
+ if (!result || typeof result !== "object")
142
+ return undefined;
143
+ const candidate = result;
144
+ // Take the human-readable text FIRST, and do NOT require `failure` to be
145
+ // present. Genuine Desktop replies are `{ok:false, panelId?, detail}` and carry
146
+ // no `failure` key at all (see AgentPanelHostResult) — gating on `failure`
147
+ // discarded every real host error and relabelled it "malformed result", which
148
+ // is exactly the mislabelling this whole change exists to prevent.
149
+ // The bridges also disagree on the key: panel uses `error`, browser `detail`.
150
+ if (typeof candidate.error === "string" && candidate.error) {
151
+ return boundedHostDetail(candidate.error);
152
+ }
153
+ if (typeof candidate.detail === "string" && candidate.detail) {
154
+ return boundedHostDetail(candidate.detail);
155
+ }
156
+ // Classification only — never the raw string. `failure` arrives from
157
+ // host-parsed JSON, so echoing an unrecognized value verbatim would let the
158
+ // host inject arbitrary unbounded text into a tool result.
159
+ if (typeof candidate.failure === "string") {
160
+ return HOST_LOOPBACK_FAILURE_DETAIL[candidate.failure];
161
+ }
162
+ return undefined;
163
+ }
164
+ /**
165
+ * Normalize what a host-loopback resolver received into either the parsed host
166
+ * payload or a classified failure. Shared by all four bridges so their terminal
167
+ * semantics can't drift apart.
168
+ *
169
+ * Accepts `{approved:true, answer:<json>}` (the ApprovalResult shape main replies
170
+ * with), a bare JSON string, or an InternalPendingCancellation sentinel.
171
+ */
172
+ function parseHostLoopbackDecision(decision, label) {
173
+ const cancelled = asInternalCancellation(decision);
174
+ if (cancelled) {
175
+ return {
176
+ ok: false,
177
+ failure: cancelled.failure,
178
+ detail: `${label} ${HOST_LOOPBACK_FAILURE_DETAIL[cancelled.failure]}`,
179
+ };
180
+ }
181
+ let raw;
182
+ if (decision && typeof decision === "object" && "approved" in decision) {
183
+ const result = decision;
184
+ raw = result.approved ? result.answer : undefined;
185
+ }
186
+ else if (typeof decision === "string") {
187
+ raw = decision;
188
+ }
189
+ if (raw === undefined) {
190
+ return {
191
+ ok: false,
192
+ failure: "denied",
193
+ detail: `${label} ${HOST_LOOPBACK_FAILURE_DETAIL.denied}`,
194
+ };
195
+ }
196
+ try {
197
+ return { ok: true, value: JSON.parse(raw) };
198
+ }
199
+ catch {
200
+ // The host answered but garbled it. That is NOT a denial — blaming the user
201
+ // for a host serialization bug sends the model looking in the wrong place.
202
+ return {
203
+ ok: false,
204
+ failure: "malformed",
205
+ detail: `${label} ${HOST_LOOPBACK_FAILURE_DETAIL.malformed}`,
206
+ };
207
+ }
208
+ }
209
+ function safeApprovalToolName(toolName) {
210
+ const firstLine = toolName.split(/\r?\n/, 1)[0]?.trim() ?? "";
211
+ if (/(?:sk|api|token|secret)[-_][a-z0-9_-]{6,}/i.test(firstLine))
212
+ return "工具";
213
+ return (firstLine
214
+ .replace(/[^\p{L}\p{N}_.:@/-]+/gu, " ")
215
+ .trim()
216
+ .slice(0, 40) || "工具");
217
+ }
43
218
  export class AgentServer {
44
- chatManager;
219
+ /** Host-supplied manager; requests route through the `chatManager` getter. */
220
+ baseChatManager;
221
+ /** Identity resolution hook; null → single-manager behavior (today's path). */
222
+ resolveIdentity;
223
+ /** Lazily-derived managers keyed by identity (non-base identities only). */
224
+ identityManagers = new Map();
45
225
  legacyEngine;
46
226
  globalQueryEngine = null;
47
227
  transport;
@@ -49,15 +229,35 @@ export class AgentServer {
49
229
  settingsReader;
50
230
  /** Disk-only active-goal reader for agent/goalGet on a non-live session. */
51
231
  readActiveGoalFromDisk;
232
+ updateActiveGoalOnDisk;
233
+ clearActiveGoalOnDisk;
52
234
  workspaceBridgeEnabled;
235
+ panelBridgeEnabled;
236
+ connectionId;
237
+ strictApprovalRouting;
238
+ approvalRouter;
239
+ approvalConnectionUnregister = null;
240
+ disconnected = false;
241
+ pendingApprovalTargets = new Map();
242
+ /** Extension modules this server consults for protocol-level hooks. */
243
+ extensionModules;
244
+ /** Protocol lifecycle observers created by extension modules (registration order). */
245
+ protocolObservers = [];
246
+ /** Extension-registered protocol method/query aliases (compat channel). */
247
+ protocolQueryHandlers = new Map();
248
+ /** Union of extension hiddenSessionKinds, excluded from generic session lists. */
249
+ hiddenSessionKinds;
53
250
  /**
54
251
  * Last per-session EngineConfigSlice supplied by agent/run. Kept even after
55
252
  * idle eviction so background-completion wakeups can rebuild the ChatSession
56
- * with the same cwd/permission/trust inputs before draining the queue.
253
+ * with the same cwd/trust inputs before draining the queue. Per-turn
254
+ * permission/plan overrides deliberately do not survive into wakeup turns.
57
255
  */
58
256
  lastSliceBySession = new Map();
59
257
  /** Lazy disk reader for cold wakeup rehydrate when this process lacks a slice. */
60
258
  diskSessionReader = null;
259
+ /** Sessions dir for diskSessionReader; undefined → default sessions root. */
260
+ sessionDiskRoot;
61
261
  /**
62
262
  * Monotonic config-reload version, bumped per reloadSettings request so each
63
263
  * Engine.refreshRuntimeConfig can drop out-of-order (stale) deliveries (Q5).
@@ -98,16 +298,87 @@ export class AgentServer {
98
298
  * itself outlives the server (it's a process-local singleton).
99
299
  */
100
300
  bgAgentBusUnsubscribe = null;
301
+ wakeupsInFlight = new Set();
302
+ /**
303
+ * Effective session manager for the connection this server serves. Without
304
+ * a resolveIdentity hook this is exactly the host-supplied manager —
305
+ * today's single-manager behavior, byte-for-byte. With the hook, requests
306
+ * route through a lazily-created per-identity manager, so the same
307
+ * sessionId under two identities resolves to two isolated ChatSessions and
308
+ * a connection can only list/stream sessions of its own identity.
309
+ */
310
+ get chatManager() {
311
+ if (!this.baseChatManager || !this.resolveIdentity)
312
+ return this.baseChatManager;
313
+ const identity = this.resolveIdentity({ connectionId: this.connectionId });
314
+ if (identity === this.baseChatManager.identity)
315
+ return this.baseChatManager;
316
+ let manager = this.identityManagers.get(identity);
317
+ if (!manager) {
318
+ // forIdentity validates the identity as a safe path segment and throws
319
+ // on garbage — the request that triggered routing then fails closed.
320
+ manager = this.baseChatManager.forIdentity(identity);
321
+ this.identityManagers.set(identity, manager);
322
+ }
323
+ return manager;
324
+ }
101
325
  constructor(options) {
102
- this.chatManager = options.chatManager ?? null;
326
+ this.baseChatManager = options.chatManager ?? null;
327
+ this.resolveIdentity = options.resolveIdentity ?? null;
328
+ this.sessionDiskRoot = options.sessionDiskRoot;
103
329
  this.legacyEngine = options.engine ?? null;
104
330
  this.settingsReader = options.settingsReader ?? null;
105
331
  this.readActiveGoalFromDisk = options.readActiveGoalFromDisk ?? null;
332
+ this.updateActiveGoalOnDisk = options.updateActiveGoalOnDisk ?? null;
333
+ this.clearActiveGoalOnDisk = options.clearActiveGoalOnDisk ?? null;
106
334
  this.workspaceBridgeEnabled = options.workspaceBridge === true;
107
- if (!this.chatManager && !this.legacyEngine) {
335
+ this.panelBridgeEnabled = options.panelBridge === true;
336
+ this.connectionId = options.connectionId ?? "legacy-agent-server";
337
+ this.strictApprovalRouting = options.connectionId !== undefined;
338
+ this.approvalRouter = options.approvalRouter ?? getApprovalRouter();
339
+ if (!this.strictApprovalRouting) {
340
+ this.approvalRouter.deregister(this.connectionId, "legacy approval host replaced");
341
+ }
342
+ getInteractiveApprovalBackend().clearPromptFn();
343
+ if (!this.baseChatManager && !this.legacyEngine) {
108
344
  throw new Error("AgentServer: either chatManager or engine must be supplied");
109
345
  }
110
346
  this.transport = options.transport;
347
+ // Protocol lifecycle observers. Each configured extension module may
348
+ // attach one; the server calls them at every lifecycle hook point and
349
+ // isolates per-observer failures. Core default-loads the pet extension
350
+ // until the pet domain moves out of core (see AgentServerOptions).
351
+ this.extensionModules = options.extensionModules ?? [];
352
+ this.hiddenSessionKinds = [
353
+ ...new Set(this.extensionModules.flatMap((module) => module.hiddenSessionKinds ?? [])),
354
+ ];
355
+ const observerHost = {
356
+ getLiveSessionSnapshot: () => this.chatManager?.getLiveSessionSnapshot().sessions ?? [],
357
+ projectionGeneration: () => this.workerGeneration(),
358
+ getSessionKind: (sessionId) => {
359
+ const session = this.chatManager?.get(sessionId);
360
+ if (!session)
361
+ return undefined;
362
+ return session.engine
363
+ .getSessionManager?.()
364
+ .readSessionKind?.(sessionId);
365
+ },
366
+ isTransportDisconnected: () => this.disconnected,
367
+ notify: (method, params) => this.notify(method, params),
368
+ registerQuery: (type, handler) => {
369
+ this.protocolQueryHandlers.set(type, handler);
370
+ },
371
+ };
372
+ for (const module of this.extensionModules) {
373
+ if (!module.createProtocolObserver)
374
+ continue;
375
+ try {
376
+ this.protocolObservers.push(module.createProtocolObserver(observerHost));
377
+ }
378
+ catch (err) {
379
+ logger.warn(`protocol observer init failed for extension ${module.id}: ${err.message}`);
380
+ }
381
+ }
111
382
  // Wire up incoming messages
112
383
  this.transport.onMessage((msg) => {
113
384
  if (isRequest(msg)) {
@@ -116,9 +387,10 @@ export class AgentServer {
116
387
  });
117
388
  }
118
389
  });
119
- setInteractiveApprovalFn((request) => {
120
- return this.requestApprovalFromClient(request);
121
- });
390
+ this.approvalConnectionUnregister = this.approvalRouter.addConnection(this.connectionId, {
391
+ requestApproval: (request, target) => this.requestApprovalFromClient(request, target),
392
+ ownershipLost: (targets, reason) => this.failClosedApprovalTargets(targets, reason),
393
+ }, { claimUnownedSessions: !this.strictApprovalRouting });
122
394
  if (this.legacyEngine) {
123
395
  // Only wire an interactive askUser when a human is present. For
124
396
  // unattended (headless) runs we leave askUser undefined so
@@ -138,8 +410,11 @@ export class AgentServer {
138
410
  // polls (background-jobs.ts). The bus now guarantees a real sessionId
139
411
  // (no more legacy-bucket coercion to ""), so we forward whatever sid it
140
412
  // hands us.
141
- this.bgAgentBusUnsubscribe = agentNotificationBus.subscribe((sessionId, event) => {
142
- this.notify(Methods.StreamEvent, { sessionId, event });
413
+ this.bgAgentBusUnsubscribe = agentNotificationBus.subscribe((envelope) => {
414
+ const sessionId = envelope.to.sessionId;
415
+ const event = notificationEnvelopeToLegacyStreamEvent(envelope);
416
+ if (event)
417
+ this.notify(Methods.StreamEvent, { sessionId, event });
143
418
  // Background work that finishes while the session is IDLE (a
144
419
  // run_in_background Bash like a download, a background sub-agent, or a
145
420
  // video poll — the engine no longer parks on any of them) would otherwise
@@ -151,7 +426,8 @@ export class AgentServer {
151
426
  // (trigger B) drains it at end-of-turn instead. A never-exiting dev server
152
427
  // emits no completion, so it never wakes anything (no task/service
153
428
  // classification needed).
154
- this.maybeWakeIdleSession(sessionId);
429
+ if (envelope.kind === "result")
430
+ this.maybeWakeIdleSession(sessionId);
155
431
  });
156
432
  // Notify client we're ready
157
433
  this.notify(Methods.Status, { status: "ready" });
@@ -174,48 +450,60 @@ export class AgentServer {
174
450
  * the first drains all currently-pending items; subsequent bus events for
175
451
  * the same session find it busy (or find an empty queue) and no-op.
176
452
  *
177
- * INVARIANT (the burst-merge correctness depends on it): the merge only
178
- * holds because (a) the bus fans out to subscribers SYNCHRONOUSLY, and (b)
179
- * `enqueueTurn` sets the session's `active` (→ isBusy()===true) SYNCHRONOUSLY
180
- * before its first await. So when bus events #2..N arrive — still on the same
181
- * synchronous fan-out as #1 — they already see the session as busy and no-op.
182
- * If either path is ever made async (e.g. an await is added inside
183
- * enqueueTurn before `active` is set, or the bus starts deferring delivery),
184
- * this degrades into N concurrent wakeups for N completions. Keep both
185
- * synchronous, or replace this comment's assumption with an explicit
186
- * "wakeup in flight" guard flag.
453
+ * `wakeupsInFlight` serializes the now-async rehydrate path, so a burst of
454
+ * completion events cannot create duplicate sessions or enqueue duplicate
455
+ * wakeup turns while getOrCreate waits for a closing generation to settle.
187
456
  */
188
457
  maybeWakeIdleSession(sessionId) {
189
- if (!this.chatManager)
458
+ if (this.wakeupsInFlight.has(sessionId))
190
459
  return;
191
- const session = this.chatManager.get(sessionId) ?? this.rehydrateSessionForWake(sessionId);
460
+ this.wakeupsInFlight.add(sessionId);
461
+ void this.wakeIdleSession(sessionId)
462
+ .then((ranTurn) => {
463
+ this.wakeupsInFlight.delete(sessionId);
464
+ if (ranTurn && notificationQueue.getSnapshot(sessionId).length > 0) {
465
+ this.maybeWakeIdleSession(sessionId);
466
+ }
467
+ })
468
+ .catch(() => {
469
+ this.wakeupsInFlight.delete(sessionId);
470
+ });
471
+ }
472
+ async wakeIdleSession(sessionId) {
473
+ if (!this.chatManager)
474
+ return false;
475
+ if (this.chatManager.isUnavailable(sessionId))
476
+ return false;
477
+ const session = this.chatManager.get(sessionId) ?? (await this.rehydrateSessionForWake(sessionId));
192
478
  if (!session || session.isBusy())
193
- return;
479
+ return false;
194
480
  // Headless / automation runs are one-shot: the caller takes result.text and
195
481
  // is gone, so there's no consumer for a woken continuation turn. Headless
196
482
  // already drained its background sub-agents inside engine.run before
197
483
  // returning; any remaining queued notification (video/shell) must NOT spin
198
484
  // an orphan turn. Only the interactive path auto-continues.
199
485
  if (session.engine.isHeadless())
200
- return;
486
+ return false;
201
487
  // Don't resurrect a session the user just Stopped: cancel() leaves it idle
202
488
  // (active=null) so isBusy() reads false, but auto-running a fresh turn here
203
489
  // would defeat the Stop. The flag clears the moment the user sends again.
204
490
  if (session.wasCancelledSinceLastTurn())
205
- return;
491
+ return false;
206
492
  const pending = notificationQueue.drainAll(sessionId);
207
493
  if (pending.length === 0)
208
- return;
494
+ return false;
209
495
  const task = `<system-reminder>\n${buildNotificationMessage(pending)}\n</system-reminder>`;
210
- void session
211
- .enqueueTurn(task, {
212
- // Synthetic notification, not the user's own input: persisted with an
213
- // `injected` flag so a disk rebuild doesn't render it as a phantom user
214
- // bubble (the live UI shows only the woken assistant's reply).
215
- injected: true,
216
- onStream: (event) => this.notify(Methods.StreamEvent, { sessionId, event }),
217
- })
218
- .catch((err) => {
496
+ try {
497
+ await session.enqueueTurn(task, {
498
+ // Synthetic notification, not the user's own input: persisted with an
499
+ // `injected` flag so a disk rebuild doesn't render it as a phantom user
500
+ // bubble (the live UI shows only the woken assistant's reply).
501
+ injected: true,
502
+ onStream: (event) => this.notify(Methods.StreamEvent, { sessionId, event }),
503
+ approvalRouter: this.approvalRouter,
504
+ });
505
+ }
506
+ catch (err) {
219
507
  // A wakeup turn failing must not crash the bus fan-out. The drained
220
508
  // notifications are already in the transcript via the run's messages;
221
509
  // log and move on.
@@ -236,21 +524,14 @@ export class AgentServer {
236
524
  sessionId,
237
525
  event: { type: "error", error: err?.message ?? "background wakeup failed" },
238
526
  });
239
- })
240
- .finally(() => {
241
- // Run-boundary re-check (trigger B): the woken summarize turn may have
242
- // spawned NEW background work (e.g. goal "generate 2 videos" → after #1
243
- // completes, this turn submits #2). When #2 finishes its notification
244
- // arrives while we're idle again — but if it arrived DURING this turn
245
- // (busy), trigger A skipped it. Re-checking at the run boundary drains
246
- // anything that landed while busy, chaining wakeups until the queue is
247
- // truly empty. Replaces the old engine for(;;) outer loop.
248
- this.maybeWakeIdleSession(sessionId);
249
- });
527
+ }
528
+ return true;
250
529
  }
251
- rehydrateSessionForWake(sessionId) {
530
+ async rehydrateSessionForWake(sessionId) {
252
531
  if (!this.chatManager)
253
532
  return null;
533
+ if (this.chatManager.isUnavailable(sessionId))
534
+ return null;
254
535
  try {
255
536
  const pending = notificationQueue.getSnapshot(sessionId);
256
537
  if (pending.length === 0) {
@@ -273,7 +554,7 @@ export class AgentServer {
273
554
  });
274
555
  return null;
275
556
  }
276
- const session = this.chatManager.getOrCreate(sessionId, slice);
557
+ const session = await this.chatManager.getOrCreate(sessionId, slice);
277
558
  this.wireInteractiveSession(session, sessionId);
278
559
  logger.debug("bg_wakeup.rehydrated_session", {
279
560
  sessionId,
@@ -295,12 +576,11 @@ export class AgentServer {
295
576
  if (cached)
296
577
  return { ...cached };
297
578
  if (!this.diskSessionReader)
298
- this.diskSessionReader = new SessionManager();
299
- const cwd = this.diskSessionReader.readCwd(sessionId);
579
+ this.diskSessionReader = new SessionManager(this.sessionDiskRoot);
580
+ const cwd = this.diskSessionReader.readSessionMainRoot(sessionId);
300
581
  if (!cwd)
301
582
  return null;
302
583
  return {
303
- permissionMode: "default",
304
584
  projectTrusted: false,
305
585
  cwd,
306
586
  };
@@ -314,9 +594,88 @@ export class AgentServer {
314
594
  session.engine.setAskUser((question, opts) => this.requestAskUserForSession(session, sid, question, opts));
315
595
  session.engine.setBrowserBridge(this.makeBrowserBridge(session, sid));
316
596
  session.engine.setInjectCredential((credentialId, credentialScope) => this.requestCredentialInjectForSession(session, sid, credentialId, credentialScope));
597
+ session.engine.setSessionMessageRouter((input) => this.routeSessionMessage(input));
317
598
  if (this.workspaceBridgeEnabled && typeof session.engine.setWorkspaceBridge === "function") {
318
599
  session.engine.setWorkspaceBridge(this.makeWorkspaceBridge(session, sid));
319
600
  }
601
+ if (this.panelBridgeEnabled && typeof session.engine.setPanelBridge === "function") {
602
+ session.engine.setPanelBridge(this.makePanelBridge(session, sid));
603
+ }
604
+ }
605
+ /** Queue a model-sent message as an ordinary user turn in another Session. */
606
+ async routeSessionMessage(input) {
607
+ const manager = this.chatManager;
608
+ if (!manager)
609
+ throw new Error("cross-Session messaging requires a multi-session host");
610
+ const targetId = input.target.sessionId;
611
+ if (manager.isUnavailable(targetId)) {
612
+ throw new Error(`target Session is closing or closed: ${targetId}`);
613
+ }
614
+ const sourceSlice = this.lastSliceBySession.get(input.sourceSessionId);
615
+ const targetSlice = {
616
+ cwd: input.target.workspaceRoot,
617
+ projectTrusted: sourceSlice?.projectTrusted ?? false,
618
+ };
619
+ const targetAlreadyExists = manager.sessionExistsOnDisk(targetId, targetSlice);
620
+ const targetSession = await manager.getOrCreate(targetId, targetSlice);
621
+ const approvalRegistration = this.approvalRouter.register(targetId, this.connectionId);
622
+ if (!approvalRegistration.ok) {
623
+ throw new Error(`target Session ${targetId} is owned by another connection`);
624
+ }
625
+ this.rememberSessionSlice(targetId, targetSlice);
626
+ this.observeSessionAttached(targetId, targetSession.lastActivityAt);
627
+ this.wireInteractiveSession(targetSession, targetId);
628
+ const userMessageEvent = {
629
+ type: "session_user_message",
630
+ text: input.message,
631
+ };
632
+ this.observeSessionStream(targetId, userMessageEvent);
633
+ this.notify(Methods.StreamEvent, { sessionId: targetId, event: userMessageEvent });
634
+ const run = targetSession.enqueueTurn(input.message, {
635
+ cwd: input.target.workspaceRoot,
636
+ // A planned Session needs its renderer-selected initial profile. Once a
637
+ // Session exists, omitting this field makes Engine use the target's own
638
+ // persisted binding, so a stale source catalog cannot switch it back.
639
+ workspaceProfile: targetAlreadyExists ? undefined : input.target.workspaceProfile,
640
+ sessionMessageTargets: input.catalog,
641
+ onStream: (event) => {
642
+ this.observeSessionStream(targetId, event);
643
+ this.notify(Methods.StreamEvent, { sessionId: targetId, event });
644
+ },
645
+ approvalRouter: this.approvalRouter,
646
+ });
647
+ // The turn itself must NOT block the sender — a member's work can take
648
+ // minutes and the lead has more to dispatch. But a turn that fails to *start*
649
+ // (most often: the target's digital human is not installed, so run-setup
650
+ // throws "Workspace profile ... is unavailable") used to be reported only to
651
+ // the log while SendMessageToSession still answered "has queued the turn".
652
+ // A real lead then waited two hours and re-sent, never learning the reason.
653
+ // So: surface an immediate failure to the caller, keep a slow turn detached.
654
+ let startupError;
655
+ const tracked = run
656
+ .then(() => this.maybeWakeIdleSession(targetId))
657
+ .catch((error) => {
658
+ startupError = error;
659
+ logger.warn("session_message.turn_failed", {
660
+ sourceSessionId: input.sourceSessionId,
661
+ targetSessionId: targetId,
662
+ error: error instanceof Error ? error.message : String(error),
663
+ });
664
+ this.notify(Methods.StreamEvent, {
665
+ sessionId: targetId,
666
+ event: {
667
+ type: "error",
668
+ error: error instanceof Error ? error.message : "cross-Session message failed",
669
+ },
670
+ });
671
+ });
672
+ // One macrotask is enough for a synchronous-throw path to settle; anything
673
+ // still running by then is genuine work and stays detached.
674
+ await Promise.race([tracked, new Promise((resolve) => setTimeout(resolve, 0))]);
675
+ if (startupError) {
676
+ throw startupError instanceof Error ? startupError : new Error(String(startupError));
677
+ }
678
+ void tracked;
320
679
  }
321
680
  // ─── Request Dispatch ───────────────────────────────────────────
322
681
  async handleRequest(req) {
@@ -324,6 +683,9 @@ export class AgentServer {
324
683
  case Methods.Run:
325
684
  await this.handleRun(req);
326
685
  break;
686
+ case Methods.ForkSession:
687
+ await this.handleForkSession(req);
688
+ break;
327
689
  case Methods.Approve:
328
690
  this.handleApprove(req);
329
691
  break;
@@ -346,14 +708,23 @@ export class AgentServer {
346
708
  this.handleUnsteer(req);
347
709
  break;
348
710
  case Methods.CloseSession:
349
- this.handleCloseSession(req);
711
+ await this.handleCloseSession(req);
350
712
  break;
351
713
  case Methods.ReleaseWorkspace:
352
714
  this.handleReleaseWorkspace(req);
353
715
  break;
716
+ case Methods.SetWorkspace:
717
+ this.handleSetWorkspace(req);
718
+ break;
354
719
  case Methods.GoalExtend:
355
720
  this.handleGoalExtend(req);
356
721
  break;
722
+ case Methods.GoalUpdate:
723
+ await this.handleGoalUpdate(req);
724
+ break;
725
+ case Methods.GoalDelete:
726
+ this.handleGoalClear(req, true);
727
+ break;
357
728
  case Methods.GoalClear:
358
729
  this.handleGoalClear(req);
359
730
  break;
@@ -366,11 +737,326 @@ export class AgentServer {
366
737
  case Methods.BackgroundWork:
367
738
  this.handleBackgroundWork(req);
368
739
  break;
740
+ case Methods.PluginCommandsList:
741
+ this.handlePluginCommandsList(req);
742
+ break;
743
+ case Methods.PluginCommandExpand:
744
+ this.handlePluginCommandExpand(req);
745
+ break;
746
+ // Compat channel: the wire method name is retained, but the handler is
747
+ // whatever extension registered it (the pet extension by default).
748
+ case Methods.GetPetProjectionSnapshot:
749
+ await this.handleExtensionProtocolMethod(req);
750
+ break;
369
751
  default:
370
752
  this.transport.send(createErrorResponse(req.id, ErrorCodes.MethodNotFound, `Unknown method: ${req.method}`));
371
753
  }
372
754
  }
755
+ pluginCommandContext(cwd) {
756
+ if (typeof cwd !== "string" ||
757
+ cwd.trim().length === 0 ||
758
+ cwd.length > 4_096 ||
759
+ cwd.includes("\0")) {
760
+ return { error: "cwd must be a non-empty string of at most 4096 characters" };
761
+ }
762
+ const disabledPluginNames = new Set(computeEffectiveDisabledLists(new SettingsManager(cwd, "full"), cwd).disabledPlugins);
763
+ return { cwd, disabledPluginNames };
764
+ }
765
+ handlePluginCommandsList(req) {
766
+ const params = (req.params ?? {});
767
+ const context = this.pluginCommandContext(params.cwd);
768
+ if ("error" in context) {
769
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, context.error));
770
+ return;
771
+ }
772
+ try {
773
+ this.transport.send(createResponse(req.id, {
774
+ commands: describePluginCommands(scanPluginCommands(), context.disabledPluginNames),
775
+ }));
776
+ }
777
+ catch (error) {
778
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InternalError, error.message));
779
+ }
780
+ }
781
+ handlePluginCommandExpand(req) {
782
+ const params = (req.params ?? {});
783
+ const context = this.pluginCommandContext(params.cwd);
784
+ if ("error" in context) {
785
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, context.error));
786
+ return;
787
+ }
788
+ if (typeof params.name !== "string" ||
789
+ params.name.length === 0 ||
790
+ params.name.length > 512 ||
791
+ params.name.includes("\0")) {
792
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "name must be a non-empty string of at most 512 characters"));
793
+ return;
794
+ }
795
+ const rawArguments = params.rawArguments ?? "";
796
+ if (typeof rawArguments !== "string" ||
797
+ rawArguments.length > MAX_PLUGIN_COMMAND_ARGUMENT_CHARS) {
798
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, `rawArguments must be a string of at most ${MAX_PLUGIN_COMMAND_ARGUMENT_CHARS} characters`));
799
+ return;
800
+ }
801
+ const command = scanPluginCommands().find((candidate) => candidate.name === params.name && !context.disabledPluginNames.has(candidate.pluginName));
802
+ if (!command) {
803
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, `plugin command is unavailable: ${params.name}`));
804
+ return;
805
+ }
806
+ try {
807
+ this.transport.send(createResponse(req.id, {
808
+ prompt: expandPluginCommandBody(command.body, rawArguments),
809
+ }));
810
+ }
811
+ catch (error) {
812
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, error.message));
813
+ }
814
+ }
815
+ /** Route a protocol method to its extension-registered handler, if any. */
816
+ async handleExtensionProtocolMethod(req) {
817
+ const handler = this.protocolQueryHandlers.get(req.method);
818
+ if (!handler) {
819
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.MethodNotFound, `Unknown method: ${req.method}`));
820
+ return;
821
+ }
822
+ try {
823
+ const data = await handler((req.params ?? {}));
824
+ this.transport.send(createResponse(req.id, data));
825
+ }
826
+ catch (err) {
827
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InternalError, err.message));
828
+ }
829
+ }
830
+ // ─── Protocol observers ─────────────────────────────────────────
831
+ // Domain-agnostic lifecycle dispatch. Observers run in registration order
832
+ // and a throwing observer never breaks the protocol path or its peers.
833
+ forEachObserver(hook, fn) {
834
+ for (const observer of this.protocolObservers) {
835
+ try {
836
+ fn(observer);
837
+ }
838
+ catch (err) {
839
+ logger.warn(`protocol observer ${hook} hook failed: ${err.message}`);
840
+ }
841
+ }
842
+ }
843
+ observeSessionAttached(sessionId, lastActivityAt) {
844
+ this.forEachObserver("onSessionAttached", (observer) => observer.onSessionAttached?.(sessionId, lastActivityAt));
845
+ }
846
+ observeSessionStream(sessionId, event) {
847
+ this.forEachObserver("onSessionStream", (observer) => observer.onSessionStream?.(sessionId, event));
848
+ }
849
+ observeRunBoundary(sessionId, phase) {
850
+ this.forEachObserver("onRunBoundary", (observer) => observer.onRunBoundary?.(sessionId, phase));
851
+ }
852
+ /** Observers may replace approval metadata (e.g. surfaceable overrides). */
853
+ observeApprovalCreated(metadata) {
854
+ for (const observer of this.protocolObservers) {
855
+ try {
856
+ const next = observer.onApprovalCreated?.(metadata);
857
+ if (next)
858
+ metadata = next;
859
+ }
860
+ catch (err) {
861
+ logger.warn(`protocol observer onApprovalCreated hook failed: ${err.message}`);
862
+ }
863
+ }
864
+ return metadata;
865
+ }
866
+ observeApprovalTransition(metadata, status) {
867
+ this.forEachObserver("onApprovalTransition", (observer) => observer.onApprovalTransition?.(metadata, status));
868
+ }
869
+ observeSessionClosed(sessionId) {
870
+ this.forEachObserver("onSessionClosed", (observer) => observer.onSessionClosed?.(sessionId));
871
+ }
872
+ observeServerClose() {
873
+ this.forEachObserver("onServerClose", (observer) => observer.onServerClose?.());
874
+ }
875
+ /** Generic run-param shape checks plus each extension's domain validation. */
876
+ validateRunInput(params) {
877
+ const generic = runInputError(params);
878
+ if (generic)
879
+ return generic;
880
+ for (const module of this.extensionModules) {
881
+ if (!module.validateRunParams)
882
+ continue;
883
+ const error = module.validateRunParams(params);
884
+ if (error)
885
+ return error;
886
+ }
887
+ return null;
888
+ }
373
889
  // ─── Run ────────────────────────────────────────────────────────
890
+ async handleForkSession(req) {
891
+ const params = req.params ?? {};
892
+ const sourceSessionId = typeof params.sourceSessionId === "string" ? params.sourceSessionId : "";
893
+ const targetSessionId = typeof params.targetSessionId === "string" ? params.targetSessionId : undefined;
894
+ const throughEventId = typeof params.throughEventId === "string" ? params.throughEventId : undefined;
895
+ const fromEventId = typeof params.fromEventId === "string" ? params.fromEventId : undefined;
896
+ const toEventId = typeof params.toEventId === "string" ? params.toEventId : undefined;
897
+ const mode = params.mode;
898
+ const isSideFork = params.forkKind === "side";
899
+ if (!sourceSessionId || (mode !== "full" && mode !== "summary")) {
900
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "forkSession requires sourceSessionId and mode=full|summary"));
901
+ return;
902
+ }
903
+ if (params.targetSessionId !== undefined &&
904
+ (typeof params.targetSessionId !== "string" || params.targetSessionId.length === 0)) {
905
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "targetSessionId must be a non-empty string"));
906
+ return;
907
+ }
908
+ if (params.throughEventId !== undefined && typeof params.throughEventId !== "string") {
909
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "throughEventId must be a string"));
910
+ return;
911
+ }
912
+ if (mode === "summary" && (!fromEventId || !toEventId)) {
913
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "summary fork requires fromEventId and toEventId"));
914
+ return;
915
+ }
916
+ if (mode === "summary" &&
917
+ (params.throughEventId !== undefined ||
918
+ params.forkKind !== undefined ||
919
+ params.quickChatClaimId !== undefined)) {
920
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "summary fork only accepts fromEventId/toEventId range fields"));
921
+ return;
922
+ }
923
+ if (mode === "full" && (params.fromEventId !== undefined || params.toEventId !== undefined)) {
924
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "full fork does not accept summary range fields"));
925
+ return;
926
+ }
927
+ if (mode === "full" &&
928
+ params.quickChatClaimId !== undefined &&
929
+ typeof params.quickChatClaimId !== "string") {
930
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "quickChatClaimId must be a string"));
931
+ return;
932
+ }
933
+ if (params.forkKind !== undefined && !isSideFork) {
934
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "forkKind must be side when present"));
935
+ return;
936
+ }
937
+ if (isSideFork && throughEventId !== undefined) {
938
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "side fork uses the last completed turn and cannot specify throughEventId"));
939
+ return;
940
+ }
941
+ try {
942
+ assertSafeSessionId(sourceSessionId);
943
+ if (targetSessionId !== undefined)
944
+ assertSafeSessionId(targetSessionId);
945
+ }
946
+ catch (err) {
947
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, err instanceof Error ? err.message : String(err)));
948
+ return;
949
+ }
950
+ let source;
951
+ try {
952
+ source = this.chatManager?.get(sourceSessionId);
953
+ if (!source && this.chatManager) {
954
+ if (this.chatManager.isUnavailable(sourceSessionId)) {
955
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.SessionClosed, `Session is closing or closed: ${sourceSessionId}`));
956
+ return;
957
+ }
958
+ source =
959
+ (await this.chatManager.getOrCreatePersisted(sourceSessionId, this.lastSliceBySession.get(sourceSessionId), mode === "summary")) ?? undefined;
960
+ if (source) {
961
+ this.rememberSessionSlice(sourceSessionId, {
962
+ ...this.lastSliceBySession.get(sourceSessionId),
963
+ cwd: source.engine.getConfig().cwd,
964
+ });
965
+ }
966
+ }
967
+ }
968
+ catch (err) {
969
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.Overloaded, err instanceof Error ? err.message : String(err)));
970
+ return;
971
+ }
972
+ if (!isSideFork && source && (source.isBusy() || source.queueDepth() > 0)) {
973
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.Overloaded, "source session is still producing or has queued turns"));
974
+ return;
975
+ }
976
+ const engine = source?.engine ?? this.legacyEngine;
977
+ if (!engine || !engine.sessionExistsOnDisk(sourceSessionId)) {
978
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.SessionNotFound, `Session not found: ${sourceSessionId}`));
979
+ return;
980
+ }
981
+ if (targetSessionId && engine.sessionExistsOnDisk(targetSessionId)) {
982
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, `Session already exists: ${targetSessionId}`));
983
+ return;
984
+ }
985
+ try {
986
+ if (mode === "summary") {
987
+ const sourceRange = { fromEventId: fromEventId, toEventId: toEventId };
988
+ const packageAndPublish = async (signal) => {
989
+ const selected = engine.selectContextPackage(sourceSessionId, sourceRange);
990
+ const packaged = await engine.summarizeContextPackage(selected.messages, signal, sourceSessionId);
991
+ const result = engine.createSummaryFork(sourceSessionId, {
992
+ targetSessionId,
993
+ ...sourceRange,
994
+ summary: packaged.summary,
995
+ sourceEventCount: selected.sourceEventCount,
996
+ estimatedTokens: packaged.estimatedTokens,
997
+ });
998
+ return { selected, packaged, result };
999
+ };
1000
+ let packagedResult;
1001
+ if (source) {
1002
+ packagedResult = await source.runExclusive(packageAndPublish);
1003
+ }
1004
+ else {
1005
+ if (this.running)
1006
+ throw new Error("source session is still producing or has queued turns");
1007
+ this.running = true;
1008
+ this.abortController = new AbortController();
1009
+ try {
1010
+ packagedResult = await packageAndPublish(this.abortController.signal);
1011
+ }
1012
+ finally {
1013
+ this.abortController = null;
1014
+ this.running = false;
1015
+ }
1016
+ }
1017
+ const { packaged, result } = packagedResult;
1018
+ const workspace = result.bundle.state.workspace ?? {
1019
+ root: result.bundle.state.cwd,
1020
+ kind: "main",
1021
+ };
1022
+ this.transport.send(createResponse(req.id, {
1023
+ sessionId: result.bundle.state.sessionId,
1024
+ mode: "summary",
1025
+ summary: packaged.summary,
1026
+ sourceRange,
1027
+ estimatedTokens: packaged.estimatedTokens,
1028
+ forkedFrom: result.lineage,
1029
+ workspace,
1030
+ }));
1031
+ return;
1032
+ }
1033
+ const result = engine.forkSession(sourceSessionId, isSideFork
1034
+ ? { targetSessionId, snapshotMode: "completed", ephemeral: true }
1035
+ : { targetSessionId, throughEventId });
1036
+ const workspace = result.bundle.state.workspace ?? {
1037
+ root: result.bundle.state.cwd,
1038
+ kind: "main",
1039
+ };
1040
+ this.transport.send(createResponse(req.id, {
1041
+ sessionId: result.bundle.state.sessionId,
1042
+ mode: "full",
1043
+ forkedFrom: result.lineage,
1044
+ workspace,
1045
+ copiedEventCount: result.copiedEventCount,
1046
+ }));
1047
+ }
1048
+ catch (err) {
1049
+ const message = err instanceof Error ? err.message : String(err);
1050
+ const code = /not found/i.test(message)
1051
+ ? ErrorCodes.SessionNotFound
1052
+ : /still producing|queued turns|busy/i.test(message)
1053
+ ? ErrorCodes.Overloaded
1054
+ : /already exists|invalid|cursor|metadata|unfinished|orphaned|unsupported|malformed/i.test(message)
1055
+ ? ErrorCodes.InvalidParams
1056
+ : ErrorCodes.InternalError;
1057
+ this.transport.send(createErrorResponse(req.id, code, message));
1058
+ }
1059
+ }
374
1060
  async handleRun(req) {
375
1061
  // ── ChatSessionManager path (multi-session) ──────────────────
376
1062
  if (this.chatManager) {
@@ -386,12 +1072,12 @@ export class AgentServer {
386
1072
  this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "sessionId is required"));
387
1073
  return;
388
1074
  }
389
- if (!params.task) {
390
- this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "task is required"));
1075
+ const inputError = this.validateRunInput(params);
1076
+ if (inputError) {
1077
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, inputError));
391
1078
  return;
392
1079
  }
393
1080
  const sessionConfig = {
394
- permissionMode: params.permissionMode,
395
1081
  cwd: params.cwd,
396
1082
  projectTrusted: params.projectTrusted,
397
1083
  goal: typeof params.goal === "string" || (params.goal != null && typeof params.goal === "object")
@@ -399,7 +1085,7 @@ export class AgentServer {
399
1085
  : undefined,
400
1086
  };
401
1087
  if (params.requireExisting === true && !cm.get(params.sessionId)) {
402
- let existsOnDisk = false;
1088
+ let existsOnDisk;
403
1089
  try {
404
1090
  existsOnDisk = cm.sessionExistsOnDisk(params.sessionId, sessionConfig);
405
1091
  }
@@ -415,13 +1101,20 @@ export class AgentServer {
415
1101
  }
416
1102
  let session;
417
1103
  try {
418
- session = cm.getOrCreate(params.sessionId, sessionConfig);
1104
+ session = await cm.getOrCreate(params.sessionId, sessionConfig);
419
1105
  }
420
1106
  catch (err) {
421
1107
  const code = err.code ?? ErrorCodes.InternalError;
422
1108
  this.transport.send(createErrorResponse(req.id, code, err.message));
423
1109
  return;
424
1110
  }
1111
+ const sid = params.sessionId;
1112
+ this.observeSessionAttached(sid, session.lastActivityAt);
1113
+ const approvalRegistration = this.approvalRouter.register(sid, this.connectionId);
1114
+ if (!approvalRegistration.ok) {
1115
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.Overloaded, `Session ${sid} is owned by another connection`));
1116
+ return;
1117
+ }
425
1118
  this.rememberSessionSlice(params.sessionId, sessionConfig);
426
1119
  if (params.model !== undefined) {
427
1120
  if (typeof params.model !== "string" || params.model.length === 0) {
@@ -436,31 +1129,61 @@ export class AgentServer {
436
1129
  return;
437
1130
  }
438
1131
  }
439
- if (typeof params.planMode === "boolean") {
440
- session.engine.setPlanMode(params.planMode);
441
- }
442
- const sid = params.sessionId;
443
1132
  // Wire AskUserQuestion and host bridges for this interactive session. The
444
1133
  // chatManager path builds a fresh per-session Engine via engineFactory, so
445
1134
  // these host callbacks must be installed after every create/recreate.
446
1135
  this.wireInteractiveSession(session, sid);
447
1136
  try {
448
- const result = await session.enqueueTurn(params.task, {
1137
+ const displayText = typeof params.displayText === "string" ? params.displayText.trim() : "";
1138
+ if (displayText) {
1139
+ const userMessageEvent = {
1140
+ type: "session_user_message",
1141
+ text: displayText,
1142
+ ...(typeof params.clientMessageId === "string"
1143
+ ? { clientMessageId: params.clientMessageId }
1144
+ : {}),
1145
+ };
1146
+ this.observeSessionStream(sid, userMessageEvent);
1147
+ this.notify(Methods.StreamEvent, { sessionId: sid, event: userMessageEvent });
1148
+ }
1149
+ const run = session.enqueueTurn(params.task, {
449
1150
  cwd: params.cwd,
1151
+ displayText: displayText || undefined,
1152
+ injected: params.injected === true,
450
1153
  attachments: Array.isArray(params.attachments) ? params.attachments : undefined,
451
1154
  goal: typeof params.goal === "string" ||
452
1155
  (params.goal != null && typeof params.goal === "object")
453
1156
  ? params.goal
454
1157
  : undefined,
455
- onStream: (event) => this.notify(Methods.StreamEvent, { sessionId: sid, event }),
1158
+ onStream: (event) => {
1159
+ this.observeSessionStream(sid, event);
1160
+ this.notify(Methods.StreamEvent, { sessionId: sid, event });
1161
+ },
456
1162
  clientMessageId: typeof params.clientMessageId === "string" ? params.clientMessageId : undefined,
1163
+ permissionMode: params.permissionMode,
1164
+ planMode: params.planMode,
1165
+ behaviorMode: params.behaviorMode,
1166
+ profileParams: params.profileParams,
1167
+ workspaceProfile: params.workspaceProfile,
1168
+ sessionBrief: params.sessionBrief,
1169
+ sessionMessageTargets: params.sessionMessageTargets,
1170
+ petRuntimeContext: params.petRuntimeContext,
1171
+ petWorkspaces: params.petWorkspaces,
1172
+ kind: params.kind,
1173
+ approvalRouter: this.approvalRouter,
457
1174
  });
1175
+ this.notify(Methods.RunAccepted, { requestId: req.id, sessionId: sid });
1176
+ this.observeRunBoundary(sid, "start");
1177
+ const result = await run;
1178
+ this.observeRunBoundary(sid, "end");
458
1179
  const runResult = {
459
1180
  text: result.text,
460
1181
  reason: result.reason,
461
1182
  sessionId: result.sessionId ?? sid,
462
1183
  turnCount: result.turnCount,
463
1184
  usage: result.usage,
1185
+ ...(result.extensions ? { extensions: result.extensions } : {}),
1186
+ petWorkDelegation: result.petWorkDelegation,
464
1187
  };
465
1188
  this.transport.send(createResponse(req.id, runResult));
466
1189
  // Run-boundary re-check (trigger B): the session is idle now. If a
@@ -471,6 +1194,7 @@ export class AgentServer {
471
1194
  this.maybeWakeIdleSession(sid);
472
1195
  }
473
1196
  catch (err) {
1197
+ this.observeRunBoundary(sid, "error");
474
1198
  this.transport.send(createErrorResponse(req.id, ErrorCodes.InternalError, err.message));
475
1199
  // Even on a failed run the session goes idle — re-check so a background
476
1200
  // completion that landed during the failed run isn't orphaned.
@@ -483,10 +1207,18 @@ export class AgentServer {
483
1207
  return;
484
1208
  }
485
1209
  const params = (req.params ?? {});
486
- if (!params.task) {
487
- this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "task is required"));
1210
+ const inputError = this.validateRunInput(params);
1211
+ if (inputError) {
1212
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, inputError));
488
1213
  return;
489
1214
  }
1215
+ if (typeof params.sessionId === "string" && params.sessionId.length > 0) {
1216
+ const approvalRegistration = this.approvalRouter.register(params.sessionId, this.connectionId);
1217
+ if (!approvalRegistration.ok) {
1218
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.Overloaded, `Session ${params.sessionId} is owned by another connection`));
1219
+ return;
1220
+ }
1221
+ }
490
1222
  if (params.cwd !== undefined && (typeof params.cwd !== "string" || params.cwd.length === 0)) {
491
1223
  this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "cwd must be a non-empty string"));
492
1224
  return;
@@ -528,16 +1260,43 @@ export class AgentServer {
528
1260
  // `signal.aborted` below it's already gone.
529
1261
  const runController = this.abortController;
530
1262
  try {
1263
+ const displayText = typeof params.displayText === "string" ? params.displayText.trim() : "";
1264
+ if (displayText) {
1265
+ streamToClient({
1266
+ type: "session_user_message",
1267
+ text: displayText,
1268
+ ...(typeof params.clientMessageId === "string"
1269
+ ? { clientMessageId: params.clientMessageId }
1270
+ : {}),
1271
+ });
1272
+ }
1273
+ this.notify(Methods.RunAccepted, {
1274
+ requestId: req.id,
1275
+ sessionId: params.sessionId ?? "",
1276
+ });
531
1277
  const result = await this.legacyEngine.run(params.task, {
532
1278
  cwd: params.cwd,
533
1279
  sessionId: params.sessionId,
1280
+ displayText: displayText || undefined,
1281
+ injected: params.injected === true,
534
1282
  signal: runController.signal,
535
1283
  onStream: streamToClient,
536
1284
  clientMessageId: typeof params.clientMessageId === "string" ? params.clientMessageId : undefined,
1285
+ attachments: Array.isArray(params.attachments) ? params.attachments : undefined,
537
1286
  goal: typeof params.goal === "string" ||
538
1287
  (params.goal != null && typeof params.goal === "object")
539
1288
  ? params.goal
540
1289
  : undefined,
1290
+ permissionMode: params.permissionMode,
1291
+ planMode: params.planMode,
1292
+ behaviorMode: params.behaviorMode,
1293
+ profileParams: params.profileParams,
1294
+ workspaceProfile: params.workspaceProfile,
1295
+ sessionBrief: params.sessionBrief,
1296
+ sessionMessageTargets: params.sessionMessageTargets,
1297
+ petRuntimeContext: params.petRuntimeContext,
1298
+ petWorkspaces: params.petWorkspaces,
1299
+ kind: params.kind,
541
1300
  });
542
1301
  const runResult = {
543
1302
  text: result.text,
@@ -545,6 +1304,8 @@ export class AgentServer {
545
1304
  sessionId: result.sessionId,
546
1305
  turnCount: result.turnCount,
547
1306
  usage: result.usage,
1307
+ ...(result.extensions ? { extensions: result.extensions } : {}),
1308
+ petWorkDelegation: result.petWorkDelegation,
548
1309
  };
549
1310
  this.transport.send(createResponse(req.id, runResult));
550
1311
  }
@@ -579,6 +1340,19 @@ export class AgentServer {
579
1340
  // ─── Approve ────────────────────────────────────────────────────
580
1341
  handleApprove(req) {
581
1342
  const params = (req.params ?? {});
1343
+ if (this.strictApprovalRouting) {
1344
+ const pending = this.pendingApprovalTargets.get(params.requestId);
1345
+ const matchesPending = pending !== undefined &&
1346
+ params.connectionId === this.connectionId &&
1347
+ params.sessionId === pending.sessionId &&
1348
+ params.connectionId === pending.connectionId &&
1349
+ params.generation === pending.generation &&
1350
+ this.approvalRouter.matches(pending);
1351
+ if (!matchesPending) {
1352
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "Approval response does not match connectionId, sessionId, generation, and requestId"));
1353
+ return;
1354
+ }
1355
+ }
582
1356
  // ChatSessionManager path: approvals are scoped by (sessionId, requestId).
583
1357
  // Never fall back from a session-tagged response to the legacy global map:
584
1358
  // a stale/misrouted UI response must fail closed instead of resolving a
@@ -589,17 +1363,19 @@ export class AgentServer {
589
1363
  this.transport.send(createErrorResponse(req.id, ErrorCodes.SessionClosed, `No such session: ${params.sessionId}`));
590
1364
  return;
591
1365
  }
592
- const resolve = s.pendingApprovals.get(params.requestId);
593
- if (!resolve) {
1366
+ const entry = s.pendingApprovals.get(params.requestId);
1367
+ if (!entry) {
594
1368
  this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, `No pending approval for session ${params.sessionId}: ${params.requestId}`));
595
1369
  return;
596
1370
  }
597
1371
  s.pendingApprovals.delete(params.requestId);
1372
+ this.observeApprovalTransition(entry.metadata, "resolved");
1373
+ this.pendingApprovalTargets.delete(params.requestId);
598
1374
  // Cancel the pending timeout for requests that arm one (browser actions,
599
1375
  // credential injection, and tool approvals). AskUserQuestion does not use
600
1376
  // a timeout; this is harmless for those request ids.
601
1377
  this.clearApprovalTimer(params.requestId);
602
- resolve(params.decision);
1378
+ entry.resolve(params.decision);
603
1379
  this.transport.send(createResponse(req.id, { ok: true }));
604
1380
  return;
605
1381
  }
@@ -610,6 +1386,7 @@ export class AgentServer {
610
1386
  return;
611
1387
  }
612
1388
  this.pendingApprovals.delete(params.requestId);
1389
+ this.pendingApprovalTargets.delete(params.requestId);
613
1390
  this.clearApprovalTimer(params.requestId);
614
1391
  resolve(params.decision);
615
1392
  this.transport.send(createResponse(req.id, { ok: true }));
@@ -690,37 +1467,260 @@ export class AgentServer {
690
1467
  }
691
1468
  this.transport.send(createResponse(req.id, { ok: true, limits: result }));
692
1469
  }
1470
+ /** Reject destructive Goal controls owned by a different TCP connection. */
1471
+ authorizeGoalControl(req, sessionId) {
1472
+ if (!this.strictApprovalRouting)
1473
+ return true;
1474
+ const owner = this.approvalRouter.current(sessionId);
1475
+ if (!owner || owner.connectionId === this.connectionId)
1476
+ return true;
1477
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.Overloaded, `Session ${sessionId} is owned by another connection`));
1478
+ return false;
1479
+ }
1480
+ /** Start a fully rebuilt Goal run for an idle or goal-less in-flight session. */
1481
+ async queueGoalResumeTurn(sessionId, goal, existing) {
1482
+ if (!this.chatManager)
1483
+ return null;
1484
+ let session = existing;
1485
+ if (!session) {
1486
+ // Let ChatSessionManager probe through its configured Engine factory so
1487
+ // custom sessionStorageDir roots stay aligned with the host. The generic
1488
+ // background-wakeup helper's default SessionManager probe is not safe for
1489
+ // this cold-resume path.
1490
+ session =
1491
+ (await this.chatManager.getOrCreatePersisted(sessionId, {
1492
+ projectTrusted: false,
1493
+ })) ?? undefined;
1494
+ if (!session)
1495
+ return null;
1496
+ this.wireInteractiveSession(session, sessionId);
1497
+ }
1498
+ if (session.engine.isHeadless())
1499
+ return null;
1500
+ return {
1501
+ completion: session.enqueueGoalResumeTurn(goal, {
1502
+ onStream: (event) => this.notify(Methods.StreamEvent, { sessionId, event }),
1503
+ approvalRouter: this.approvalRouter,
1504
+ }),
1505
+ };
1506
+ }
1507
+ reconcileFailedGoalResume(sessionId, goal, claimedOwner, error) {
1508
+ const patch = {
1509
+ paused: true,
1510
+ expectedGoalId: goal.goalId,
1511
+ expectedRevision: goal.revision,
1512
+ };
1513
+ const live = this.chatManager?.get(sessionId);
1514
+ const rolledBack = live
1515
+ ? live.updateGoal(patch)
1516
+ : this.updateActiveGoalOnDisk?.(sessionId, patch);
1517
+ const authoritative = rolledBack ?? live?.getGoal() ?? this.readActiveGoalFromDisk?.(sessionId);
1518
+ if (authoritative) {
1519
+ this.notify(Methods.StreamEvent, {
1520
+ sessionId,
1521
+ event: {
1522
+ type: "goal_updated",
1523
+ goalId: authoritative.goalId,
1524
+ revision: authoritative.revision,
1525
+ objective: authoritative.objective,
1526
+ paused: authoritative.paused === true,
1527
+ },
1528
+ });
1529
+ }
1530
+ if (rolledBack && claimedOwner && this.approvalRouter.matches(claimedOwner)) {
1531
+ this.approvalRouter.release(sessionId, this.connectionId, "goal resume turn was not queued");
1532
+ }
1533
+ logger.warn("goal.resume_turn_not_queued", {
1534
+ sessionId,
1535
+ goalId: goal.goalId,
1536
+ revision: goal.revision,
1537
+ error: error instanceof Error ? error.message : String(error),
1538
+ });
1539
+ this.notify(Methods.StreamEvent, {
1540
+ sessionId,
1541
+ event: {
1542
+ type: "error",
1543
+ error: rolledBack
1544
+ ? "Goal resume could not start; the Goal was paused again"
1545
+ : "Goal resume could not start; Goal state changed before rollback",
1546
+ },
1547
+ });
1548
+ }
1549
+ /** Edit or pause/resume a persisted goal without replacing its identity. */
1550
+ async handleGoalUpdate(req) {
1551
+ const params = (req.params ?? {});
1552
+ if (typeof params.sessionId !== "string" || !params.sessionId) {
1553
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "sessionId is required"));
1554
+ return;
1555
+ }
1556
+ if (params.objective === undefined && params.paused === undefined) {
1557
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "objective or paused is required"));
1558
+ return;
1559
+ }
1560
+ if (params.objective !== undefined && typeof params.objective !== "string") {
1561
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "objective must be a string"));
1562
+ return;
1563
+ }
1564
+ if (params.objective !== undefined && !params.objective.trim()) {
1565
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "objective must not be blank"));
1566
+ return;
1567
+ }
1568
+ if (params.paused !== undefined && typeof params.paused !== "boolean") {
1569
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "paused must be boolean"));
1570
+ return;
1571
+ }
1572
+ if (typeof params.expectedGoalId !== "string" || !params.expectedGoalId) {
1573
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "expectedGoalId is required and must be a non-empty string"));
1574
+ return;
1575
+ }
1576
+ if (typeof params.expectedRevision !== "number" ||
1577
+ !Number.isInteger(params.expectedRevision) ||
1578
+ params.expectedRevision < 1) {
1579
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "expectedRevision is required and must be a positive integer"));
1580
+ return;
1581
+ }
1582
+ const patch = {
1583
+ objective: params.objective,
1584
+ paused: params.paused,
1585
+ expectedGoalId: params.expectedGoalId,
1586
+ expectedRevision: params.expectedRevision,
1587
+ };
1588
+ const session = this.chatManager?.get(params.sessionId);
1589
+ const beforeGoal = session?.getGoal?.() ??
1590
+ this.legacyEngine?.getGoal(params.sessionId) ??
1591
+ this.readActiveGoalFromDisk?.(params.sessionId);
1592
+ const resumeInPlace = session?.canResumeGoalInPlace?.() === true;
1593
+ // `paused:false` is also an explicit kick for an unpaused-but-idle Goal
1594
+ // left behind by ESC, a model failure, or worker restart. A live Goal run
1595
+ // consumes the update in place and must not enqueue a duplicate turn.
1596
+ const driveRequested = !!beforeGoal && params.paused === false && !resumeInPlace;
1597
+ if (!this.authorizeGoalControl(req, params.sessionId))
1598
+ return;
1599
+ if (driveRequested && !this.chatManager) {
1600
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "Goal resume requires a multi-session host that can schedule a continuation turn"));
1601
+ return;
1602
+ }
1603
+ let claimedResumeOwner = null;
1604
+ if (this.strictApprovalRouting &&
1605
+ driveRequested &&
1606
+ !this.approvalRouter.current(params.sessionId)) {
1607
+ const registration = this.approvalRouter.register(params.sessionId, this.connectionId);
1608
+ if (!registration.ok) {
1609
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.Overloaded, `Session ${params.sessionId} is owned by another connection`));
1610
+ return;
1611
+ }
1612
+ claimedResumeOwner = registration.target;
1613
+ }
1614
+ const goal = session
1615
+ ? session.updateGoal(patch)
1616
+ : this.legacyEngine
1617
+ ? this.legacyEngine.updateGoal(params.sessionId, patch)
1618
+ : this.updateActiveGoalOnDisk?.(params.sessionId, patch);
1619
+ if (!goal) {
1620
+ if (claimedResumeOwner && this.approvalRouter.matches(claimedResumeOwner)) {
1621
+ this.approvalRouter.release(params.sessionId, this.connectionId, "goal resume CAS did not apply");
1622
+ }
1623
+ // A missing/stale goal is an ordinary optimistic-concurrency miss, not a
1624
+ // malformed request. Keep it in the result channel so Desktop, TUI and
1625
+ // the no-worker fallback share one contract.
1626
+ this.transport.send(createResponse(req.id, { ok: true, updated: false }));
1627
+ return;
1628
+ }
1629
+ this.notify(Methods.StreamEvent, {
1630
+ sessionId: params.sessionId,
1631
+ event: {
1632
+ type: "goal_updated",
1633
+ ...(goal.goalId ? { goalId: goal.goalId } : {}),
1634
+ ...(goal.revision ? { revision: goal.revision } : {}),
1635
+ objective: goal.objective,
1636
+ paused: goal.paused === true,
1637
+ },
1638
+ });
1639
+ this.transport.send(createResponse(req.id, {
1640
+ ok: true,
1641
+ updated: true,
1642
+ goal: goal.objective,
1643
+ ...(goal.goalId ? { goalId: goal.goalId } : {}),
1644
+ ...(goal.revision ? { revision: goal.revision } : {}),
1645
+ paused: goal.paused === true,
1646
+ }));
1647
+ if (driveRequested) {
1648
+ void this.queueGoalResumeTurn(params.sessionId, goal, session)
1649
+ .then((ticket) => {
1650
+ if (!ticket) {
1651
+ this.reconcileFailedGoalResume(params.sessionId, goal, claimedResumeOwner, "session is unavailable or headless");
1652
+ return;
1653
+ }
1654
+ void ticket.completion.catch((err) => {
1655
+ this.reconcileFailedGoalResume(params.sessionId, goal, claimedResumeOwner, err);
1656
+ });
1657
+ })
1658
+ .catch((err) => {
1659
+ this.reconcileFailedGoalResume(params.sessionId, goal, claimedResumeOwner, err);
1660
+ });
1661
+ }
1662
+ }
693
1663
  /**
694
1664
  * Clear a session's persisted active goal (CC /goal clear). Routes to the
695
1665
  * named session, falling back to the legacy single engine (mirrors
696
1666
  * handleGoalExtend / handleCancel's dual path). Returns { ok, cleared }.
697
1667
  */
698
- handleGoalClear(req) {
1668
+ handleGoalClear(req, deleteAlias = false) {
699
1669
  const params = (req.params ?? {});
1670
+ if (typeof params.sessionId !== "string" || !params.sessionId) {
1671
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "sessionId is required"));
1672
+ return;
1673
+ }
1674
+ if (deleteAlias &&
1675
+ (typeof params.expectedGoalId !== "string" ||
1676
+ !params.expectedGoalId ||
1677
+ typeof params.expectedRevision !== "number" ||
1678
+ !Number.isInteger(params.expectedRevision) ||
1679
+ params.expectedRevision < 1)) {
1680
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "goalDelete requires expectedGoalId and a positive expectedRevision"));
1681
+ return;
1682
+ }
1683
+ if (!this.authorizeGoalControl(req, params.sessionId))
1684
+ return;
1685
+ const expected = deleteAlias
1686
+ ? { goalId: params.expectedGoalId, revision: params.expectedRevision }
1687
+ : undefined;
700
1688
  const session = this.chatManager && typeof params.sessionId === "string"
701
1689
  ? this.chatManager.get(params.sessionId)
702
1690
  : undefined;
703
- if (!session && !this.legacyEngine) {
1691
+ if (!session && !this.legacyEngine && !this.clearActiveGoalOnDisk) {
704
1692
  this.transport.send(createErrorResponse(req.id, ErrorCodes.SessionClosed, params.sessionId ? `No such session: ${params.sessionId}` : "sessionId is required"));
705
1693
  return;
706
1694
  }
1695
+ const clearingGoal = session
1696
+ ? session.getGoal?.()
1697
+ : params.sessionId
1698
+ ? (this.legacyEngine?.getGoal(params.sessionId) ??
1699
+ this.readActiveGoalFromDisk?.(params.sessionId))
1700
+ : undefined;
707
1701
  const cleared = session
708
- ? session.clearGoal()
1702
+ ? session.clearGoal(expected)
709
1703
  : params.sessionId
710
- ? (this.legacyEngine.clearGoal(params.sessionId) ?? false)
1704
+ ? (this.legacyEngine?.clearGoal(params.sessionId, expected) ??
1705
+ this.clearActiveGoalOnDisk?.(params.sessionId, expected) ??
1706
+ false)
711
1707
  : false;
712
1708
  if (cleared && typeof params.sessionId === "string" && params.sessionId.length > 0) {
713
1709
  this.notify(Methods.StreamEvent, {
714
1710
  sessionId: params.sessionId,
715
- event: { type: "goal_cleared" },
1711
+ event: {
1712
+ type: "goal_cleared",
1713
+ ...(clearingGoal?.goalId ? { goalId: clearingGoal.goalId } : {}),
1714
+ ...(clearingGoal?.revision ? { revision: clearingGoal.revision } : {}),
1715
+ },
716
1716
  });
717
1717
  }
718
- this.transport.send(createResponse(req.id, { ok: true, cleared }));
1718
+ this.transport.send(createResponse(req.id, deleteAlias ? { ok: true, deleted: cleared } : { ok: true, cleared }));
719
1719
  }
720
1720
  /**
721
1721
  * Read a session's persisted active goal so the host can re-surface the goal
722
1722
  * block + Cancel button on session load. A persistent goal lives only in
723
- * state.activeGoal and is never replayed from the transcript, so a reloaded
1723
+ * state.goalLifecycle and is never replayed from the transcript, so a reloaded
724
1724
  * (or disk-rebuilt) session has no other way to learn it. Prefers a live
725
1725
  * chatManager session, else falls through to a disk read (readActiveGoalFromDisk
726
1726
  * in worker mode, or the legacy engine's disk read in single-engine mode) — the
@@ -749,7 +1749,13 @@ export class AgentServer {
749
1749
  : (this.readActiveGoalFromDisk?.(params.sessionId) ??
750
1750
  this.legacyEngine?.getGoal(params.sessionId) ??
751
1751
  undefined);
752
- this.transport.send(createResponse(req.id, { ok: true, goal: goal ? goal.objective : null }));
1752
+ this.transport.send(createResponse(req.id, {
1753
+ ok: true,
1754
+ goal: goal ? goal.objective : null,
1755
+ ...(goal?.goalId ? { goalId: goal.goalId } : {}),
1756
+ ...(goal?.revision ? { revision: goal.revision } : {}),
1757
+ paused: goal?.paused === true,
1758
+ }));
753
1759
  }
754
1760
  /**
755
1761
  * Query/control a session's background shells for the desktop UI panel (TODO
@@ -817,7 +1823,7 @@ export class AgentServer {
817
1823
  this.transport.send(createResponse(req.id, { items }));
818
1824
  }
819
1825
  // ─── CloseSession ───────────────────────────────────────────────
820
- handleCloseSession(req) {
1826
+ async handleCloseSession(req) {
821
1827
  const params = (req.params ?? {});
822
1828
  if (typeof params.sessionId !== "string" || params.sessionId.length === 0) {
823
1829
  this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "sessionId is required"));
@@ -826,15 +1832,18 @@ export class AgentServer {
826
1832
  if (this.chatManager) {
827
1833
  const session = this.chatManager.get(params.sessionId);
828
1834
  if (session) {
829
- this.cancelSessionApprovals(session, "session closed");
1835
+ this.cancelSessionApprovals(session, "session closed", "cancelled", "session_closed");
830
1836
  }
831
- this.chatManager.close(params.sessionId);
1837
+ this.approvalRouter.release(params.sessionId, this.connectionId, "session closed");
1838
+ await this.chatManager.close(params.sessionId);
1839
+ this.observeSessionClosed(params.sessionId);
832
1840
  }
833
1841
  // Explicit session teardown — reap that session's background shells
834
1842
  // (design §6 "session 被显式删除 → killSession"). This is the RPC path
835
1843
  // (agent/closeSession from the host on delete), distinct from the idle
836
- // sweeper's chatManager.close() which must NOT kill (§6). Fire-and-forget.
837
- void backgroundShellManager.killSession(params.sessionId);
1844
+ // sweeper's idle eviction which must NOT kill (§6). Await teardown so the
1845
+ // close RPC is a real deletion barrier for Desktop.
1846
+ await backgroundShellManager.killSession(params.sessionId);
838
1847
  // Drop this session's retained background jobs too (#2/#5): finished jobs
839
1848
  // are kept for the panel, so explicit teardown is where they're released.
840
1849
  backgroundJobRegistry.dropForSession(params.sessionId);
@@ -862,6 +1871,33 @@ export class AgentServer {
862
1871
  const workspace = engine?.releaseSessionWorkspace?.(params.sessionId) ?? null;
863
1872
  this.transport.send(createResponse(req.id, { ok: true, workspace }));
864
1873
  }
1874
+ handleSetWorkspace(req) {
1875
+ const params = (req.params ?? {});
1876
+ if (typeof params.sessionId !== "string" || params.sessionId.length === 0) {
1877
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "sessionId is required"));
1878
+ return;
1879
+ }
1880
+ if (!params.workspace ||
1881
+ typeof params.workspace !== "object" ||
1882
+ typeof params.workspace.root !== "string" ||
1883
+ params.workspace.root.length === 0 ||
1884
+ (params.workspace.kind !== "main" && params.workspace.kind !== "worktree")) {
1885
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "valid workspace is required"));
1886
+ return;
1887
+ }
1888
+ const engine = this.chatManager
1889
+ ? this.chatManager.get(params.sessionId)?.engine
1890
+ : this.legacyEngine;
1891
+ if (!engine) {
1892
+ this.transport.send(createResponse(req.id, { ok: true, workspace: null }));
1893
+ return;
1894
+ }
1895
+ const workspace = engine.setSessionWorkspace?.(params.sessionId, params.workspace);
1896
+ this.transport.send(createResponse(req.id, {
1897
+ ok: workspace !== undefined && workspace !== null,
1898
+ workspace: workspace ?? null,
1899
+ }));
1900
+ }
865
1901
  // ─── Configure ──────────────────────────────────────────────────
866
1902
  handleConfigure(req) {
867
1903
  const params = (req.params ?? {});
@@ -1027,6 +2063,51 @@ export class AgentServer {
1027
2063
  this.transport.send(createResponse(req.id, { ok: true }));
1028
2064
  }
1029
2065
  // ─── Query ──────────────────────────────────────────────────────
2066
+ /**
2067
+ * Resolve the Engine that owns a session-scoped query (compact, archive_range,
2068
+ * …). With a chatManager: materialize a persisted-but-not-live session on
2069
+ * demand, or borrow any live engine when no sessionId is given. Sends the
2070
+ * appropriate error response and returns null when the session is
2071
+ * closing/closed, unknown, or no engine is available; `label` names the query
2072
+ * in the "no engine" message. Domain-neutral — no query-specific literals.
2073
+ */
2074
+ async resolveEngineForSessionQuery(req, sessionId, fallbackEngine, label) {
2075
+ let resolved = fallbackEngine;
2076
+ if (this.chatManager) {
2077
+ if (sessionId) {
2078
+ if (this.chatManager.isUnavailable(sessionId)) {
2079
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.SessionClosed, `Session is closing or closed: ${sessionId}`));
2080
+ return null;
2081
+ }
2082
+ let session = this.chatManager.get(sessionId);
2083
+ if (!session) {
2084
+ const probeEngine = this.anyEngine();
2085
+ if (!probeEngine?.sessionExistsOnDisk(sessionId)) {
2086
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.SessionNotFound, `Session not found: ${sessionId}`));
2087
+ return null;
2088
+ }
2089
+ try {
2090
+ session = await this.chatManager.getOrCreate(sessionId, {
2091
+ cwd: probeEngine.getSessionManager().readSessionMainRoot(sessionId),
2092
+ });
2093
+ }
2094
+ catch (err) {
2095
+ this.transport.send(createErrorResponse(req.id, err.code ?? ErrorCodes.InternalError, err.message));
2096
+ return null;
2097
+ }
2098
+ }
2099
+ resolved = session.engine;
2100
+ }
2101
+ else {
2102
+ resolved = this.anyEngine();
2103
+ }
2104
+ }
2105
+ if (!resolved) {
2106
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InternalError, `No engine available for ${label} query`));
2107
+ return null;
2108
+ }
2109
+ return resolved;
2110
+ }
1030
2111
  async handleQuery(req) {
1031
2112
  const params = (req.params ?? {});
1032
2113
  // For query operations we prefer the legacyEngine; if absent borrow any
@@ -1049,20 +2130,14 @@ export class AgentServer {
1049
2130
  }
1050
2131
  case "sessions": {
1051
2132
  if (this.chatManager) {
1052
- // Return the ChatSessionManager's live sessions
1053
- const sessions = [];
1054
- this.chatManager.forEachSession((s) => {
1055
- sessions.push({
1056
- sessionId: s.id,
1057
- busy: s.isBusy(),
1058
- queueDepth: s.queueDepth(),
1059
- lastActivityAt: s.lastActivityAt,
1060
- });
1061
- });
2133
+ // Pet projection and the legacy query share this one live-state builder.
2134
+ const { sessions } = this.chatManager.getLiveSessionSnapshot();
1062
2135
  this.transport.send(createResponse(req.id, { type: "sessions", data: sessions }));
1063
2136
  }
1064
2137
  else if (engine) {
1065
- const sessions = engine.getSessionManager().list();
2138
+ const sessions = engine
2139
+ .getSessionManager()
2140
+ .list(undefined, { excludeKinds: this.hiddenSessionKinds });
1066
2141
  this.transport.send(createResponse(req.id, { type: "sessions", data: sessions }));
1067
2142
  }
1068
2143
  else {
@@ -1124,36 +2199,9 @@ export class AgentServer {
1124
2199
  const compactSessionId = typeof params.sessionId === "string" && params.sessionId.length > 0
1125
2200
  ? params.sessionId
1126
2201
  : undefined;
1127
- let compactEngine = engine;
1128
- if (this.chatManager) {
1129
- if (compactSessionId) {
1130
- let session = this.chatManager.get(compactSessionId);
1131
- if (!session) {
1132
- const probeEngine = this.anyEngine();
1133
- if (!probeEngine?.sessionExistsOnDisk(compactSessionId)) {
1134
- this.transport.send(createErrorResponse(req.id, ErrorCodes.SessionNotFound, `Session not found: ${compactSessionId}`));
1135
- return;
1136
- }
1137
- try {
1138
- session = this.chatManager.getOrCreate(compactSessionId, {
1139
- cwd: probeEngine.getSessionManager().readCwd(compactSessionId),
1140
- });
1141
- }
1142
- catch (err) {
1143
- this.transport.send(createErrorResponse(req.id, err.code ?? ErrorCodes.InternalError, err.message));
1144
- return;
1145
- }
1146
- }
1147
- compactEngine = session.engine;
1148
- }
1149
- else {
1150
- compactEngine = this.anyEngine();
1151
- }
1152
- }
1153
- if (!compactEngine) {
1154
- this.transport.send(createErrorResponse(req.id, ErrorCodes.InternalError, "No engine available for compact query"));
2202
+ const compactEngine = await this.resolveEngineForSessionQuery(req, compactSessionId, engine, "compact");
2203
+ if (!compactEngine)
1155
2204
  return;
1156
- }
1157
2205
  try {
1158
2206
  const result = await compactEngine.forceCompact(compactSessionId);
1159
2207
  if (result.before > result.after) {
@@ -1178,6 +2226,45 @@ export class AgentServer {
1178
2226
  }
1179
2227
  break;
1180
2228
  }
2229
+ case "archive_range": {
2230
+ const archiveSessionId = typeof params.sessionId === "string" && params.sessionId.length > 0
2231
+ ? params.sessionId
2232
+ : undefined;
2233
+ const start = Number(params.start);
2234
+ const end = Number(params.end);
2235
+ if (!archiveSessionId || !Number.isFinite(start) || !Number.isFinite(end)) {
2236
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "archive_range requires sessionId, start and end"));
2237
+ return;
2238
+ }
2239
+ const archiveEngine = await this.resolveEngineForSessionQuery(req, archiveSessionId, engine, "archive_range");
2240
+ if (!archiveEngine)
2241
+ return;
2242
+ try {
2243
+ const result = await archiveEngine.archiveTurnRange(archiveSessionId, { start, end });
2244
+ if (result.before > result.after) {
2245
+ const event = {
2246
+ type: "context_compact",
2247
+ // Range archival has its own tier; "range" must be in
2248
+ // COMPACT_STREAM_STRATEGIES or it degrades to "compacted".
2249
+ strategy: toCompactStreamStrategy("range"),
2250
+ before: result.before,
2251
+ after: result.after,
2252
+ };
2253
+ this.notify(Methods.StreamEvent, {
2254
+ sessionId: archiveSessionId,
2255
+ event,
2256
+ });
2257
+ }
2258
+ this.transport.send(createResponse(req.id, {
2259
+ type: "archive_range",
2260
+ data: result,
2261
+ }));
2262
+ }
2263
+ catch (err) {
2264
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InternalError, err.message));
2265
+ }
2266
+ break;
2267
+ }
1181
2268
  case "models": {
1182
2269
  if (!engine) {
1183
2270
  this.transport.send(createErrorResponse(req.id, ErrorCodes.InternalError, "No engine available for models query"));
@@ -1246,11 +2333,6 @@ export class AgentServer {
1246
2333
  }
1247
2334
  break;
1248
2335
  }
1249
- case "arena_status": {
1250
- const status = getArenaStatus();
1251
- this.transport.send(createResponse(req.id, { type: "arena_status", data: status }));
1252
- break;
1253
- }
1254
2336
  case "config_set": {
1255
2337
  if (!engine) {
1256
2338
  this.transport.send(createErrorResponse(req.id, ErrorCodes.InternalError, "No engine available for config_set"));
@@ -1439,8 +2521,16 @@ export class AgentServer {
1439
2521
  }
1440
2522
  break;
1441
2523
  }
1442
- default:
2524
+ default: {
2525
+ const capability = engine
2526
+ ? await engine.queryCapability(params.type, params)
2527
+ : { handled: false };
2528
+ if (capability.handled) {
2529
+ this.transport.send(createResponse(req.id, { type: params.type, data: capability.data }));
2530
+ break;
2531
+ }
1443
2532
  this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, `Unknown query type: ${params.type}`));
2533
+ }
1444
2534
  }
1445
2535
  }
1446
2536
  // ─── Inject ─────────────────────────────────────────────────────
@@ -1452,6 +2542,10 @@ export class AgentServer {
1452
2542
  }
1453
2543
  // In the chatManager path, inject into the session's engine
1454
2544
  if (this.chatManager) {
2545
+ if (this.chatManager.isUnavailable(params.sessionId)) {
2546
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.SessionClosed, `Session is closing or closed: ${params.sessionId}`));
2547
+ return;
2548
+ }
1455
2549
  const s = this.chatManager.get(params.sessionId);
1456
2550
  if (!s) {
1457
2551
  this.transport.send(createErrorResponse(req.id, ErrorCodes.SessionClosed, `No such session: ${params.sessionId}`));
@@ -1485,6 +2579,10 @@ export class AgentServer {
1485
2579
  this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "text and sessionId required"));
1486
2580
  return;
1487
2581
  }
2582
+ if (this.chatManager?.isUnavailable(params.sessionId)) {
2583
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.SessionClosed, `Session is closing or closed: ${params.sessionId}`));
2584
+ return;
2585
+ }
1488
2586
  const engine = this.chatManager
1489
2587
  ? this.chatManager.get(params.sessionId)?.engine
1490
2588
  : this.legacyEngine;
@@ -1506,6 +2604,10 @@ export class AgentServer {
1506
2604
  this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "id and sessionId required"));
1507
2605
  return;
1508
2606
  }
2607
+ if (this.chatManager?.isUnavailable(params.sessionId)) {
2608
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.SessionClosed, `Session is closing or closed: ${params.sessionId}`));
2609
+ return;
2610
+ }
1509
2611
  const engine = this.chatManager
1510
2612
  ? this.chatManager.get(params.sessionId)?.engine
1511
2613
  : this.legacyEngine;
@@ -1527,39 +2629,73 @@ export class AgentServer {
1527
2629
  /**
1528
2630
  * Ask the client to approve a tool operation (legacy single-engine path).
1529
2631
  */
1530
- requestApprovalFromClient(request) {
2632
+ requestApprovalFromClient(request, route) {
1531
2633
  return new Promise((resolve) => {
1532
2634
  const requestId = nanoid(12);
1533
- const sessionId = typeof request.sessionId === "string" ? request.sessionId : undefined;
2635
+ const effectiveRoute = route ?? {
2636
+ connectionId: this.connectionId,
2637
+ sessionId: request.sessionId ?? "",
2638
+ generation: 0,
2639
+ };
2640
+ const sessionId = effectiveRoute.sessionId;
1534
2641
  const session = this.chatManager && sessionId ? this.chatManager.get(sessionId) : undefined;
1535
2642
  if (this.chatManager && sessionId && !session) {
1536
2643
  resolve({ approved: false, reason: `session closed: ${sessionId}` });
1537
2644
  return;
1538
2645
  }
1539
2646
  if (session) {
1540
- session.pendingApprovals.set(requestId, (decision) => {
1541
- resolve(decision);
1542
- });
2647
+ const internal = INTERNAL_PENDING_TOOLS.has(request.toolName);
2648
+ const toolName = safeApprovalToolName(request.toolName);
2649
+ this.registerSessionApproval(session, {
2650
+ sessionId,
2651
+ requestId,
2652
+ routeGeneration: effectiveRoute.generation,
2653
+ workerGeneration: this.workerGeneration(),
2654
+ kind: internal ? "internal" : "tool_approval",
2655
+ title: internal ? "内部操作等待" : `等待批准 ${toolName}`,
2656
+ toolName,
2657
+ riskLevel: request.riskLevel,
2658
+ createdAt: Date.now(),
2659
+ expiresAt: Date.now() + AgentServer.APPROVAL_TIMEOUT_MS,
2660
+ surfaceable: !internal,
2661
+ }, (decision) => resolve(decision));
1543
2662
  }
1544
2663
  else {
1545
2664
  this.pendingApprovals.set(requestId, resolve);
1546
2665
  }
2666
+ this.pendingApprovalTargets.set(requestId, { ...effectiveRoute, requestId });
1547
2667
  const timer = setTimeout(() => {
1548
2668
  const pending = session?.pendingApprovals ?? this.pendingApprovals;
1549
2669
  if (pending.has(requestId)) {
1550
- pending.delete(requestId);
2670
+ if (session) {
2671
+ this.takeSessionApproval(session, requestId, "expired");
2672
+ }
2673
+ else {
2674
+ pending.delete(requestId);
2675
+ }
2676
+ this.pendingApprovalTargets.delete(requestId);
1551
2677
  this.approvalTimers.delete(requestId);
1552
2678
  resolve({ approved: false, reason: "approval timed out" });
1553
2679
  }
1554
2680
  }, AgentServer.APPROVAL_TIMEOUT_MS);
1555
2681
  this.approvalTimers.set(requestId, timer);
1556
2682
  this.notify(Methods.ApprovalRequest, {
2683
+ connectionId: effectiveRoute.connectionId,
1557
2684
  ...(sessionId ? { sessionId } : {}),
2685
+ generation: effectiveRoute.generation,
1558
2686
  requestId,
1559
2687
  request,
1560
2688
  });
1561
2689
  });
1562
2690
  }
2691
+ approvalRouteEnvelope(sessionId, requestId) {
2692
+ const route = this.approvalRouter.current(sessionId);
2693
+ if (!route || route.connectionId !== this.connectionId)
2694
+ return { sessionId, requestId };
2695
+ const target = { ...route, requestId };
2696
+ this.pendingApprovalTargets.set(requestId, target);
2697
+ return target;
2698
+ }
1563
2699
  /**
1564
2700
  * Per-session AskUserQuestion for the chatManager path. Resolves via the
1565
2701
  * SESSION's pendingApprovals (the chatManager approve handler looks there,
@@ -1570,7 +2706,17 @@ export class AgentServer {
1570
2706
  requestAskUserForSession(session, sessionId, question, opts) {
1571
2707
  return new Promise((resolve) => {
1572
2708
  const requestId = nanoid(12);
1573
- session.pendingApprovals.set(requestId, (decision) => {
2709
+ const routeEnvelope = this.approvalRouteEnvelope(sessionId, requestId);
2710
+ this.registerSessionApproval(session, {
2711
+ sessionId,
2712
+ requestId,
2713
+ routeGeneration: "generation" in routeEnvelope ? routeEnvelope.generation : undefined,
2714
+ workerGeneration: this.workerGeneration(),
2715
+ kind: "ask_user",
2716
+ title: "需要用户回答",
2717
+ createdAt: Date.now(),
2718
+ surfaceable: true,
2719
+ }, (decision) => {
1574
2720
  this.clearApprovalTimer(requestId);
1575
2721
  const result = decision;
1576
2722
  if (result && typeof result === "object" && "approved" in result) {
@@ -1593,8 +2739,7 @@ export class AgentServer {
1593
2739
  if (opts?.optionsOnly !== undefined)
1594
2740
  args.optionsOnly = opts.optionsOnly;
1595
2741
  this.notify(Methods.ApprovalRequest, {
1596
- sessionId,
1597
- requestId,
2742
+ ...routeEnvelope,
1598
2743
  request: {
1599
2744
  toolName: "__ask_user__",
1600
2745
  args,
@@ -1619,11 +2764,11 @@ export class AgentServer {
1619
2764
  }
1620
2765
  if (goalActive) {
1621
2766
  const timer = setTimeout(() => {
1622
- const resolveFn = session.pendingApprovals.get(requestId);
1623
- if (resolveFn) {
1624
- session.pendingApprovals.delete(requestId);
2767
+ const entry = this.takeSessionApproval(session, requestId, "expired");
2768
+ if (entry) {
2769
+ this.pendingApprovalTargets.delete(requestId);
1625
2770
  this.approvalTimers.delete(requestId);
1626
- resolveFn("(用户未在限定时间内回答;目标运行中请自行做出合理假设并继续推进。)");
2771
+ entry.resolve("(用户未在限定时间内回答;目标运行中请自行做出合理假设并继续推进。)");
1627
2772
  // Tell every client the ask is resolved (nobody answered) so the
1628
2773
  // stale AskUserQuestion card is dismissed instead of lingering as
1629
2774
  // if still waiting. Reuses the same envelope shape the desktop main
@@ -1650,7 +2795,7 @@ export class AgentServer {
1650
2795
  type: (ref, text) => call("type", { ref, text }),
1651
2796
  navigate: (url) => call("navigate", { url }),
1652
2797
  scroll: (dir, amount) => call("scroll", { dir, amount }),
1653
- readContent: () => call("readContent", {}),
2798
+ readContent: (options) => call("readContent", { ...(options ?? {}) }),
1654
2799
  extractLinks: () => call("extractLinks", {}),
1655
2800
  waitForLoad: (timeoutMs) => call("waitForLoad", { timeoutMs }),
1656
2801
  hover: (ref) => call("hover", { ref }),
@@ -1665,40 +2810,31 @@ export class AgentServer {
1665
2810
  requestBrowserActionForSession(session, sessionId, action, payload) {
1666
2811
  return new Promise((resolve) => {
1667
2812
  const requestId = nanoid(12);
1668
- session.pendingApprovals.set(requestId, (decision) => {
2813
+ const routeEnvelope = this.approvalRouteEnvelope(sessionId, requestId);
2814
+ this.registerSessionApproval(session, this.internalPendingMetadata(sessionId, requestId, routeEnvelope, "__browser_action__"), (decision) => {
1669
2815
  this.clearApprovalTimer(requestId);
1670
2816
  // Main replies with { approved:true, answer:<json string> } (reusing the
1671
2817
  // ApprovalResult shape) or a raw json string. Parse → typed result.
1672
- let raw;
1673
- if (decision && typeof decision === "object" && "approved" in decision) {
1674
- const r = decision;
1675
- raw = r.approved ? r.answer : undefined;
1676
- }
1677
- else if (typeof decision === "string") {
1678
- raw = decision;
1679
- }
1680
- if (raw === undefined) {
1681
- resolve({ ok: false, detail: "browser action declined or unavailable" });
1682
- return;
1683
- }
1684
- try {
1685
- resolve(JSON.parse(raw));
1686
- }
1687
- catch {
1688
- resolve({ ok: false, detail: "malformed browser action result" });
1689
- }
2818
+ const parsed = parseHostLoopbackDecision(decision, "browser action");
2819
+ resolve(parsed.ok
2820
+ ? parsed.value
2821
+ : { ok: false, failure: parsed.failure, detail: parsed.detail });
1690
2822
  });
1691
2823
  const timer = setTimeout(() => {
1692
2824
  if (session.pendingApprovals.has(requestId)) {
1693
- session.pendingApprovals.delete(requestId);
2825
+ this.takeSessionApproval(session, requestId, "expired");
2826
+ this.pendingApprovalTargets.delete(requestId);
1694
2827
  this.approvalTimers.delete(requestId);
1695
- resolve({ ok: false, detail: "browser action timed out" });
2828
+ resolve({
2829
+ ok: false,
2830
+ failure: "timed_out",
2831
+ detail: `browser action ${HOST_LOOPBACK_FAILURE_DETAIL.timed_out}`,
2832
+ });
1696
2833
  }
1697
2834
  }, AgentServer.APPROVAL_TIMEOUT_MS);
1698
2835
  this.approvalTimers.set(requestId, timer);
1699
2836
  this.notify(Methods.ApprovalRequest, {
1700
- sessionId,
1701
- requestId,
2837
+ ...routeEnvelope,
1702
2838
  request: {
1703
2839
  toolName: "__browser_action__",
1704
2840
  args: { action, ...payload },
@@ -1717,38 +2853,28 @@ export class AgentServer {
1717
2853
  requestCredentialInjectForSession(session, sessionId, credentialId, credentialScope = "full") {
1718
2854
  return new Promise((resolve) => {
1719
2855
  const requestId = nanoid(12);
1720
- session.pendingApprovals.set(requestId, (decision) => {
2856
+ const routeEnvelope = this.approvalRouteEnvelope(sessionId, requestId);
2857
+ this.registerSessionApproval(session, this.internalPendingMetadata(sessionId, requestId, routeEnvelope, "__credential_action__"), (decision) => {
1721
2858
  this.clearApprovalTimer(requestId);
1722
- let raw;
1723
- if (decision && typeof decision === "object" && "approved" in decision) {
1724
- const r = decision;
1725
- raw = r.approved ? r.answer : undefined;
1726
- }
1727
- else if (typeof decision === "string") {
1728
- raw = decision;
1729
- }
1730
- if (raw === undefined) {
1731
- resolve({ ok: false, error: "credential inject declined or unavailable" });
1732
- return;
1733
- }
1734
- try {
1735
- resolve(JSON.parse(raw));
1736
- }
1737
- catch {
1738
- resolve({ ok: false, error: "malformed credential inject result" });
1739
- }
2859
+ const parsed = parseHostLoopbackDecision(decision, "credential inject");
2860
+ resolve(parsed.ok
2861
+ ? parsed.value
2862
+ : { ok: false, error: parsed.detail });
1740
2863
  });
1741
2864
  const timer = setTimeout(() => {
1742
2865
  if (session.pendingApprovals.has(requestId)) {
1743
- session.pendingApprovals.delete(requestId);
2866
+ this.takeSessionApproval(session, requestId, "expired");
2867
+ this.pendingApprovalTargets.delete(requestId);
1744
2868
  this.approvalTimers.delete(requestId);
1745
- resolve({ ok: false, error: "credential inject timed out" });
2869
+ resolve({
2870
+ ok: false,
2871
+ error: `credential inject ${HOST_LOOPBACK_FAILURE_DETAIL.timed_out}`,
2872
+ });
1746
2873
  }
1747
2874
  }, AgentServer.APPROVAL_TIMEOUT_MS);
1748
2875
  this.approvalTimers.set(requestId, timer);
1749
2876
  this.notify(Methods.ApprovalRequest, {
1750
- sessionId,
1751
- requestId,
2877
+ ...routeEnvelope,
1752
2878
  request: {
1753
2879
  toolName: "__credential_action__",
1754
2880
  args: { action: "injectCookie", credentialId, credentialScope },
@@ -1763,46 +2889,145 @@ export class AgentServer {
1763
2889
  switch: (target) => this.requestWorkspaceSwitchForSession(session, sessionId, target),
1764
2890
  };
1765
2891
  }
2892
+ makePanelBridge(session, sessionId) {
2893
+ return {
2894
+ list: async () => {
2895
+ const result = (await this.requestPanelActionForSession(session, sessionId, "list", {}));
2896
+ if (result?.ok === true && Array.isArray(result.panels)) {
2897
+ return {
2898
+ items: result.panels,
2899
+ };
2900
+ }
2901
+ // Never report a failed discovery as an empty host. "(no panels
2902
+ // available)" is an affirmative claim that makes the model give up.
2903
+ return { items: [], failed: hostLoopbackDetail(result) ?? "panel list failed" };
2904
+ },
2905
+ open: async (panelId) => {
2906
+ const result = (await this.requestPanelActionForSession(session, sessionId, "open", {
2907
+ panelId,
2908
+ }));
2909
+ // Gate on SUCCESS, not merely on shape. A real failure reply is
2910
+ // `{ok:false, panelId, detail}` (AgentPanelHost) — it satisfies a
2911
+ // shape-only `result?.panelId` check and would return verbatim, skipping
2912
+ // both the classification recovery and the length bound below. Every
2913
+ // failure must fall through here.
2914
+ if (result?.ok === true && result?.panelId)
2915
+ return result;
2916
+ // A classified terminal failure (cancelled / denied / timed out) must
2917
+ // survive this normalization — reporting it as "malformed" would tell the
2918
+ // model the HOST misbehaved when in fact the user stopped the turn.
2919
+ return {
2920
+ ok: false,
2921
+ panelId: result?.panelId ?? panelId,
2922
+ detail: hostLoopbackDetail(result) ?? "panel host returned a malformed result",
2923
+ };
2924
+ },
2925
+ tools: async (panelId) => {
2926
+ const result = (await this.requestPanelActionForSession(session, sessionId, "tools", {
2927
+ panelId,
2928
+ }));
2929
+ if (result?.ok === true && Array.isArray(result.tools)) {
2930
+ return {
2931
+ items: result.tools,
2932
+ };
2933
+ }
2934
+ return { items: [], failed: hostLoopbackDetail(result) ?? "panel tools query failed" };
2935
+ },
2936
+ invoke: async (panelId, toolName, args) => {
2937
+ const result = (await this.requestPanelActionForSession(session, sessionId, "invoke", {
2938
+ panelId,
2939
+ toolName,
2940
+ arguments: args,
2941
+ }));
2942
+ // Success-gated for the same reason as `open` above. Note a real failure
2943
+ // reply carries `panelId` but usually NOT `toolName` (AgentPanelHost emits
2944
+ // `{ok:false, panelId, detail}`), so the shape check alone was less
2945
+ // frequently bypassed here than for `open` — but `ok === true` is the
2946
+ // property that actually matters, and it also keeps a Panel App's raw
2947
+ // `error.message` from reaching the model unbounded.
2948
+ if (result?.ok === true && result?.panelId && result?.toolName)
2949
+ return result;
2950
+ return {
2951
+ ok: false,
2952
+ panelId: result?.panelId ?? panelId,
2953
+ toolName: result?.toolName ?? toolName,
2954
+ detail: hostLoopbackDetail(result) ?? "panel host returned a malformed result",
2955
+ };
2956
+ },
2957
+ };
2958
+ }
2959
+ requestPanelActionForSession(session, sessionId, action, payload) {
2960
+ return new Promise((resolve) => {
2961
+ const requestId = nanoid(12);
2962
+ const routeEnvelope = this.approvalRouteEnvelope(sessionId, requestId);
2963
+ this.registerSessionApproval(session, this.internalPendingMetadata(sessionId, requestId, routeEnvelope, "__panel_action__"), (decision) => {
2964
+ this.clearApprovalTimer(requestId);
2965
+ const parsed = parseHostLoopbackDecision(decision, "panel action");
2966
+ resolve(parsed.ok ? parsed.value : { ok: false, failure: parsed.failure, error: parsed.detail });
2967
+ });
2968
+ const timer = setTimeout(() => {
2969
+ if (session.pendingApprovals.has(requestId)) {
2970
+ this.takeSessionApproval(session, requestId, "expired");
2971
+ this.pendingApprovalTargets.delete(requestId);
2972
+ this.approvalTimers.delete(requestId);
2973
+ resolve({
2974
+ ok: false,
2975
+ failure: "timed_out",
2976
+ error: `panel action ${HOST_LOOPBACK_FAILURE_DETAIL.timed_out}`,
2977
+ });
2978
+ }
2979
+ }, AgentServer.APPROVAL_TIMEOUT_MS);
2980
+ this.approvalTimers.set(requestId, timer);
2981
+ this.notify(Methods.ApprovalRequest, {
2982
+ ...routeEnvelope,
2983
+ request: {
2984
+ toolName: "__panel_action__",
2985
+ args: { action, ...payload },
2986
+ description: `panel:${action}`,
2987
+ riskLevel: "low",
2988
+ },
2989
+ });
2990
+ });
2991
+ }
1766
2992
  requestWorkspaceSwitchForSession(session, sessionId, target) {
1767
2993
  return new Promise((resolve, reject) => {
1768
2994
  const requestId = nanoid(12);
1769
- session.pendingApprovals.set(requestId, (decision) => {
2995
+ const routeEnvelope = this.approvalRouteEnvelope(sessionId, requestId);
2996
+ this.registerSessionApproval(session, this.internalPendingMetadata(sessionId, requestId, routeEnvelope, "__workspace_action__"), (decision) => {
1770
2997
  this.clearApprovalTimer(requestId);
1771
- let raw;
1772
- if (decision && typeof decision === "object" && "approved" in decision) {
1773
- const r = decision;
1774
- raw = r.approved ? r.answer : undefined;
1775
- }
1776
- else if (typeof decision === "string") {
1777
- raw = decision;
1778
- }
1779
- if (raw === undefined) {
1780
- reject(new Error("workspace switch declined or unavailable"));
2998
+ const outcome = parseHostLoopbackDecision(decision, "workspace switch");
2999
+ if (!outcome.ok) {
3000
+ reject(new Error(outcome.detail));
1781
3001
  return;
1782
3002
  }
1783
- try {
1784
- const parsed = JSON.parse(raw);
1785
- if ("ok" in parsed && parsed.ok === false) {
1786
- reject(new Error(parsed.error ?? "workspace switch failed"));
1787
- return;
1788
- }
1789
- resolve(parsed);
3003
+ const parsed = outcome.value;
3004
+ // A workspace is always an object. Reject any other JSON shape rather
3005
+ // than resolving it: `null` / a bare number / a string would otherwise
3006
+ // be handed to setSessionWorkspace as if the switch had succeeded,
3007
+ // rebasing the session onto a non-workspace. (Previously a `"ok" in
3008
+ // parsed` TypeError happened to be caught and turned into a rejection;
3009
+ // this states the requirement instead of relying on that.)
3010
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3011
+ reject(new Error(`workspace switch ${HOST_LOOPBACK_FAILURE_DETAIL.malformed}`));
3012
+ return;
1790
3013
  }
1791
- catch {
1792
- reject(new Error("malformed workspace switch result"));
3014
+ if ("ok" in parsed && parsed.ok === false) {
3015
+ reject(new Error(parsed.error ?? "workspace switch failed"));
3016
+ return;
1793
3017
  }
3018
+ resolve(parsed);
1794
3019
  });
1795
3020
  const timer = setTimeout(() => {
1796
3021
  if (session.pendingApprovals.has(requestId)) {
1797
- session.pendingApprovals.delete(requestId);
3022
+ this.takeSessionApproval(session, requestId, "expired");
3023
+ this.pendingApprovalTargets.delete(requestId);
1798
3024
  this.approvalTimers.delete(requestId);
1799
- reject(new Error("workspace switch timed out"));
3025
+ reject(new Error(`workspace switch ${HOST_LOOPBACK_FAILURE_DETAIL.timed_out}`));
1800
3026
  }
1801
3027
  }, AgentServer.APPROVAL_TIMEOUT_MS);
1802
3028
  this.approvalTimers.set(requestId, timer);
1803
3029
  this.notify(Methods.ApprovalRequest, {
1804
- sessionId,
1805
- requestId,
3030
+ ...routeEnvelope,
1806
3031
  request: {
1807
3032
  toolName: "__workspace_action__",
1808
3033
  args: { action: "switch", target },
@@ -1876,6 +3101,8 @@ export class AgentServer {
1876
3101
  }
1877
3102
  // ─── Lifecycle ──────────────────────────────────────────────────
1878
3103
  close() {
3104
+ this.observeServerClose();
3105
+ this.disconnect("server closing");
1879
3106
  // Detach the bg-agent bus subscription first so a final flurry of
1880
3107
  // completions during shutdown can't race the `shutdown` status
1881
3108
  // notify below. Safe to call repeatedly — the unsubscribe is a
@@ -1889,11 +3116,17 @@ export class AgentServer {
1889
3116
  }
1890
3117
  this.bgAgentBusUnsubscribe = null;
1891
3118
  }
1892
- if (this.chatManager) {
1893
- this.chatManager.forEachSession((session) => {
1894
- this.cancelSessionApprovals(session, "server closing");
1895
- });
1896
- this.chatManager.closeAll();
3119
+ // Close the host-supplied manager plus every identity-derived manager
3120
+ // this server lazily created (the map is empty without a resolveIdentity
3121
+ // hook, so the default path closes exactly the base manager as before).
3122
+ if (this.baseChatManager) {
3123
+ const managers = [this.baseChatManager, ...this.identityManagers.values()];
3124
+ for (const manager of managers) {
3125
+ manager.forEachSession((session) => {
3126
+ this.cancelSessionApprovals(session, "server closing", "cancelled", "session_closed");
3127
+ });
3128
+ manager.closeAll();
3129
+ }
1897
3130
  }
1898
3131
  // Legacy path cleanup
1899
3132
  if (this.abortController) {
@@ -1912,6 +3145,59 @@ export class AgentServer {
1912
3145
  this.notify(Methods.Status, { status: "shutdown" });
1913
3146
  this.transport.close();
1914
3147
  }
3148
+ /** Release only this transport's approval ownership. Safe on socket close
3149
+ * even when the ChatSessionManager is shared by other TCP connections. */
3150
+ disconnect(reason = "approval connection disconnected") {
3151
+ if (this.disconnected)
3152
+ return;
3153
+ this.disconnected = true;
3154
+ const unregister = this.approvalConnectionUnregister;
3155
+ this.approvalConnectionUnregister = null;
3156
+ if (unregister) {
3157
+ this.approvalRouter.deregister(this.connectionId, reason);
3158
+ }
3159
+ }
3160
+ /** Resolver-free metadata view for extension projections and protocol snapshots. */
3161
+ getPendingDecisionSnapshot() {
3162
+ const entries = [];
3163
+ this.forEachObserver("snapshotPendingDecisions", (observer) => {
3164
+ const snapshot = observer.snapshotPendingDecisions?.();
3165
+ if (snapshot)
3166
+ entries.push(...snapshot);
3167
+ });
3168
+ return entries;
3169
+ }
3170
+ /** Host lifecycle generation stamped onto pending-approval metadata. */
3171
+ workerGeneration() {
3172
+ return this.chatManager?.getLiveSessionSnapshot().generation ?? 0;
3173
+ }
3174
+ registerSessionApproval(session, metadata, resolve) {
3175
+ metadata = this.observeApprovalCreated(metadata);
3176
+ session.pendingApprovals.set(metadata.requestId, { resolve, metadata });
3177
+ }
3178
+ takeSessionApproval(session, requestId, status) {
3179
+ const entry = session.pendingApprovals.get(requestId);
3180
+ if (!entry)
3181
+ return undefined;
3182
+ session.pendingApprovals.delete(requestId);
3183
+ this.observeApprovalTransition(entry.metadata, status);
3184
+ return entry;
3185
+ }
3186
+ internalPendingMetadata(sessionId, requestId, route, toolName) {
3187
+ const createdAt = Date.now();
3188
+ return {
3189
+ sessionId,
3190
+ requestId,
3191
+ routeGeneration: "generation" in route ? route.generation : undefined,
3192
+ workerGeneration: this.workerGeneration(),
3193
+ kind: "internal",
3194
+ title: "内部操作等待",
3195
+ toolName,
3196
+ createdAt,
3197
+ expiresAt: createdAt + AgentServer.APPROVAL_TIMEOUT_MS,
3198
+ surfaceable: false,
3199
+ };
3200
+ }
1915
3201
  clearApprovalTimer(requestId) {
1916
3202
  const timer = this.approvalTimers.get(requestId);
1917
3203
  if (timer) {
@@ -1926,18 +3212,77 @@ export class AgentServer {
1926
3212
  * the tool hanging. Bounded request types have same-keyed timer entries;
1927
3213
  * AskUserQuestion does not.
1928
3214
  */
1929
- cancelSessionApprovals(session, reason = "cancelled") {
1930
- for (const [requestId, resolve] of session.pendingApprovals) {
1931
- this.clearApprovalTimer(requestId);
1932
- try {
1933
- resolve({ approved: false, reason });
1934
- }
1935
- catch {
1936
- /* a resolver must never break cancel cleanup */
3215
+ cancelSessionApprovals(session, reason = "cancelled", status = "cancelled", failure = "cancelled") {
3216
+ // Two kinds of entry share this map and they must NOT settle the same way.
3217
+ //
3218
+ // - kind "tool_approval": a real permission prompt. `{approved:false}` is
3219
+ // the correct terminal value — the tool call does not proceed.
3220
+ // - kind "internal": an in-flight host-loopback request (Panel / Browser /
3221
+ // workspace / credential). `{approved:false}` here is a lie: it makes a
3222
+ // Stop or a session close indistinguishable from the user pressing Deny,
3223
+ // and the model is told its Panel operation was refused. These get a
3224
+ // cancellation sentinel carrying the real cause instead.
3225
+ //
3226
+ // Internal entries settle FIRST: they need no user interaction and have a
3227
+ // determinate outcome, and draining them before the map is emptied keeps
3228
+ // every resolver reachable.
3229
+ //
3230
+ // Drain in a loop rather than over one snapshot. A resolver — or the
3231
+ // observeApprovalTransition hook called synchronously below — may register a
3232
+ // NEW pending entry while we are cancelling. Iterating a snapshot and then
3233
+ // clear()ing would delete that newcomer without ever settling it, leaving its
3234
+ // awaiting tool hung until an outer timeout (its own timer is already gone).
3235
+ // The `settled` guard keeps this terminating even if a resolver re-registers
3236
+ // the same requestId, and the iteration cap stops a pathological resolver
3237
+ // that registers a fresh id every time from spinning forever.
3238
+ const settled = new Set();
3239
+ for (let pass = 0; session.pendingApprovals.size > 0 && pass < 1000; pass += 1) {
3240
+ const entries = [...session.pendingApprovals].filter(([id]) => !settled.has(id));
3241
+ if (entries.length === 0)
3242
+ break;
3243
+ const ordered = [
3244
+ ...entries.filter(([, entry]) => entry.metadata.kind === "internal"),
3245
+ ...entries.filter(([, entry]) => entry.metadata.kind !== "internal"),
3246
+ ];
3247
+ for (const [requestId, entry] of ordered) {
3248
+ settled.add(requestId);
3249
+ session.pendingApprovals.delete(requestId);
3250
+ this.clearApprovalTimer(requestId);
3251
+ this.pendingApprovalTargets.delete(requestId);
3252
+ this.observeApprovalTransition(entry.metadata, status);
3253
+ try {
3254
+ entry.resolve(entry.metadata.kind === "internal"
3255
+ ? internalCancellation(failure, reason)
3256
+ : { approved: false, reason });
3257
+ }
3258
+ catch {
3259
+ /* a resolver must never break cancel cleanup */
3260
+ }
1937
3261
  }
1938
3262
  }
1939
3263
  session.pendingApprovals.clear();
1940
3264
  }
3265
+ failClosedApprovalTargets(targets, reason) {
3266
+ for (const target of targets) {
3267
+ const session = this.chatManager?.get(target.sessionId);
3268
+ if (session) {
3269
+ this.cancelSessionApprovals(session, reason, "owner-lost", "owner_lost");
3270
+ continue;
3271
+ }
3272
+ for (const [requestId, pending] of this.pendingApprovalTargets) {
3273
+ if (pending.connectionId !== target.connectionId ||
3274
+ pending.sessionId !== target.sessionId ||
3275
+ pending.generation !== target.generation) {
3276
+ continue;
3277
+ }
3278
+ const resolve = this.pendingApprovals.get(requestId);
3279
+ this.pendingApprovals.delete(requestId);
3280
+ this.pendingApprovalTargets.delete(requestId);
3281
+ this.clearApprovalTimer(requestId);
3282
+ resolve?.({ approved: false, reason });
3283
+ }
3284
+ }
3285
+ }
1941
3286
  clearAllApprovalTimers() {
1942
3287
  for (const timer of this.approvalTimers.values()) {
1943
3288
  clearTimeout(timer);