@gajae-code/coding-agent 0.10.1 → 0.11.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 (474) hide show
  1. package/CHANGELOG.md +70 -2
  2. package/dist/types/append-only-mode.d.ts +1 -1
  3. package/dist/types/cli/args.d.ts +4 -5
  4. package/dist/types/cli/daemon-cli.d.ts +12 -1
  5. package/dist/types/cli/notify-cli.d.ts +21 -1
  6. package/dist/types/cli.d.ts +3 -0
  7. package/dist/types/commands/daemon.d.ts +39 -0
  8. package/dist/types/commands/launch.d.ts +3 -0
  9. package/dist/types/commands/notify.d.ts +28 -1
  10. package/dist/types/commands/sdk.d.ts +33 -0
  11. package/dist/types/config/atomic-yaml-patch.d.ts +85 -0
  12. package/dist/types/config/model-profile-activation.d.ts +35 -15
  13. package/dist/types/config/model-profiles.d.ts +11 -5
  14. package/dist/types/config/model-registry.d.ts +3 -3
  15. package/dist/types/config/model-resolver.d.ts +39 -6
  16. package/dist/types/config/model-selector-value.d.ts +17 -0
  17. package/dist/types/config/models-config-schema.d.ts +10 -6
  18. package/dist/types/config/settings-schema.d.ts +133 -6
  19. package/dist/types/config/settings.d.ts +63 -35
  20. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -6
  21. package/dist/types/coordinator-mcp/server.d.ts +6 -49
  22. package/dist/types/daemon/builtin.d.ts +6 -5
  23. package/dist/types/daemon/control-types.d.ts +1 -1
  24. package/dist/types/discovery/mcp-json.d.ts +12 -1
  25. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  26. package/dist/types/extensibility/extensions/compact-handler.d.ts +4 -2
  27. package/dist/types/extensibility/extensions/get-commands-handler.d.ts +2 -2
  28. package/dist/types/extensibility/extensions/loader.d.ts +10 -1
  29. package/dist/types/extensibility/extensions/runner.d.ts +3 -3
  30. package/dist/types/extensibility/extensions/types.d.ts +141 -16
  31. package/dist/types/extensibility/hooks/types.d.ts +1 -1
  32. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -2
  33. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
  34. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +3 -0
  35. package/dist/types/gjc-runtime/team-runtime.d.ts +3 -4
  36. package/dist/types/gjc-runtime/tmux-owner-isolation-cli.d.ts +13 -0
  37. package/dist/types/gjc-runtime/tmux-sessions.d.ts +4 -1
  38. package/dist/types/gjc-runtime/windows-powershell-command.d.ts +9 -0
  39. package/dist/types/harness-control-plane/operate.d.ts +5 -5
  40. package/dist/types/harness-control-plane/owner.d.ts +18 -4
  41. package/dist/types/harness-control-plane/receipts.d.ts +0 -1
  42. package/dist/types/harness-control-plane/sdk-transport.d.ts +43 -0
  43. package/dist/types/harness-control-plane/session-transport.d.ts +48 -0
  44. package/dist/types/harness-control-plane/types.d.ts +3 -4
  45. package/dist/types/lsp/index.d.ts +1 -1
  46. package/dist/types/main.d.ts +32 -38
  47. package/dist/types/modes/acp/acp-agent.d.ts +224 -53
  48. package/dist/types/modes/acp/acp-client-bridge.d.ts +1 -0
  49. package/dist/types/modes/acp/acp-mode.d.ts +6 -15
  50. package/dist/types/modes/acp/permission-mode.d.ts +4 -0
  51. package/dist/types/modes/acp/startup-options.d.ts +6 -0
  52. package/dist/types/modes/components/agent-dashboard.d.ts +3 -0
  53. package/dist/types/modes/components/gajae-pet-widget.d.ts +1 -0
  54. package/dist/types/modes/components/model-selector.d.ts +1 -0
  55. package/dist/types/modes/components/notifications-settings-editor.d.ts +135 -0
  56. package/dist/types/modes/components/pet-capability.d.ts +37 -0
  57. package/dist/types/modes/components/pet-selector.d.ts +1 -1
  58. package/dist/types/modes/components/settings-selector.d.ts +15 -4
  59. package/dist/types/modes/components/welcome.d.ts +1 -1
  60. package/dist/types/modes/controllers/selector-controller.d.ts +35 -1
  61. package/dist/types/modes/index.d.ts +0 -4
  62. package/dist/types/modes/interactive-mode.d.ts +5 -1
  63. package/dist/types/modes/runtime-init.d.ts +2 -2
  64. package/dist/types/modes/shared/agent-wire/approval-gate.d.ts +6 -9
  65. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +4 -6
  66. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +6 -6
  67. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +4 -9
  68. package/dist/types/modes/shared/agent-wire/event-observation.d.ts +1 -1
  69. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +169 -30
  70. package/dist/types/modes/shared/agent-wire/workflow-gate-schema.d.ts +7 -7
  71. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +105 -0
  72. package/dist/types/modes/types.d.ts +16 -1
  73. package/dist/types/runtime-mcp/client.d.ts +2 -0
  74. package/dist/types/runtime-mcp/config.d.ts +5 -1
  75. package/dist/types/runtime-mcp/manager.d.ts +8 -1
  76. package/dist/types/runtime-mcp/types.d.ts +4 -0
  77. package/dist/types/sdk/acp/adapter.d.ts +67 -0
  78. package/dist/types/sdk/acp/index.d.ts +2 -0
  79. package/dist/types/sdk/broker/broker.d.ts +53 -0
  80. package/dist/types/sdk/broker/discovery.d.ts +30 -0
  81. package/dist/types/sdk/broker/ensure.d.ts +33 -0
  82. package/dist/types/sdk/broker/identity.d.ts +3 -0
  83. package/dist/types/sdk/broker/index.d.ts +8 -0
  84. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +95 -0
  85. package/dist/types/sdk/broker/lifecycle.d.ts +85 -0
  86. package/dist/types/sdk/broker/process-incarnation.d.ts +14 -0
  87. package/dist/types/sdk/broker/runtime.d.ts +33 -0
  88. package/dist/types/sdk/broker/session-index.d.ts +57 -0
  89. package/dist/types/sdk/broker/state-version.d.ts +8 -0
  90. package/dist/types/sdk/broker/transport.d.ts +9 -0
  91. package/dist/types/sdk/bus/chat-command-policy.d.ts +49 -0
  92. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +21 -0
  93. package/dist/types/sdk/bus/chat-daemon-control.d.ts +111 -0
  94. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +77 -0
  95. package/dist/types/sdk/bus/chat-effect-journal.d.ts +84 -0
  96. package/dist/types/{notifications → sdk/bus}/config-commands.d.ts +7 -2
  97. package/dist/types/sdk/bus/config.d.ts +274 -0
  98. package/dist/types/sdk/bus/conversation-store.d.ts +72 -0
  99. package/dist/types/sdk/bus/discord-conversation.d.ts +58 -0
  100. package/dist/types/sdk/bus/discord-daemon.d.ts +45 -0
  101. package/dist/types/sdk/bus/discord-live-provider.d.ts +70 -0
  102. package/dist/types/sdk/bus/discord-provider.d.ts +85 -0
  103. package/dist/types/{notifications → sdk/bus}/html-format.d.ts +2 -2
  104. package/dist/types/{notifications → sdk/bus}/index.d.ts +113 -13
  105. package/dist/types/{notifications → sdk/bus}/lifecycle-control-runtime.d.ts +4 -2
  106. package/dist/types/{notifications → sdk/bus}/lifecycle-orchestrator.d.ts +18 -21
  107. package/dist/types/{notifications → sdk/bus}/managed-daemon.d.ts +1 -1
  108. package/dist/types/sdk/bus/native-runtime-compatibility.d.ts +13 -0
  109. package/dist/types/sdk/bus/notification-orchestration.d.ts +170 -0
  110. package/dist/types/{notifications → sdk/bus}/notification-service.d.ts +7 -1
  111. package/dist/types/sdk/bus/session-control.d.ts +83 -0
  112. package/dist/types/sdk/bus/slack-conversation.d.ts +56 -0
  113. package/dist/types/sdk/bus/slack-daemon.d.ts +60 -0
  114. package/dist/types/sdk/bus/slack-live-provider.d.ts +59 -0
  115. package/dist/types/sdk/bus/slack-provider.d.ts +55 -0
  116. package/dist/types/{notifications → sdk/bus}/telegram-cli.d.ts +4 -4
  117. package/dist/types/{notifications → sdk/bus}/telegram-daemon-cli.d.ts +14 -5
  118. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -0
  119. package/dist/types/{notifications → sdk/bus}/telegram-daemon-control.d.ts +2 -2
  120. package/dist/types/{notifications → sdk/bus}/telegram-daemon.d.ts +55 -17
  121. package/dist/types/{notifications → sdk/bus}/telegram-reference.d.ts +2 -2
  122. package/dist/types/sdk/bus/telegram-setup.d.ts +128 -0
  123. package/dist/types/{notifications → sdk/bus}/topic-registry.d.ts +11 -0
  124. package/dist/types/sdk/cli/index.d.ts +1 -0
  125. package/dist/types/sdk/cli/session-cli.d.ts +19 -0
  126. package/dist/types/sdk/client/client.d.ts +1 -0
  127. package/dist/types/sdk/client/discovery.d.ts +27 -0
  128. package/dist/types/sdk/client/index.d.ts +2 -0
  129. package/dist/types/sdk/host/control/dispatch.d.ts +34 -0
  130. package/dist/types/sdk/host/control/index.d.ts +2 -0
  131. package/dist/types/sdk/host/control/operations.d.ts +65 -0
  132. package/dist/types/sdk/host/events.d.ts +35 -0
  133. package/dist/types/sdk/host/host.d.ts +35 -0
  134. package/dist/types/sdk/host/index.d.ts +4 -0
  135. package/dist/types/sdk/host/query/cursor.d.ts +62 -0
  136. package/dist/types/sdk/host/query/handlers.d.ts +70 -0
  137. package/dist/types/sdk/host/query/index.d.ts +3 -0
  138. package/dist/types/sdk/host/query/revision-store.d.ts +60 -0
  139. package/dist/types/sdk/host/reverse-leases.d.ts +45 -0
  140. package/dist/types/sdk/host/types.d.ts +32 -0
  141. package/dist/types/sdk/index.d.ts +6 -0
  142. package/dist/types/sdk/lifecycle-session.d.ts +14 -0
  143. package/dist/types/sdk/mcp/index.d.ts +1 -0
  144. package/dist/types/sdk/mcp/server.d.ts +37 -0
  145. package/dist/types/sdk/models.d.ts +44 -0
  146. package/dist/types/sdk/protocol/adapter-validation.d.ts +14 -0
  147. package/dist/types/sdk/protocol/operation-registry.d.ts +24 -0
  148. package/dist/types/{sdk.d.ts → sdk/session.d.ts} +44 -38
  149. package/dist/types/sdk/startup-capability.d.ts +50 -0
  150. package/dist/types/session/agent-session.d.ts +114 -21
  151. package/dist/types/session/default-model-selection.d.ts +25 -0
  152. package/dist/types/session/fallback-chain-controller.d.ts +52 -0
  153. package/dist/types/session/session-manager.d.ts +58 -1
  154. package/dist/types/setup/credential-auto-import.d.ts +36 -6
  155. package/dist/types/tools/ask-answer-registry.d.ts +1 -1
  156. package/dist/types/tools/browser/benchmark/detector-report.d.ts +63 -0
  157. package/dist/types/tools/browser/benchmark/run-suite.d.ts +18 -0
  158. package/dist/types/tools/browser/launch.d.ts +22 -0
  159. package/dist/types/tools/browser/profile-discovery.d.ts +31 -0
  160. package/dist/types/tools/browser/profile-posture.d.ts +37 -0
  161. package/dist/types/tools/browser/profile-reuse.d.ts +39 -0
  162. package/dist/types/tools/browser/profile-warmup.d.ts +39 -0
  163. package/dist/types/tools/browser/registry.d.ts +19 -0
  164. package/dist/types/tools/browser/tab-protocol.d.ts +4 -0
  165. package/dist/types/tools/index.d.ts +8 -8
  166. package/package.json +34 -13
  167. package/scripts/build-sdk-package-smoke.ts +94 -0
  168. package/scripts/capture-notifications-settings-showcase.ts +435 -0
  169. package/scripts/compile-args.ts +4 -2
  170. package/scripts/g005-daemon-smoke.ts +4 -5
  171. package/scripts/g011-daemon-path-smoke.ts +5 -5
  172. package/scripts/generate-sdk-adapter-parity-manifest.ts +163 -0
  173. package/scripts/generate-sdk-operation-inventory.ts +867 -0
  174. package/scripts/generate-telegram-baseline-manifest.ts +128 -0
  175. package/scripts/manifest-schema.ts +124 -0
  176. package/scripts/run-test-manifest.ts +153 -0
  177. package/scripts/run-threat-model-check.ts +157 -0
  178. package/scripts/verify-gjc-sdk-canonicalization.ts +3467 -0
  179. package/scripts/verify-gjc-sdk-rename.ts +90 -0
  180. package/src/append-only-mode.ts +1 -1
  181. package/src/cli/args.ts +43 -12
  182. package/src/cli/auth-gateway-cli.ts +2 -2
  183. package/src/cli/config-cli.ts +2 -1
  184. package/src/cli/daemon-cli.ts +63 -14
  185. package/src/cli/list-models.ts +1 -1
  186. package/src/cli/notify-cli.ts +288 -286
  187. package/src/cli.ts +21 -2
  188. package/src/commands/daemon.ts +50 -6
  189. package/src/commands/harness.ts +93 -36
  190. package/src/commands/launch.ts +5 -2
  191. package/src/commands/mcp-serve.ts +19 -12
  192. package/src/commands/notify.ts +36 -3
  193. package/src/commands/sdk.ts +426 -0
  194. package/src/commit/agentic/agent.ts +1 -0
  195. package/src/config/atomic-yaml-patch.ts +423 -0
  196. package/src/config/model-profile-activation.ts +240 -89
  197. package/src/config/model-profiles.ts +37 -34
  198. package/src/config/model-registry.ts +51 -26
  199. package/src/config/model-resolver.ts +138 -51
  200. package/src/config/model-selector-value.ts +50 -0
  201. package/src/config/models-config-schema.ts +14 -21
  202. package/src/config/settings-schema.ts +128 -10
  203. package/src/config/settings.ts +1120 -158
  204. package/src/coordinator-mcp/model-preset.ts +74 -20
  205. package/src/coordinator-mcp/server.ts +1597 -3052
  206. package/src/daemon/builtin.ts +8 -5
  207. package/src/daemon/control-types.ts +1 -1
  208. package/src/defaults/gjc/skills/team/SKILL.md +4 -4
  209. package/src/discovery/mcp-json.ts +327 -20
  210. package/src/extensibility/custom-tools/types.ts +1 -1
  211. package/src/extensibility/extensions/compact-handler.ts +3 -3
  212. package/src/extensibility/extensions/get-commands-handler.ts +2 -2
  213. package/src/extensibility/extensions/loader.ts +69 -5
  214. package/src/extensibility/extensions/runner.ts +112 -4
  215. package/src/extensibility/extensions/types.ts +176 -17
  216. package/src/extensibility/hooks/types.ts +1 -1
  217. package/src/extensibility/slash-commands.ts +3 -3
  218. package/src/gjc-runtime/launch-tmux.ts +170 -86
  219. package/src/gjc-runtime/psmux-detect.ts +82 -4
  220. package/src/gjc-runtime/session-state-sidecar.ts +41 -31
  221. package/src/gjc-runtime/team-runtime.ts +54 -35
  222. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +53 -17
  223. package/src/gjc-runtime/tmux-owner-isolation.ts +5 -2
  224. package/src/gjc-runtime/tmux-sessions.ts +19 -13
  225. package/src/gjc-runtime/ultragoal-guard.ts +53 -6
  226. package/src/gjc-runtime/ultragoal-runtime.ts +155 -3
  227. package/src/gjc-runtime/windows-powershell-command.ts +58 -0
  228. package/src/harness-control-plane/operate.ts +9 -9
  229. package/src/harness-control-plane/owner.ts +283 -39
  230. package/src/harness-control-plane/receipts.ts +0 -1
  231. package/src/harness-control-plane/sdk-transport.ts +395 -0
  232. package/src/harness-control-plane/session-transport.ts +94 -0
  233. package/src/harness-control-plane/types.ts +1 -1
  234. package/src/internal-urls/docs-index.generated.ts +36 -37
  235. package/src/lsp/client.ts +42 -6
  236. package/src/lsp/index.ts +2 -2
  237. package/src/main.ts +299 -204
  238. package/src/modes/DESIGN.md +258 -0
  239. package/src/modes/acp/acp-agent.ts +1228 -2612
  240. package/src/modes/acp/acp-client-bridge.ts +2 -20
  241. package/src/modes/acp/acp-event-mapper.ts +18 -2
  242. package/src/modes/acp/acp-mode.ts +8 -36
  243. package/src/modes/acp/permission-mode.ts +25 -0
  244. package/src/modes/acp/startup-options.ts +6 -0
  245. package/src/modes/components/agent-dashboard.ts +37 -17
  246. package/src/modes/components/custom-editor.ts +1 -0
  247. package/src/modes/components/gajae-pet-widget.ts +169 -20
  248. package/src/modes/components/model-selector.ts +93 -35
  249. package/src/modes/components/notifications-settings-editor.ts +1379 -0
  250. package/src/modes/components/pet-capability.ts +115 -0
  251. package/src/modes/components/pet-selector.ts +4 -6
  252. package/src/modes/components/settings-selector.ts +86 -11
  253. package/src/modes/components/welcome.ts +4 -4
  254. package/src/modes/controllers/command-controller.ts +1 -2
  255. package/src/modes/controllers/event-controller.ts +11 -16
  256. package/src/modes/controllers/extension-ui-controller.ts +357 -12
  257. package/src/modes/controllers/input-controller.ts +43 -13
  258. package/src/modes/controllers/selector-controller.ts +671 -44
  259. package/src/modes/index.ts +0 -22
  260. package/src/modes/interactive-mode.ts +108 -24
  261. package/src/modes/runtime-init.ts +298 -6
  262. package/src/modes/shared/agent-wire/approval-gate.ts +9 -11
  263. package/src/modes/shared/agent-wire/deep-interview-gate.ts +10 -11
  264. package/src/modes/shared/agent-wire/event-contract.ts +7 -8
  265. package/src/modes/shared/agent-wire/event-envelope.ts +9 -17
  266. package/src/modes/shared/agent-wire/event-observation.ts +16 -7
  267. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +1254 -87
  268. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +15 -15
  269. package/src/modes/shared/agent-wire/workflow-gate-types.ts +113 -0
  270. package/src/modes/types.ts +16 -1
  271. package/src/runtime-mcp/client.ts +94 -19
  272. package/src/runtime-mcp/config.ts +27 -15
  273. package/src/runtime-mcp/manager.ts +343 -105
  274. package/src/runtime-mcp/oauth-flow.ts +42 -12
  275. package/src/runtime-mcp/transports/http.ts +49 -19
  276. package/src/runtime-mcp/transports/stdio.ts +47 -28
  277. package/src/runtime-mcp/types.ts +10 -0
  278. package/src/sdk/acp/adapter.ts +418 -0
  279. package/src/sdk/acp/index.ts +2 -0
  280. package/src/sdk/broker/broker.ts +642 -0
  281. package/src/sdk/broker/discovery.ts +94 -0
  282. package/src/sdk/broker/ensure.ts +249 -0
  283. package/src/sdk/broker/identity.ts +42 -0
  284. package/src/sdk/broker/index.ts +8 -0
  285. package/src/sdk/broker/internal-source-marker-2178.txt +1 -0
  286. package/src/sdk/broker/internal-source.bunfig.toml +1 -0
  287. package/src/sdk/broker/lifecycle-ledger.ts +286 -0
  288. package/src/sdk/broker/lifecycle.ts +2157 -0
  289. package/src/sdk/broker/process-incarnation.ts +159 -0
  290. package/src/sdk/broker/runtime.ts +164 -0
  291. package/src/sdk/broker/session-index.ts +318 -0
  292. package/src/sdk/broker/state-version.ts +23 -0
  293. package/src/sdk/broker/transport.ts +125 -0
  294. package/src/sdk/bus/chat-command-policy.ts +107 -0
  295. package/src/sdk/bus/chat-daemon-cli.ts +214 -0
  296. package/src/sdk/bus/chat-daemon-control.ts +713 -0
  297. package/src/sdk/bus/chat-daemon-runtime.ts +526 -0
  298. package/src/sdk/bus/chat-effect-journal.ts +353 -0
  299. package/src/{notifications → sdk/bus}/config-commands.ts +39 -10
  300. package/src/sdk/bus/config.ts +433 -0
  301. package/src/sdk/bus/conversation-store.ts +391 -0
  302. package/src/sdk/bus/discord-conversation.ts +85 -0
  303. package/src/sdk/bus/discord-daemon.ts +2275 -0
  304. package/src/sdk/bus/discord-live-provider.ts +521 -0
  305. package/src/sdk/bus/discord-provider.ts +56 -0
  306. package/src/{notifications → sdk/bus}/html-format.ts +2 -2
  307. package/src/sdk/bus/index.ts +3943 -0
  308. package/src/{notifications → sdk/bus}/lifecycle-commands.ts +2 -0
  309. package/src/{notifications → sdk/bus}/lifecycle-control-runtime.ts +225 -196
  310. package/src/{notifications → sdk/bus}/lifecycle-orchestrator.ts +161 -74
  311. package/src/{notifications → sdk/bus}/managed-daemon.ts +2 -2
  312. package/src/sdk/bus/native-runtime-compatibility.ts +42 -0
  313. package/src/sdk/bus/natives-augment.d.ts +38 -0
  314. package/src/sdk/bus/notification-orchestration.ts +535 -0
  315. package/src/{notifications → sdk/bus}/notification-service.ts +106 -12
  316. package/src/sdk/bus/session-control.ts +333 -0
  317. package/src/sdk/bus/slack-conversation.ts +74 -0
  318. package/src/sdk/bus/slack-daemon.ts +2144 -0
  319. package/src/sdk/bus/slack-live-provider.ts +380 -0
  320. package/src/sdk/bus/slack-provider.ts +76 -0
  321. package/src/{notifications → sdk/bus}/telegram-cli.ts +8 -8
  322. package/src/sdk/bus/telegram-daemon-cli.ts +330 -0
  323. package/src/sdk/bus/telegram-daemon-contract.ts +14 -0
  324. package/src/{notifications → sdk/bus}/telegram-daemon-control.ts +4 -4
  325. package/src/{notifications → sdk/bus}/telegram-daemon.ts +502 -54
  326. package/src/{notifications → sdk/bus}/telegram-reference.ts +2 -2
  327. package/src/sdk/bus/telegram-setup.ts +663 -0
  328. package/src/{notifications → sdk/bus}/topic-registry.ts +32 -0
  329. package/src/sdk/cli/index.ts +1 -0
  330. package/src/sdk/cli/session-cli.ts +275 -0
  331. package/src/sdk/client/client.ts +1 -0
  332. package/src/sdk/client/discovery.ts +108 -0
  333. package/src/sdk/client/index.ts +2 -0
  334. package/src/sdk/host/control/dispatch.ts +367 -0
  335. package/src/sdk/host/control/index.ts +10 -0
  336. package/src/sdk/host/control/operations.ts +63 -0
  337. package/src/sdk/host/events.ts +91 -0
  338. package/src/sdk/host/host.ts +296 -0
  339. package/src/sdk/host/index.ts +4 -0
  340. package/src/sdk/host/query/cursor.ts +189 -0
  341. package/src/sdk/host/query/handlers.ts +533 -0
  342. package/src/sdk/host/query/index.ts +3 -0
  343. package/src/sdk/host/query/revision-store.ts +1126 -0
  344. package/src/sdk/host/reverse-leases.ts +325 -0
  345. package/src/sdk/host/types.ts +29 -0
  346. package/src/sdk/index.ts +13 -0
  347. package/src/sdk/lifecycle-session.ts +39 -0
  348. package/src/sdk/mcp/index.ts +1 -0
  349. package/src/sdk/mcp/server.ts +332 -0
  350. package/src/sdk/models.ts +168 -0
  351. package/src/sdk/protocol/adapter-validation.ts +48 -0
  352. package/src/sdk/protocol/operation-inventory.generated.json +5654 -0
  353. package/src/sdk/protocol/operation-registry.ts +312 -0
  354. package/src/{sdk.ts → sdk/session.ts} +339 -212
  355. package/src/sdk/startup-capability.ts +189 -0
  356. package/src/session/agent-session.ts +2470 -866
  357. package/src/session/default-model-selection.ts +93 -0
  358. package/src/session/fallback-chain-controller.ts +136 -0
  359. package/src/session/session-manager.ts +447 -13
  360. package/src/session/session-storage.ts +8 -1
  361. package/src/setup/credential-auto-import.ts +290 -35
  362. package/src/setup/hermes/templates/operator-instructions.v1.md +3 -5
  363. package/src/slash-commands/builtin-registry.ts +33 -30
  364. package/src/task/executor.ts +62 -13
  365. package/src/tools/ask-answer-registry.ts +1 -1
  366. package/src/tools/ask.ts +13 -8
  367. package/src/tools/browser/STEALTH-THIRD-PARTY-NOTICES.md +26 -0
  368. package/src/tools/browser/benchmark/detector-report.ts +157 -0
  369. package/src/tools/browser/benchmark/run-suite.ts +39 -0
  370. package/src/tools/browser/launch.ts +124 -19
  371. package/src/tools/browser/profile-discovery.ts +63 -0
  372. package/src/tools/browser/profile-posture.ts +59 -0
  373. package/src/tools/browser/profile-reuse.ts +102 -0
  374. package/src/tools/browser/profile-warmup.ts +109 -0
  375. package/src/tools/browser/registry.ts +87 -11
  376. package/src/tools/browser/tab-protocol.ts +1 -0
  377. package/src/tools/browser/tab-supervisor.ts +1 -0
  378. package/src/tools/browser/tab-worker.ts +1 -1
  379. package/src/tools/browser.ts +6 -0
  380. package/src/tools/index.ts +8 -8
  381. package/src/tools/puppeteer/14_stealth_permissions.txt +57 -0
  382. package/src/tools/puppeteer/15_stealth_webrtc.txt +60 -0
  383. package/src/tools/telegram-send.ts +2 -2
  384. package/vendor/insane-search/MANIFEST.json +2 -1
  385. package/vendor/insane-search/engine/bias_check.py +4 -1
  386. package/vendor/insane-search/engine/phase0.py +26 -1
  387. package/dist/types/harness-control-plane/rpc-adapter.d.ts +0 -69
  388. package/dist/types/modes/bridge/auth.d.ts +0 -12
  389. package/dist/types/modes/bridge/bridge-client-bridge.d.ts +0 -9
  390. package/dist/types/modes/bridge/bridge-mode.d.ts +0 -47
  391. package/dist/types/modes/bridge/bridge-ui-context.d.ts +0 -88
  392. package/dist/types/modes/bridge/event-stream.d.ts +0 -8
  393. package/dist/types/modes/rpc/host-tools.d.ts +0 -1
  394. package/dist/types/modes/rpc/host-uris.d.ts +0 -1
  395. package/dist/types/modes/rpc/rpc-client.d.ts +0 -299
  396. package/dist/types/modes/rpc/rpc-mode.d.ts +0 -86
  397. package/dist/types/modes/rpc/rpc-socket-security.d.ts +0 -10
  398. package/dist/types/modes/rpc/rpc-types.d.ts +0 -810
  399. package/dist/types/modes/shared/agent-wire/command-contract.d.ts +0 -18
  400. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +0 -37
  401. package/dist/types/modes/shared/agent-wire/command-validation.d.ts +0 -2
  402. package/dist/types/modes/shared/agent-wire/handshake.d.ts +0 -56
  403. package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +0 -16
  404. package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +0 -17
  405. package/dist/types/modes/shared/agent-wire/protocol.d.ts +0 -25
  406. package/dist/types/modes/shared/agent-wire/responses.d.ts +0 -4
  407. package/dist/types/modes/shared/agent-wire/scopes.d.ts +0 -18
  408. package/dist/types/modes/shared/agent-wire/session-registry.d.ts +0 -25
  409. package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +0 -42
  410. package/dist/types/modes/shared/agent-wire/ui-result.d.ts +0 -27
  411. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +0 -29
  412. package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +0 -68
  413. package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +0 -166
  414. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +0 -127
  415. package/dist/types/notifications/config.d.ts +0 -132
  416. package/src/harness-control-plane/rpc-adapter.ts +0 -290
  417. package/src/modes/bridge/auth.ts +0 -41
  418. package/src/modes/bridge/bridge-client-bridge.ts +0 -47
  419. package/src/modes/bridge/bridge-mode.ts +0 -675
  420. package/src/modes/bridge/bridge-ui-context.ts +0 -200
  421. package/src/modes/bridge/event-stream.ts +0 -70
  422. package/src/modes/rpc/host-tools.ts +0 -1
  423. package/src/modes/rpc/host-uris.ts +0 -1
  424. package/src/modes/rpc/rpc-client.ts +0 -1166
  425. package/src/modes/rpc/rpc-mode.ts +0 -1029
  426. package/src/modes/rpc/rpc-socket-security.ts +0 -115
  427. package/src/modes/rpc/rpc-types.ts +0 -637
  428. package/src/modes/shared/agent-wire/command-contract.ts +0 -18
  429. package/src/modes/shared/agent-wire/command-dispatch.ts +0 -498
  430. package/src/modes/shared/agent-wire/command-validation.ts +0 -181
  431. package/src/modes/shared/agent-wire/handshake.ts +0 -157
  432. package/src/modes/shared/agent-wire/host-tool-bridge.ts +0 -197
  433. package/src/modes/shared/agent-wire/host-uri-bridge.ts +0 -236
  434. package/src/modes/shared/agent-wire/protocol.ts +0 -46
  435. package/src/modes/shared/agent-wire/responses.ts +0 -17
  436. package/src/modes/shared/agent-wire/scopes.ts +0 -94
  437. package/src/modes/shared/agent-wire/session-registry.ts +0 -109
  438. package/src/modes/shared/agent-wire/ui-request-broker.ts +0 -150
  439. package/src/modes/shared/agent-wire/ui-result.ts +0 -48
  440. package/src/modes/shared/agent-wire/unattended-action-policy.ts +0 -365
  441. package/src/modes/shared/agent-wire/unattended-audit.ts +0 -176
  442. package/src/modes/shared/agent-wire/unattended-run-controller.ts +0 -426
  443. package/src/modes/shared/agent-wire/unattended-session.ts +0 -528
  444. package/src/notifications/config.ts +0 -219
  445. package/src/notifications/index.ts +0 -1835
  446. package/src/notifications/telegram-daemon-cli.ts +0 -243
  447. package/src/tools/puppeteer/09_stealth_locale.txt +0 -46
  448. /package/dist/types/{notifications → sdk/bus}/attachment-registry.d.ts +0 -0
  449. /package/dist/types/{notifications → sdk/bus}/chat-adapters.d.ts +0 -0
  450. /package/dist/types/{notifications → sdk/bus}/daemon-paths.d.ts +0 -0
  451. /package/dist/types/{notifications → sdk/bus}/engine.d.ts +0 -0
  452. /package/dist/types/{notifications → sdk/bus}/helpers.d.ts +0 -0
  453. /package/dist/types/{notifications → sdk/bus}/lifecycle-commands.d.ts +0 -0
  454. /package/dist/types/{notifications → sdk/bus}/operator-runtime.d.ts +0 -0
  455. /package/dist/types/{notifications → sdk/bus}/rate-limit-pool.d.ts +0 -0
  456. /package/dist/types/{notifications → sdk/bus}/recent-activity.d.ts +0 -0
  457. /package/dist/types/{notifications → sdk/bus}/reply-sent-store.d.ts +0 -0
  458. /package/dist/types/{notifications → sdk/bus}/rich-draft.d.ts +0 -0
  459. /package/dist/types/{notifications → sdk/bus}/rich-render.d.ts +0 -0
  460. /package/dist/types/{notifications → sdk/bus}/threaded-inbound.d.ts +0 -0
  461. /package/dist/types/{notifications → sdk/bus}/threaded-render.d.ts +0 -0
  462. /package/src/{notifications → sdk/bus}/attachment-registry.ts +0 -0
  463. /package/src/{notifications → sdk/bus}/chat-adapters.ts +0 -0
  464. /package/src/{notifications → sdk/bus}/daemon-paths.ts +0 -0
  465. /package/src/{notifications → sdk/bus}/engine.ts +0 -0
  466. /package/src/{notifications → sdk/bus}/helpers.ts +0 -0
  467. /package/src/{notifications → sdk/bus}/operator-runtime.ts +0 -0
  468. /package/src/{notifications → sdk/bus}/rate-limit-pool.ts +0 -0
  469. /package/src/{notifications → sdk/bus}/recent-activity.ts +0 -0
  470. /package/src/{notifications → sdk/bus}/reply-sent-store.ts +0 -0
  471. /package/src/{notifications → sdk/bus}/rich-draft.ts +0 -0
  472. /package/src/{notifications → sdk/bus}/rich-render.ts +0 -0
  473. /package/src/{notifications → sdk/bus}/threaded-inbound.ts +0 -0
  474. /package/src/{notifications → sdk/bus}/threaded-render.ts +0 -0
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import * as path from "node:path";
2
3
  import {
3
4
  type Agent,
@@ -5,15 +6,11 @@ import {
5
6
  type AuthenticateRequest,
6
7
  type AuthenticateResponse,
7
8
  type AuthMethod,
8
- type AvailableCommand,
9
9
  type ClientCapabilities,
10
10
  type CloseSessionRequest,
11
11
  type CloseSessionResponse,
12
- type CreateElicitationResponse,
13
12
  type DeleteSessionRequest,
14
13
  type DeleteSessionResponse,
15
- type ElicitationContentValue,
16
- type ElicitationPropertySchema,
17
14
  type ForkSessionRequest,
18
15
  type ForkSessionResponse,
19
16
  type InitializeRequest,
@@ -22,7 +19,6 @@ import {
22
19
  type ListSessionsResponse,
23
20
  type LoadSessionRequest,
24
21
  type LoadSessionResponse,
25
- type McpServer,
26
22
  type NewSessionRequest,
27
23
  type NewSessionResponse,
28
24
  PROTOCOL_VERSION,
@@ -30,471 +26,576 @@ import {
30
26
  type PromptResponse,
31
27
  type ResumeSessionRequest,
32
28
  type ResumeSessionResponse,
33
- type SessionConfigOption,
34
29
  type SessionInfo,
35
- type SessionModeState,
36
30
  type SessionNotification,
37
- type SessionUpdate,
38
31
  type SetSessionConfigOptionRequest,
39
32
  type SetSessionConfigOptionResponse,
40
33
  type SetSessionModeRequest,
41
34
  type SetSessionModeResponse,
42
- type Usage,
43
35
  } from "@agentclientprotocol/sdk";
44
- import type { AssistantMessage, Model } from "@gajae-code/ai";
45
- import { logger } from "@gajae-code/utils";
36
+ import { getAgentDir } from "@gajae-code/utils";
46
37
  import packageJson from "../../../package.json" with { type: "json" };
47
- import { disableProvider, enableProvider, reset as resetCapabilities } from "../../capability";
48
- import { Settings } from "../../config/settings";
49
- import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
50
- import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../../extensibility/extensions";
51
- import { runExtensionCompact } from "../../extensibility/extensions/compact-handler";
52
- import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
53
- import { resolveSubskillActivationForSkillInvocation } from "../../extensibility/gjc-plugins";
54
38
  import {
55
- buildSkillPromptMessage,
56
- getSkillSlashCommandNames,
57
- isNamespacedSkillSlashCommandName,
58
- isSkillSlashCommandName,
59
- parseSkillInvocations,
60
- } from "../../extensibility/skills";
61
- import { loadSlashCommands } from "../../extensibility/slash-commands";
62
- import { loadAllExtensions } from "../../modes/components/extensions/state-manager";
63
- import { theme } from "../../modes/theme/theme";
64
- import { MCPManager } from "../../runtime-mcp/manager";
65
- import type { MCPServerConfig } from "../../runtime-mcp/types";
66
- import type { AgentSession, AgentSessionEvent } from "../../session/agent-session";
67
- import { isSilentAbort, SKILL_PROMPT_MESSAGE_TYPE } from "../../session/messages";
68
- import { isLegacyProviderSafetyStopMessage } from "../../session/provider-safety-stop";
69
- import {
70
- SessionManager,
71
- type SessionInfo as StoredSessionInfo,
72
- type StrictInventoryCandidate,
73
- type UsageStatistics,
74
- } from "../../session/session-manager";
75
- import {
76
- FileSessionStorage,
77
- type SessionStorageFileIdentity,
78
- type VerifiedSessionDeleteResult,
79
- type VerifiedSessionDeleteTarget,
80
- } from "../../session/session-storage";
81
- import { ACP_BUILTIN_SLASH_COMMANDS, executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins";
82
- import { parseThinkingLevel } from "../../thinking";
83
- import { toAgentWireEventPayload } from "../shared/agent-wire/event-envelope";
84
- import { createAcpClientBridge } from "./acp-client-bridge";
85
- import {
86
- buildToolCallStartUpdate,
87
- mapAgentWireEventPayloadToAcpSessionUpdates,
88
- normalizeReplayToolArguments,
89
- } from "./acp-event-mapper";
39
+ type AcpProviderRegistration,
40
+ type AcpReverseConnection,
41
+ AcpSdkAdapter,
42
+ AcpSdkAdapterError,
43
+ } from "../../sdk/acp";
44
+ import { ensureBroker } from "../../sdk/broker/ensure";
45
+ import { readSdkBrokerDiscovery, SdkClient } from "../../sdk/client";
46
+ import { mapAgentWireEventPayloadToAcpSessionUpdates } from "./acp-event-mapper";
47
+ import { resolveAcpPermissionMode } from "./permission-mode";
48
+ import type { AcpStartupOptions } from "./startup-options";
90
49
  import { ACP_TERMINAL_AUTH_FLAG } from "./terminal-auth";
91
50
 
92
51
  const ACP_DEFAULT_MODE_ID = "default";
93
52
  const ACP_PLAN_MODE_ID = "plan";
94
- const DEFAULT_PLAN_FILE_URL = "local://PLAN.md";
95
53
  const MODE_CONFIG_ID = "mode";
96
54
  const MODEL_CONFIG_ID = "model";
97
55
  const THINKING_CONFIG_ID = "thinking";
98
- const THINKING_OFF = "off";
99
56
  const SESSION_PAGE_SIZE = 50;
57
+ export const ACP_BOOTSTRAP_RACE_GUARD_MS = 50;
58
+ const MAX_ACP_REPLAY_PAGES = 10_000;
100
59
 
60
+ type JsonObject = Record<string, unknown>;
101
61
  /**
102
- * One immutable authorization snapshot built from a complete strict scoped inventory.
103
- * `entries` maps every discovered session id to either the single exact-identity
104
- * candidate (authoritative) or a conflict tombstone (duplicate/unsafe). The snapshot
105
- * is built once, before pagination, so a duplicate beyond page 1 is known before the
106
- * first page response. Lifecycle changes bump {@link #authorityGeneration} and
107
- * invalidate the snapshot.
62
+ * ACP prompt completion is tied to a post-acknowledgement lifecycle boundary.
63
+ * AgentSession events do not carry the command identity themselves, so the
64
+ * host stamps command/turn identities into its replay ring and ACP also keeps
65
+ * a per-endpoint ingress sequence. A frame observed before an acknowledgement
66
+ * can never settle the waiter that acknowledgement creates.
108
67
  */
109
- type AcpAuthorityEntry =
110
- | { kind: "candidate"; candidate: StrictInventoryCandidate }
111
- | { kind: "conflict"; reason: string };
112
- /**
113
- * Phase-aware same-connection retry evidence for an incomplete verified delete.
114
- * The transcript identity is always recorded and re-verified on retry; the
115
- * artifacts identity is recorded only when the failure occurred at the
116
- * artifacts phase (the artifact directory still existed at failure time).
117
- */
118
- type PendingDeleteEvidence = {
119
- transcriptIdentity: SessionStorageFileIdentity;
120
- artifactsIdentity?: SessionStorageFileIdentity;
121
- };
68
+ interface PromptWaiter {
69
+ cancelRequested: boolean;
70
+ acknowledged: boolean;
71
+ activityObserved: boolean;
72
+ /** The prompt was accepted while the host was already busy, so its next valid idle ends the steer. */
73
+ steeringAtAcknowledgement: boolean;
74
+ /** Highest inbound frame sequence already observed when the prompt was acknowledged. */
75
+ boundary: number;
76
+ correlation: PromptCorrelation;
77
+ pendingTerminal?: PromptCorrelation;
78
+ resolve: (response: PromptResponse) => void;
79
+ reject: (error: Error) => void;
80
+ }
122
81
 
123
- type AcpAuthoritySnapshot = {
124
- scope: string;
125
- entries: Map<string, AcpAuthorityEntry>;
126
- /** Ordered unique session ids (conflicts appear once at first sight) for paging. */
127
- orderedIds: string[];
128
- generation: number;
129
- };
130
- /**
131
- * Delay between `session/new` (or `session/load` / `session/resume` /
132
- * `unstable_session/fork`) returning and the agent firing the first
133
- * notifications against the new session id. Mitigates Zed's
134
- * `Received session notification for unknown session` race — see
135
- * `#scheduleBootstrapUpdates`. Exported so the ACP test harness can
136
- * wait past this guard without hard-coding the literal.
137
- */
138
- export const ACP_BOOTSTRAP_RACE_GUARD_MS = 50;
139
- const CODING_AGENT_VERSION: string = packageJson.version;
140
- const ACP_CANCEL_CLEANUP_TIMEOUT_MS = 5_000;
141
- const ACP_ASYNC_DELIVERY_DRAIN_TIMEOUT_MS = 250;
142
- const ACP_ASYNC_DELIVERY_DRAIN_MAX_PASSES = 3;
143
-
144
- type AgentImageContent = {
145
- type: "image";
146
- data: string;
147
- mimeType: string;
82
+ type PromptCorrelation = { commandId?: string; turnId?: string };
83
+
84
+ type BrokerConnection = { adapter: AcpSdkAdapter; client: SdkClient };
85
+ type PendingAttachment = { epoch: number; task: Promise<void> };
86
+
87
+ type SessionRecord = {
88
+ cwd: string;
89
+ adapter: AcpSdkAdapter;
90
+ unsubscribe: () => void;
91
+ reconnectUnsubscribe: () => void;
92
+ /** Per-session frame work queue; callbacks never race prompt ownership. */
93
+ frameTail: Promise<void>;
94
+ /** Monotonic at WebSocket ingress, before queued work begins. */
95
+ inboundSequence: number;
96
+ /** Updated at ingress so a prompt acknowledgement can distinguish a steer from a fresh turn. */
97
+ busy: boolean;
98
+ activePrompt?: PromptWaiter;
148
99
  };
100
+ type Endpoint = { url: string; token: string };
149
101
 
150
- type PromptQueueState = {
151
- promise: Promise<void>;
152
- release: (() => void) | undefined;
102
+ type BrokerSession = {
103
+ sessionId: string;
104
+ locator?: { repo?: string };
105
+ live?: boolean;
106
+ endpointGeneration?: number;
153
107
  };
154
108
 
155
- type PromptTurnState = {
156
- userMessageId: string;
157
- cancelRequested: boolean;
158
- settled: boolean;
159
- /**
160
- * `abort()` is in-flight (or its bounded-timeout race). `undefined` while the turn is
161
- * running normally and after cleanup completes. The turn occupies `record.promptTurn`
162
- * for as long as either `!settled` or `cleanup` is set — that combined window is the
163
- * "turn in flight" predicate (`isPromptTurnInFlight`) every consumer gates on.
164
- */
165
- cleanup: Promise<void> | undefined;
166
- usageBaseline: UsageStatistics;
167
- unsubscribe: (() => void) | undefined;
168
- resolve: (value: PromptResponse) => void;
169
- reject: (reason?: unknown) => void;
170
- promise: Promise<PromptResponse>;
171
- };
109
+ function parseAcpStartupOptions(value: unknown): AcpStartupOptions | undefined {
110
+ const candidate = object(value);
111
+ if (!candidate) return undefined;
112
+ const modelId = typeof candidate.modelId === "string" ? candidate.modelId : undefined;
113
+ const modelPreset = typeof candidate.modelPreset === "string" ? candidate.modelPreset : undefined;
114
+ const thinkingLevel = typeof candidate.thinkingLevel === "string" ? candidate.thinkingLevel : undefined;
115
+ return modelId || modelPreset || thinkingLevel
116
+ ? {
117
+ ...(modelId ? { modelId } : {}),
118
+ ...(modelPreset ? { modelPreset } : {}),
119
+ ...(thinkingLevel ? { thinkingLevel } : {}),
120
+ }
121
+ : undefined;
122
+ }
172
123
 
173
- /**
174
- * A turn is "in flight" from the moment `prompt()` reserves the slot until `settled` is
175
- * true AND any cancel cleanup has completed. Fork/queue/event gating all depend on this
176
- * combined window — a settled-but-still-aborting turn is not safe to fork from, queue
177
- * onto, or forward late events for.
178
- */
179
- function isPromptTurnInFlight(turn: PromptTurnState | undefined): turn is PromptTurnState {
180
- return turn !== undefined && (!turn.settled || turn.cleanup !== undefined);
124
+ function object(value: unknown): JsonObject | undefined {
125
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : undefined;
126
+ }
127
+
128
+ function aggregateAcpFailure(code: string, message: string, failures: unknown[]): AcpSdkAdapterError {
129
+ const aggregate = new AggregateError(failures, message);
130
+ return Object.assign(new AcpSdkAdapterError(code, aggregate.message), {
131
+ cause: aggregate,
132
+ errors: aggregate.errors,
133
+ });
134
+ }
135
+
136
+ /** Applies ACP's offset cursor after narrowing the broker listing to the requested cwd. */
137
+ export function paginateAcpSessions(listed: unknown[], cwd: string | undefined, offset: number): ListSessionsResponse {
138
+ const filtered = listed
139
+ .map(value => object(value) as BrokerSession | undefined)
140
+ .filter(
141
+ (value): value is BrokerSession & { locator: { repo: string } } =>
142
+ typeof value?.sessionId === "string" && typeof value.locator?.repo === "string",
143
+ )
144
+ .filter(value => !cwd || value.locator.repo === cwd);
145
+ const sessions = filtered
146
+ .slice(offset, offset + SESSION_PAGE_SIZE)
147
+ .map(
148
+ value =>
149
+ ({ sessionId: value.sessionId, cwd: value.locator.repo, title: value.sessionId }) satisfies SessionInfo,
150
+ );
151
+ return {
152
+ sessions,
153
+ nextCursor: offset + sessions.length < filtered.length ? String(offset + sessions.length) : undefined,
154
+ };
155
+ }
156
+
157
+ function endpoint(value: unknown): Endpoint {
158
+ const candidate = object(value);
159
+ const result = object(candidate?.result) ?? candidate;
160
+ const nested = object(result?.endpoint) ?? result;
161
+ if (typeof nested?.url !== "string" || typeof nested.token !== "string")
162
+ throw new AcpSdkAdapterError("unavailable", "SDK lifecycle response omitted a session endpoint.");
163
+ return { url: nested.url, token: nested.token };
164
+ }
165
+
166
+ function sessionId(value: unknown): string {
167
+ const candidate = object(value);
168
+ const result = object(candidate?.result) ?? candidate;
169
+ if (typeof result?.sessionId !== "string" || !result.sessionId)
170
+ throw new AcpSdkAdapterError("unavailable", "SDK lifecycle response omitted a session id.");
171
+ return result.sessionId;
181
172
  }
182
173
 
174
+ function pageItems(value: unknown): unknown[] {
175
+ const response = object(value);
176
+ const result = object(response?.result) ?? response;
177
+ const page = object(result?.page);
178
+ return Array.isArray(page?.items) ? page.items : [];
179
+ }
180
+
181
+ function correlationFrom(...values: unknown[]): PromptCorrelation {
182
+ const correlation: PromptCorrelation = {};
183
+ for (const value of values) {
184
+ const candidate = object(value);
185
+ for (const record of [candidate, object(candidate?.result)]) {
186
+ if (!record) continue;
187
+ if (!correlation.commandId) {
188
+ const commandId = record.commandId ?? record.command_id;
189
+ if (typeof commandId === "string" && commandId) correlation.commandId = commandId;
190
+ }
191
+ if (!correlation.turnId) {
192
+ const turnId = record.turnId ?? record.turn_id;
193
+ if (typeof turnId === "string" && turnId) correlation.turnId = turnId;
194
+ }
195
+ }
196
+ }
197
+ return correlation;
198
+ }
199
+
200
+ function correlationsConflict(expected: PromptCorrelation, actual: PromptCorrelation): boolean {
201
+ return (
202
+ (expected.commandId !== undefined && actual.commandId !== undefined && expected.commandId !== actual.commandId) ||
203
+ (expected.turnId !== undefined && actual.turnId !== undefined && expected.turnId !== actual.turnId)
204
+ );
205
+ }
206
+
207
+ function correlationsMatch(expected: PromptCorrelation, actual: PromptCorrelation): boolean {
208
+ return (
209
+ (expected.commandId !== undefined && expected.commandId === actual.commandId) ||
210
+ (expected.turnId !== undefined && expected.turnId === actual.turnId)
211
+ );
212
+ }
213
+
214
+ function isPromptActivity(eventType: string): boolean {
215
+ return [
216
+ "agent_start",
217
+ "turn_start",
218
+ "message_start",
219
+ "message_update",
220
+ "message_end",
221
+ "tool_execution_start",
222
+ "tool_execution_update",
223
+ "tool_execution_end",
224
+ ].includes(eventType);
225
+ }
226
+
227
+ export type TranscriptReplayBlock = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string };
228
+
183
229
  /**
184
- * Per-record terminal state machine for session deletion.
185
- * - `active`: normal operation; all lifecycle methods allowed.
186
- * - `deleting`: a terminal delete has reserved the record; prompt/config/close/
187
- * load/resume/fork reject, concurrent deletes join the terminal promise.
188
- * - `pre_dispatch_retryable`: close_failed_retryable before verified-delete
189
- * dispatch; normal ops rejected but a subsequent delete may retry.
190
- * - `cleanup_pending`: verified delete returned cleanup_pending with identity
191
- * evidence; normal ops rejected but a subsequent delete may retry.
192
- * - `terminal_failure`: close_unknown or quiescence failure (or any unexpected
193
- * failure); normal ops rejected and delete NEVER reopens — subsequent deletes
194
- * share the settled terminal failure error.
195
- * - `deleted`: verified delete succeeded; record removed from the active map.
230
+ * The production transcript query exposes durable `{ body, textSummary }`
231
+ * entries, not an ACP-shaped `content` array. Historical session JSONL has no
232
+ * recoverable image bytes, so replay exposes that boundary rather than
233
+ * pretending images were restored.
196
234
  */
197
- type AcpSessionTerminalState =
198
- | "active"
199
- | "deleting"
200
- | "pre_dispatch_retryable"
201
- | "cleanup_pending"
202
- | "terminal_failure"
203
- | "deleted";
204
-
205
- type ManagedSessionRecord = {
206
- session: AgentSession;
207
- mcpManager: MCPManager | undefined;
208
- promptTurn: PromptTurnState | undefined;
209
- promptQueue: PromptQueueState;
210
- liveMessageId: string | undefined;
211
- liveMessageProgress: { textEmitted: boolean; thoughtEmitted: boolean } | undefined;
212
- toolArgsById: Map<string, unknown>;
213
- extensionsConfigured: boolean;
214
- // Installed after the bootstrap race guard or eagerly when the client starts a prompt;
215
- // released in `#disposeSessionRecord`. Lives independent of any prompt turn.
216
- lifetimeUnsubscribe: (() => void) | undefined;
217
- // Per-record terminal state machine (see AcpSessionTerminalState). After ANY
218
- // delete failure the record stays non-`active` so prompt/config/close/load/
219
- // resume/fork remain rejected. Only `pre_dispatch_retryable` and
220
- // `cleanup_pending` permit a retry delete; `terminal_failure` never reopens.
221
- terminalState: AcpSessionTerminalState;
222
- // The settled terminal failure error, shared with subsequent delete callers
223
- // so a terminal_failure is observable without re-running the terminal op.
224
- terminalFailure: Error | undefined;
225
- // Resolves when an in-progress terminal delete settles. Concurrent delete
226
- // callers and abort/shutdown join this promise.
227
- terminalPromise: Promise<DeleteSessionResponse> | undefined;
228
- };
229
-
230
- type ReplayableMessage = {
231
- role: string;
232
- content?: unknown;
233
- errorMessage?: string;
234
- toolCallId?: string;
235
- toolName?: string;
236
- details?: unknown;
237
- isError?: boolean;
238
- };
235
+ export interface TranscriptReplayContent {
236
+ blocks: TranscriptReplayBlock[];
237
+ images: { available: false; reason: "historical_transcript_images_unavailable" };
238
+ }
239
239
 
240
- type ReplayableToolItem = {
241
- type?: unknown;
242
- id?: unknown;
243
- name?: unknown;
244
- arguments?: unknown;
245
- input?: unknown;
246
- };
240
+ export function transcriptReplayContent(entry: unknown): TranscriptReplayContent {
241
+ const record = object(entry);
242
+ if (typeof record?.body !== "string")
243
+ throw new AcpSdkAdapterError(
244
+ "transcript_body_unavailable",
245
+ "ACP cannot replay a transcript entry without its production body.",
246
+ );
247
+ return {
248
+ blocks: record.body.length > 0 ? [{ type: "text", text: record.body }] : [],
249
+ images: { available: false, reason: "historical_transcript_images_unavailable" },
250
+ };
251
+ }
247
252
 
248
- type MCPConfigMap = {
249
- [name: string]: MCPServerConfig;
253
+ type ReceivedSdkEvent = {
254
+ event: JsonObject;
255
+ /** Event payload accepted by the ACP event mapper, when this is an agent-wire frame. */
256
+ wirePayload?: JsonObject;
250
257
  };
251
258
 
252
- type MCPSource = {
253
- provider: string;
254
- providerName: string;
255
- path: string;
256
- level: "project";
257
- };
259
+ /**
260
+ * Native session hosts emit `activity` directly; test-only/legacy adapters may
261
+ * wrap agent-wire events in `{ type: "event", payload }`. Normalize both
262
+ * without treating notification-specific frames as agent lifecycle truth.
263
+ */
264
+ function receivedSdkEvent(frame: JsonObject): ReceivedSdkEvent | undefined {
265
+ if (frame.type === "activity") {
266
+ const type = frame.state === "busy" ? "agent_start" : frame.state === "idle" ? "agent_end" : undefined;
267
+ return type ? { event: { type, ...correlationFrom(frame) } } : undefined;
268
+ }
269
+ if (frame.type !== "event") return undefined;
270
+ const payload = object(frame.payload);
271
+ if (!payload) return undefined;
272
+ const replayPayload = object(payload.payload);
273
+ const event = object(payload.event) ?? replayPayload ?? payload;
274
+ if (typeof event.type !== "string") return undefined;
275
+ return {
276
+ event,
277
+ ...(object(payload.event) ? { wirePayload: payload } : {}),
278
+ };
279
+ }
258
280
 
259
- type MCPSourceMap = {
260
- [name: string]: MCPSource;
281
+ const ACP_CONFIG_OPTIONS = [
282
+ { id: MODEL_CONFIG_ID, name: "Model", options: [] },
283
+ { id: THINKING_CONFIG_ID, name: "Thinking", options: [] },
284
+ {
285
+ id: "steeringMode",
286
+ name: "Steering queue",
287
+ options: [
288
+ { value: "all", name: "All" },
289
+ { value: "one-at-a-time", name: "One at a time" },
290
+ ],
291
+ },
292
+ {
293
+ id: "followUpMode",
294
+ name: "Follow-up queue",
295
+ options: [
296
+ { value: "all", name: "All" },
297
+ { value: "one-at-a-time", name: "One at a time" },
298
+ ],
299
+ },
300
+ {
301
+ id: "interruptMode",
302
+ name: "Interrupt mode",
303
+ options: [
304
+ { value: "immediate", name: "Immediate" },
305
+ { value: "wait", name: "Wait" },
306
+ ],
307
+ },
308
+ ] as const;
309
+
310
+ const ACP_CONFIG_CONTROL_OPERATIONS: Record<string, string> = {
311
+ steeringMode: "queue.steering_mode.set",
312
+ followUpMode: "queue.follow_up_mode.set",
313
+ interruptMode: "queue.interrupt_mode.set",
261
314
  };
262
315
 
263
- type CreateAcpSession = (cwd: string) => Promise<AgentSession>;
316
+ function configValues(query: unknown): Map<string, string> {
317
+ const values = new Map<string, string>();
318
+ for (const item of pageItems(query)) {
319
+ const record = object(item);
320
+ if (!record) continue;
321
+ if (typeof record.id === "string" && typeof record.value === "string") {
322
+ values.set(record.id, record.value);
323
+ continue;
324
+ }
325
+ for (const [id, value] of Object.entries(record)) {
326
+ if (typeof value === "string") values.set(id, value);
327
+ }
328
+ }
329
+ return values;
330
+ }
264
331
 
265
- /**
266
- * Bridge a single ExtensionUIContext call to the ACP `unstable_createElicitation`
267
- * surface. Skills/extensions ask for one value at a time (a chosen option, a
268
- * confirmation, a piece of text), so every elicitation here uses a one-property
269
- * `value` schema; the caller narrows the resulting `ElicitationContentValue`
270
- * back to its concrete primitive type.
271
- *
272
- * `dialogOptions.signal` short-circuits the elicitation if it is already
273
- * aborted and races the in-flight request against the abort event. The SDK
274
- * exposes no `cancel_elicitation` surface for form-mode elicitations
275
- * (`unstable_completeElicitation` is URL-mode only), so the ACP request itself
276
- * keeps running on the client side until the user dismisses it — but
277
- * resolving the local promise unblocks the caller (matches the RPC mode
278
- * pattern in `requestRpcEditor`). The abort listener is removed once the
279
- * elicitation settles so that callers which reuse the same signal across many
280
- * elicitations (e.g. `ask` multi-select loops) don't accumulate listeners and
281
- * trip Node's `MaxListeners` warning.
282
- *
283
- * `dialogOptions.timeout` mirrors `RpcExtensionUIContext.#createDialogPromise`:
284
- * when the timer fires before the client responds, `onTimeout` is invoked and
285
- * the caller's promise resolves to the stub fallback. Late SDK responses that
286
- * arrive after abort/timeout — both rejections and successful `accept`s —
287
- * are dropped silently (no `logger.warn`) to keep operator logs clean.
288
- */
289
- async function elicitFromAcpClient(
290
- connection: AgentSideConnection,
291
- sessionId: string,
292
- method: "select" | "confirm" | "input",
293
- message: string,
294
- property: ElicitationPropertySchema,
295
- dialogOptions: ExtensionUIDialogOptions | undefined,
296
- ): Promise<ElicitationContentValue | undefined> {
297
- const signal = dialogOptions?.signal;
298
- if (signal?.aborted) {
299
- return undefined;
332
+ function modelConfigOptions(query: unknown, current: string | undefined): { value: string; name: string }[] {
333
+ const options = new Map<string, string>();
334
+ for (const item of pageItems(query)) {
335
+ const model = object(item);
336
+ if (!model || typeof model.provider !== "string" || typeof model.id !== "string") continue;
337
+ const value = `${model.provider}/${model.id}`;
338
+ options.set(value, typeof model.name === "string" ? model.name : value);
300
339
  }
301
- const { promise, resolve } = Promise.withResolvers<CreateElicitationResponse | undefined>();
302
- let settled = false;
303
- let timeoutId: NodeJS.Timeout | undefined;
304
- const finish = (value: CreateElicitationResponse | undefined) => {
305
- if (settled) return;
306
- settled = true;
307
- if (timeoutId !== undefined) clearTimeout(timeoutId);
308
- signal?.removeEventListener("abort", onAbort);
309
- resolve(value);
340
+ if (current && !options.has(current)) options.set(current, current);
341
+ return [...options].map(([value, name]) => ({ value, name }));
342
+ }
343
+
344
+ const THINKING_CONFIG_OPTIONS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"].map(value => ({
345
+ value,
346
+ name: value,
347
+ }));
348
+
349
+ /** Maps live canonical SDK config and model queries into the ACP 1.2.1 session state surface. */
350
+ export function acpSessionStateFromConfig(query: unknown, modelsQuery?: unknown) {
351
+ const values = configValues(query);
352
+ const currentModeId = values.get(MODE_CONFIG_ID) === ACP_PLAN_MODE_ID ? ACP_PLAN_MODE_ID : ACP_DEFAULT_MODE_ID;
353
+ return {
354
+ configOptions: [
355
+ {
356
+ id: MODE_CONFIG_ID,
357
+ name: "Mode",
358
+ type: "select" as const,
359
+ currentValue: currentModeId,
360
+ options: [
361
+ { value: ACP_DEFAULT_MODE_ID, name: "Default" },
362
+ { value: ACP_PLAN_MODE_ID, name: "Plan" },
363
+ ],
364
+ },
365
+ ...ACP_CONFIG_OPTIONS.flatMap(option => {
366
+ const value = values.get(option.id);
367
+ if (value === undefined) return [];
368
+ const options =
369
+ option.id === MODEL_CONFIG_ID
370
+ ? modelConfigOptions(modelsQuery, value)
371
+ : option.id === THINKING_CONFIG_ID
372
+ ? THINKING_CONFIG_OPTIONS
373
+ : [...option.options];
374
+ return [{ ...option, type: "select" as const, currentValue: value, options }];
375
+ }),
376
+ ],
377
+ modes: {
378
+ availableModes: [
379
+ { id: ACP_DEFAULT_MODE_ID, name: "Default" },
380
+ { id: ACP_PLAN_MODE_ID, name: "Plan" },
381
+ ],
382
+ currentModeId,
383
+ },
310
384
  };
311
- const onAbort = () => finish(undefined);
312
- signal?.addEventListener("abort", onAbort, { once: true });
313
- if (dialogOptions?.timeout !== undefined) {
314
- timeoutId = setTimeout(() => {
315
- if (settled) return;
316
- try {
317
- dialogOptions.onTimeout?.();
318
- } catch (error) {
319
- // A throwing `onTimeout` must not leave the elicitation promise
320
- // pending — settle it via `finish` below regardless.
321
- logger.warn("ACP elicitation onTimeout threw", { sessionId, method, error });
385
+ }
386
+
387
+ /** Convert every ACP prompt block the agent advertises without silently discarding context. */
388
+ export function acpPromptPayload(blocks: PromptRequest["prompt"]): {
389
+ text: string;
390
+ images: Array<{ data: string; mimeType: string }>;
391
+ } {
392
+ const text: string[] = [];
393
+ const images: Array<{ data: string; mimeType: string }> = [];
394
+ for (const block of blocks) {
395
+ switch (block.type) {
396
+ case "text":
397
+ text.push(block.text);
398
+ break;
399
+ case "image":
400
+ if (block.uri) text.push(`[Image URI: ${block.uri}]`);
401
+ images.push({ data: block.data, mimeType: block.mimeType });
402
+ break;
403
+ case "resource_link":
404
+ text.push(
405
+ [
406
+ `[Resource: ${block.name}]`,
407
+ `URI: ${block.uri}`,
408
+ ...(block.title ? [`Title: ${block.title}`] : []),
409
+ ...(block.description ? [block.description] : []),
410
+ ...(block.mimeType ? [`MIME: ${block.mimeType}`] : []),
411
+ ...(typeof block.size === "number" ? [`Size: ${block.size}`] : []),
412
+ ].join("\n"),
413
+ );
414
+ break;
415
+ case "resource": {
416
+ const resource = block.resource;
417
+ if ("text" in resource) {
418
+ text.push(
419
+ [
420
+ `[Resource: ${resource.uri}]`,
421
+ ...(resource.mimeType ? [`MIME: ${resource.mimeType}`] : []),
422
+ resource.text,
423
+ ].join("\n"),
424
+ );
425
+ break;
426
+ }
427
+ const mimeType = resource.mimeType ?? "application/octet-stream";
428
+ if (!mimeType.startsWith("image/"))
429
+ throw new AcpSdkAdapterError(
430
+ "unsupported_content",
431
+ `Unsupported embedded resource MIME type: ${mimeType}`,
432
+ );
433
+ text.push(`[Resource: ${resource.uri}]\nMIME: ${mimeType}`);
434
+ images.push({ data: resource.blob, mimeType });
435
+ break;
322
436
  }
323
- finish(undefined);
324
- }, dialogOptions.timeout);
325
- // A long pending timeout alone shouldn't keep the event loop alive when
326
- // the rest of the agent has shut down — matches `job-manager.ts` /
327
- // `executor.ts` timer hygiene. Connection + session lifetimes keep the
328
- // loop alive on the happy path.
329
- timeoutId.unref();
437
+ case "audio":
438
+ throw new AcpSdkAdapterError("unsupported_content", "ACP audio prompts are not supported.");
439
+ default:
440
+ throw new AcpSdkAdapterError("unsupported_content", "Unsupported ACP prompt content.");
441
+ }
330
442
  }
331
- connection
332
- .unstable_createElicitation({
333
- mode: "form",
334
- sessionId,
443
+ if (text.length === 0 && images.length === 0)
444
+ throw new AcpSdkAdapterError("invalid_input", "ACP prompt must contain at least one supported content block.");
445
+ return { text: text.join("\n"), images };
446
+ }
447
+
448
+ /** Registers a permission provider only when the ACP client requires prompts. */
449
+ export function acpProviderRegistrations(
450
+ capabilities: ClientCapabilities | undefined,
451
+ env: NodeJS.ProcessEnv = process.env,
452
+ ): AcpProviderRegistration[] {
453
+ return [
454
+ ...(capabilities?.fs?.readTextFile || capabilities?.fs?.writeTextFile
455
+ ? [{ capability: "fs", definitions: [] }]
456
+ : []),
457
+ ...(capabilities?.terminal ? [{ capability: "terminal", definitions: [] }] : []),
458
+ ...(resolveAcpPermissionMode(capabilities, env) === "prompt"
459
+ ? [{ capability: "permission", definitions: [] }]
460
+ : []),
461
+ { capability: "ui", definitions: [] },
462
+ ];
463
+ }
464
+
465
+ /** Maps ACP permission handling to the session's canonical SDK policy. */
466
+ export async function applyAcpPermissionMode(
467
+ adapter: Pick<AcpSdkAdapter, "control">,
468
+ capabilities: ClientCapabilities | undefined,
469
+ env: NodeJS.ProcessEnv = process.env,
470
+ ): Promise<void> {
471
+ const mode = resolveAcpPermissionMode(capabilities, env);
472
+ await adapter.control("permission_mode.set", { mode: mode === "prompt" ? "prompt" : "allow" });
473
+ }
474
+
475
+ /** Applies CLI-provided ACP startup settings through SDK controls before session exposure. */
476
+ export async function applyAcpStartupOptions(
477
+ adapter: Pick<AcpSdkAdapter, "setModel" | "control">,
478
+ options: AcpStartupOptions | undefined,
479
+ ): Promise<void> {
480
+ if (options?.modelId) await adapter.setModel(options.modelId);
481
+ if (options?.thinkingLevel) await adapter.control("thinking.set", { level: options.thinkingLevel });
482
+ }
483
+
484
+ /** ACP form elicitation uses the client-facing reverse surface without owning a session runtime. */
485
+ export function createAcpExtensionUiContext(
486
+ connection: AgentSideConnection,
487
+ getSessionId: () => string,
488
+ capabilities: ClientCapabilities | undefined,
489
+ ): {
490
+ select: (
491
+ message: string,
492
+ options: string[],
493
+ dialog?: { signal?: AbortSignal; timeout?: number; onTimeout?: () => void },
494
+ ) => Promise<string | undefined>;
495
+ confirm: (
496
+ message: string,
497
+ detail?: string,
498
+ dialog?: { signal?: AbortSignal; timeout?: number; onTimeout?: () => void },
499
+ ) => Promise<boolean>;
500
+ input: (
501
+ message: string,
502
+ placeholder?: string,
503
+ dialog?: { signal?: AbortSignal; timeout?: number; onTimeout?: () => void },
504
+ ) => Promise<string | undefined>;
505
+ } {
506
+ const elicit = async (
507
+ kind: "select" | "confirm" | "input",
508
+ message: string,
509
+ options: string[] | undefined,
510
+ dialog: { signal?: AbortSignal; timeout?: number; onTimeout?: () => void } | undefined,
511
+ ): Promise<unknown> => {
512
+ if (!capabilities?.elicitation?.form || dialog?.signal?.aborted) return undefined;
513
+ const request = (
514
+ connection as unknown as { unstable_createElicitation(input: JsonObject): Promise<JsonObject> }
515
+ ).unstable_createElicitation({
516
+ sessionId: getSessionId(),
335
517
  message,
336
518
  requestedSchema: {
337
519
  type: "object",
338
- properties: { value: property },
520
+ properties: {
521
+ value:
522
+ kind === "confirm" ? { type: "boolean" } : { type: "string", ...(options ? { enum: options } : {}) },
523
+ },
339
524
  required: ["value"],
340
525
  },
341
- })
342
- .then(finish, error => {
343
- // Caller may already have moved on via abort/timeout; suppress noise.
344
- if (settled) return;
345
- logger.warn("ACP elicitation failed", { sessionId, method, error });
346
- finish(undefined);
347
526
  });
348
- const response = await promise;
349
- if (
350
- response?.action !== "accept" ||
351
- typeof response.content !== "object" ||
352
- response.content === null ||
353
- !("value" in response.content)
354
- ) {
355
- return undefined;
356
- }
357
- const value = response.content.value;
358
- if (
359
- typeof value === "string" ||
360
- typeof value === "number" ||
361
- typeof value === "boolean" ||
362
- (Array.isArray(value) && value.every(item => typeof item === "string"))
363
- ) {
364
- return value;
365
- }
366
- return undefined;
367
- }
368
-
369
- /**
370
- * Build an {@link ExtensionUIContext} that translates skill/extension UI
371
- * requests into ACP elicitations against `connection` for the session
372
- * returned by `getSessionId()`. The id is read lazily at each elicitation
373
- * because `AgentSession.sessionId` is a getter over `sessionManager` state
374
- * that mutates when an extension command calls `ctx.newSession` /
375
- * `ctx.switchSession` — snapshotting it once at factory time would route
376
- * later elicitations to the pre-switch id. Live reads keep the bridge
377
- * symmetric with every other `sessionUpdate` call in this file
378
- * (`record.session.sessionId` is always evaluated at emit time).
379
- *
380
- * The non-elicitation surface (custom components, editor, theming,
381
- * terminal input) remains stubbed — ACP clients render those themselves
382
- * or not at all. Capability gating respects the client's `initialize`
383
- * advertisement.
384
- */
385
- export function createAcpExtensionUiContext(
386
- connection: AgentSideConnection,
387
- getSessionId: () => string,
388
- clientCapabilities: ClientCapabilities | undefined,
389
- ): ExtensionUIContext {
390
- const supportsForm = clientCapabilities?.elicitation?.form != null;
527
+ let timer: NodeJS.Timeout | undefined;
528
+ const timeout =
529
+ dialog?.timeout === undefined
530
+ ? undefined
531
+ : new Promise<undefined>(resolve => {
532
+ timer = setTimeout(() => {
533
+ dialog.onTimeout?.();
534
+ resolve(undefined);
535
+ }, dialog.timeout);
536
+ });
537
+ try {
538
+ const response = timeout ? await Promise.race([request, timeout]) : await request;
539
+ return object(object(response)?.content)?.value;
540
+ } catch {
541
+ return undefined;
542
+ } finally {
543
+ if (timer) clearTimeout(timer);
544
+ }
545
+ };
391
546
  return {
392
- select: async (title, options, dialogOptions) => {
393
- if (!supportsForm) return undefined;
394
- const value = await elicitFromAcpClient(
395
- connection,
396
- getSessionId(),
397
- "select",
398
- title,
399
- { type: "string", enum: options },
400
- dialogOptions,
401
- );
402
- return typeof value === "string" ? value : undefined;
403
- },
404
- confirm: async (title, message, dialogOptions) => {
405
- if (!supportsForm) return false;
406
- const value = await elicitFromAcpClient(
407
- connection,
408
- getSessionId(),
409
- "confirm",
410
- message.trim().length > 0 ? `${title}\n\n${message}` : title,
411
- { type: "boolean" },
412
- dialogOptions,
413
- );
414
- return typeof value === "boolean" ? value : false;
547
+ select: async (message, options, dialog) => {
548
+ const value = await elicit("select", message, options, dialog);
549
+ return typeof value === "string" && options.includes(value) ? value : undefined;
415
550
  },
416
- input: async (title, placeholder, dialogOptions) => {
417
- if (!supportsForm) return undefined;
418
- const value = await elicitFromAcpClient(
419
- connection,
420
- getSessionId(),
421
- "input",
422
- title,
423
- // ACP's `StringPropertySchema` has no `placeholder` field, so we
424
- // surface the placeholder text as `description` — the closest
425
- // semantic field a client can render alongside the input.
426
- // Empty / whitespace-only placeholders are treated as absent.
427
- { type: "string", ...(placeholder?.trim() ? { description: placeholder } : {}) },
428
- dialogOptions,
429
- );
551
+ confirm: async (message, detail, dialog) =>
552
+ (await elicit("confirm", detail ? `${message}\n\n${detail}` : message, undefined, dialog)) === true,
553
+ input: async (message, placeholder, dialog) => {
554
+ const value = await elicit("input", placeholder ? `${message}\n\n${placeholder}` : message, undefined, dialog);
430
555
  return typeof value === "string" ? value : undefined;
431
556
  },
432
- notify: (message, type) => {
433
- logger.debug("ACP extension notification", { message, type });
434
- },
435
- onTerminalInput: () => () => {},
436
- setStatus: () => {},
437
- setWorkingMessage: () => {},
438
- setWidget: () => {},
439
- setFooter: () => {},
440
- setHeader: () => {},
441
- setTitle: () => {},
442
- custom: async () => undefined as never,
443
- pasteToEditor: () => {},
444
- setEditorText: () => {},
445
- getEditorText: () => "",
446
- editor: async () => undefined,
447
- setEditorComponent: () => {},
448
- get theme() {
449
- return theme;
450
- },
451
- getAllThemes: async () => [],
452
- getTheme: async () => undefined,
453
- setTheme: async () => ({ success: false, error: "Theme changes are unavailable in ACP mode" }),
454
- getToolsExpanded: () => false,
455
- setToolsExpanded: () => {},
456
557
  };
457
558
  }
458
559
 
560
+ /**
561
+ * ACP is a pure SDK client. Session processes are created and resumed by the
562
+ * broker, while all per-session operations use that session's authenticated SDK
563
+ * endpoint. This class deliberately imports neither AgentSession nor any local
564
+ * runtime host component.
565
+ */
459
566
  export class AcpAgent implements Agent {
460
- #connection: AgentSideConnection;
461
- #initialSession: AgentSession | undefined;
462
- #createSession: CreateAcpSession;
463
- #sessions = new Map<string, ManagedSessionRecord>();
464
- #disposePromise: Promise<void> | undefined;
465
- #cleanupRegistered = false;
567
+ readonly #connection: AgentSideConnection;
568
+ readonly #agentDir: string;
569
+ readonly #sessions = new Map<string, SessionRecord>();
570
+ readonly #attaching = new Map<string, PendingAttachment>();
571
+ readonly #resolvingExisting = new Map<string, PendingAttachment>();
572
+ readonly #knownSessionCwds = new Map<string, string>();
573
+ readonly #sessionEpochs = new Map<string, number>();
574
+ readonly #tearingDown = new Map<string, number>();
466
575
  #clientCapabilities: ClientCapabilities | undefined;
467
- #cancelCleanupTimeoutMs = ACP_CANCEL_CLEANUP_TIMEOUT_MS;
468
- /** Immutable canonical cwd locked by the first successful explicit-cwd lifecycle/list call. */
469
- #canonicalCwdScope: string | undefined;
470
- /** Authority snapshot from the last complete strict scoped inventory. Invalidation bumps this. */
471
- #authorityGeneration = 0;
472
- #authoritySnapshot: AcpAuthoritySnapshot | undefined;
473
- /**
474
- * Same-connection retry evidence for an incomplete verified delete. Keyed by
475
- * session id. Phase-aware: every cleanup_pending carries the transcript
476
- * identity at failure time, which is re-verified against the fresh candidate
477
- * on retry so a replaced transcript cannot authorize a replacement; the
478
- * artifacts-phase result additionally carries the artifact directory
479
- * identity to re-accept. Cleared once a delete reaches `kind: "deleted"`.
480
- */
481
- #pendingDeleteEvidence = new Map<string, PendingDeleteEvidence>();
482
- #shutdownPromise: Promise<void> | undefined;
483
- #lifecycleOperations = new Set<Promise<unknown>>();
484
- #shuttingDown = false;
576
+ #broker: Promise<BrokerConnection> | undefined;
577
+ readonly #startupOptions: AcpStartupOptions | undefined;
578
+ #disposed = false;
579
+ #disposePromise: Promise<void> | undefined;
485
580
 
486
- constructor(connection: AgentSideConnection, createSession: CreateAcpSession, initialSession?: AgentSession) {
581
+ constructor(
582
+ connection: AgentSideConnection,
583
+ options?: { agentDir?: string; startupOptions?: AcpStartupOptions } | unknown,
584
+ ) {
487
585
  this.#connection = connection;
488
- this.#initialSession = initialSession;
489
- this.#createSession = createSession;
490
- }
491
-
492
- setCancelCleanupTimeoutForTesting(timeoutMs: number): void {
493
- this.#cancelCleanupTimeoutMs = Math.max(1, timeoutMs);
586
+ const candidate = object(options);
587
+ this.#agentDir = typeof candidate?.agentDir === "string" ? candidate.agentDir : getAgentDir();
588
+ this.#startupOptions = parseAcpStartupOptions(candidate?.startupOptions);
589
+ queueMicrotask(() => {
590
+ if (connection.signal.aborted) {
591
+ this.#beginDispose();
592
+ } else {
593
+ connection.signal.addEventListener("abort", () => this.#beginDispose(), { once: true });
594
+ }
595
+ });
494
596
  }
495
597
 
496
598
  async initialize(params: InitializeRequest): Promise<InitializeResponse> {
497
- this.#registerConnectionCleanup();
498
599
  this.#clientCapabilities = params.clientCapabilities;
499
600
  const authMethods: AuthMethod[] = [
500
601
  {
@@ -514,2377 +615,892 @@ export class AcpAgent implements Agent {
514
615
  }
515
616
  return {
516
617
  protocolVersion: PROTOCOL_VERSION,
517
- agentInfo: {
518
- name: "gajae-code",
519
- title: "Gajae Code",
520
- version: CODING_AGENT_VERSION,
521
- },
618
+ agentInfo: { name: "gajae-code", title: "Gajae Code", version: packageJson.version },
522
619
  authMethods,
523
620
  agentCapabilities: {
524
621
  loadSession: true,
525
- mcpCapabilities: {
526
- http: true,
527
- sse: true,
528
- },
529
- promptCapabilities: {
530
- embeddedContext: true,
531
- image: true,
532
- },
533
- sessionCapabilities: {
534
- list: {},
535
- fork: {},
536
- resume: {},
537
- close: {},
538
- delete: {},
539
- },
622
+ promptCapabilities: { embeddedContext: true, image: true },
623
+ sessionCapabilities: { list: {}, fork: {}, resume: {}, close: {}, delete: {} },
540
624
  },
541
625
  };
542
626
  }
543
627
 
544
628
  async authenticate(params: AuthenticateRequest): Promise<AuthenticateResponse> {
545
- // ACP spec: `methodId` must be one of the methods advertised by `initialize`.
546
- // Reject anything else so malformed clients fail fast rather than appearing
547
- // authenticated and surfacing a downstream model failure later.
548
- const supportsTerminalAuth = this.#clientCapabilities?.auth?.terminal === true;
549
- const validMethods = supportsTerminalAuth ? ["agent", "terminal"] : ["agent"];
550
- if (!validMethods.includes(params.methodId)) {
551
- throw new Error(`Unknown ACP auth method: ${params.methodId}`);
552
- }
629
+ const methods = this.#clientCapabilities?.auth?.terminal ? ["agent", "terminal"] : ["agent"];
630
+ if (!methods.includes(params.methodId)) throw new Error(`Unknown ACP auth method: ${params.methodId}`);
553
631
  return {};
554
632
  }
555
633
 
556
634
  async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
557
- this.#rejectIfShuttingDown();
558
- // Stage scope before the lifecycle op; commit only after it succeeds so a
559
- // failed first call does not pin the connection to a cwd it never served.
560
- const canonical = this.#stageCanonicalScope(params.cwd);
561
- const record = await this.#trackLifecycle(() => this.#createNewSessionRecord(params.cwd, params.mcpServers));
562
- this.#rejectIfShuttingDown();
563
- this.#commitCanonicalScope(canonical);
564
- this.#invalidateAuthority();
565
- const response: NewSessionResponse = {
566
- sessionId: record.session.sessionId,
567
- configOptions: this.#buildConfigOptions(record.session),
568
- modes: this.#buildModeState(record.session),
569
- };
570
- this.#scheduleBootstrapUpdates(record.session.sessionId);
571
- return response;
572
- }
573
-
574
- async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
575
- this.#rejectIfShuttingDown();
576
- const canonical = this.#stageCanonicalScope(params.cwd);
577
- const existingRecord = this.#sessions.get(params.sessionId);
578
- const record = await this.#trackLifecycle(() =>
579
- this.#loadManagedSession(params.sessionId, params.cwd, params.mcpServers),
635
+ this.#assertNoMcpServers(params);
636
+ this.#assertAbsoluteCwd(params.cwd);
637
+ const result = await (await this.#brokerAdapter()).global(
638
+ "session.create",
639
+ {
640
+ cwd: params.cwd,
641
+ target: { path: params.cwd },
642
+ ...(this.#startupOptions?.modelPreset ? { modelPreset: this.#startupOptions.modelPreset } : {}),
643
+ },
644
+ randomUUID(),
580
645
  );
646
+ const id = sessionId(result);
647
+ this.#knownSessionCwds.set(id, params.cwd);
581
648
  try {
582
- this.#rejectIfShuttingDown();
583
- await this.#replaySessionHistory(record);
584
- this.#rejectIfShuttingDown();
585
- // Construct every fallible response/bootstrap value inside the
586
- // transaction so a build/schedule failure rolls back the prepared
587
- // record and never pins the connection.
588
- const response: LoadSessionResponse = {
589
- configOptions: this.#buildConfigOptions(record.session),
590
- modes: this.#buildModeState(record.session),
591
- };
592
- this.#scheduleBootstrapUpdates(record.session.sessionId);
593
- // Commit the cwd scope only after replay, response construction, and
594
- // bootstrap scheduling all succeed — a failed first load leaves no
595
- // half-loaded session and does not pin the connection.
596
- this.#commitCanonicalScope(canonical);
597
- this.#invalidateAuthority();
598
- return response;
649
+ await this.#attach(id, params.cwd, endpoint(result));
650
+ await applyAcpStartupOptions(this.#adapter(id), this.#startupOptions);
651
+ this.#scheduleBootstrap(id);
652
+ return { sessionId: id, ...(await this.#sessionState(id)) };
599
653
  } catch (error) {
600
- // Any pre-return failure: roll back the prepared record so a failed
601
- // first load leaves no half-loaded session and does not pin the
602
- // connection, and commit no cwd scope.
603
- if (record !== existingRecord) {
604
- this.#sessions.delete(record.session.sessionId);
605
- await this.#disposeSessionRecord(record);
606
- }
654
+ await this.#discardNewSession(id);
607
655
  throw error;
608
656
  }
609
657
  }
610
658
 
611
- async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
612
- this.#rejectIfShuttingDown();
613
- const scope = params.cwd ? this.#stageCanonicalScope(params.cwd) : undefined;
614
- for (const record of this.#sessions.values()) {
615
- await record.session.sessionManager.flush();
616
- }
617
- this.#rejectIfShuttingDown();
618
- // Cwd-less / global listing stays display-only and non-authorizing.
619
- if (!scope) {
620
- const sessions = await this.#listStoredSessions(undefined);
621
- const offset = this.#parseCursor(params.cursor ?? undefined);
622
- const paged = sessions.slice(offset, offset + SESSION_PAGE_SIZE);
623
- const nextOffset = offset + paged.length;
624
- return {
625
- sessions: paged.map(session => this.#toSessionInfo(session)),
626
- nextCursor: nextOffset < sessions.length ? String(nextOffset) : undefined,
627
- };
628
- }
629
- // Explicit scoped list: stage scope, strict raw inventory, build the full
630
- // authorization/conflict snapshot BEFORE page slicing so duplicate ids
631
- // beyond page 1 are known before the first page response. Scope commits
632
- // only after cursor validation, stored-session read, and full response
633
- // construction succeed — a failed first list never pins the connection.
634
- if (params.cursor === undefined) {
635
- // A new first-page request is a new issuance event. Refresh the complete
636
- // snapshot and invalidate cursors minted by the previous issuance.
637
- this.#invalidateAuthority();
638
- }
639
- const snapshot = this.#buildAuthoritySnapshot(scope);
640
- // Scoped cursors carry the authority generation at which they were minted
641
- // (form `${generation}:${offset}`). A lifecycle change between page 1 and
642
- // page 2 bumps the generation, so the old cursor is rejected instead of
643
- // silently rebuilding at the new generation. The cwd-less display cursor
644
- // stays a plain offset (see #parseCursor).
645
- const offset = this.#parseScopedCursor(params.cursor ?? undefined, snapshot.generation);
646
- const stored = await this.#listStoredSessions(scope);
647
- const storedById = new Map(stored.map(s => [s.id, s] as const));
648
- const pageIds = snapshot.orderedIds.slice(offset, offset + SESSION_PAGE_SIZE);
649
- const nextOffset = offset + pageIds.length;
650
- const pageSessions = pageIds.map(id => storedById.get(id)).filter((s): s is StoredSessionInfo => s !== undefined);
651
- const response: ListSessionsResponse = {
652
- sessions: pageSessions.map(session => this.#toSessionInfo(session)),
653
- nextCursor: nextOffset < snapshot.orderedIds.length ? `${snapshot.generation}:${nextOffset}` : undefined,
654
- };
655
- // Commit the scope only after cursor validation, stored-session read, and
656
- // full response construction succeed.
657
- this.#commitCanonicalScope(scope);
658
- return response;
659
+ async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
660
+ this.#assertNoMcpServers(params);
661
+ this.#assertAbsoluteCwd(params.cwd);
662
+ await this.#attachExisting(params.sessionId, params.cwd);
663
+ await this.#replaySession(params.sessionId);
664
+ this.#scheduleBootstrap(params.sessionId);
665
+ return await this.#sessionState(params.sessionId);
659
666
  }
660
667
 
661
668
  async resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
662
- this.#rejectIfShuttingDown();
663
- const canonical = this.#stageCanonicalScope(params.cwd);
664
- const record = await this.#trackLifecycle(() =>
665
- this.#resumeManagedSession(params.sessionId, params.cwd, params.mcpServers ?? []),
666
- );
667
- this.#rejectIfShuttingDown();
668
- this.#commitCanonicalScope(canonical);
669
- this.#invalidateAuthority();
670
- const response: ResumeSessionResponse = {
671
- configOptions: this.#buildConfigOptions(record.session),
672
- modes: this.#buildModeState(record.session),
673
- };
674
- this.#scheduleBootstrapUpdates(record.session.sessionId);
675
- return response;
669
+ this.#assertNoMcpServers(params);
670
+ this.#assertAbsoluteCwd(params.cwd);
671
+ await this.#attachExisting(params.sessionId, params.cwd);
672
+ this.#scheduleBootstrap(params.sessionId);
673
+ return await this.#sessionState(params.sessionId);
676
674
  }
677
675
 
678
676
  async unstable_forkSession(params: ForkSessionRequest): Promise<ForkSessionResponse> {
679
- this.#rejectIfShuttingDown();
680
- const canonical = this.#stageCanonicalScope(params.cwd);
681
- const record = await this.#trackLifecycle(() => this.#forkManagedSession(params));
682
- this.#rejectIfShuttingDown();
683
- this.#commitCanonicalScope(canonical);
684
- this.#invalidateAuthority();
685
- const response: ForkSessionResponse = {
686
- sessionId: record.session.sessionId,
687
- configOptions: this.#buildConfigOptions(record.session),
688
- modes: this.#buildModeState(record.session),
689
- };
690
- this.#scheduleBootstrapUpdates(record.session.sessionId);
691
- return response;
677
+ this.#assertNoMcpServers(params);
678
+ this.#assertAbsoluteCwd(params.cwd);
679
+ const source = await this.#resolveSavedSession(params.sessionId, params.cwd);
680
+ const result = await (await this.#brokerAdapter()).global(
681
+ "session.fork",
682
+ {
683
+ cwd: params.cwd,
684
+ sourceSessionId: params.sessionId,
685
+ sourceSessionPath: source,
686
+ target: { path: params.cwd },
687
+ },
688
+ randomUUID(),
689
+ );
690
+ const id = sessionId(result);
691
+ this.#knownSessionCwds.set(id, params.cwd);
692
+ try {
693
+ await this.#attach(id, params.cwd, endpoint(result));
694
+ this.#scheduleBootstrap(id);
695
+ return { sessionId: id, ...(await this.#sessionState(id)) };
696
+ } catch (error) {
697
+ await this.#discardNewSession(id);
698
+ throw error;
699
+ }
700
+ }
701
+
702
+ async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
703
+ if (params.cwd) this.#assertAbsoluteCwd(params.cwd);
704
+ const result = object(await (await this.#brokerAdapter()).global("session.list"));
705
+ const listing = object(result?.result) ?? result;
706
+ const listed = Array.isArray(listing?.sessions) ? listing.sessions : [];
707
+ if (params.cwd) {
708
+ const discovered = new Set<string>();
709
+ for (const session of listed) {
710
+ const candidate = object(session) as BrokerSession | undefined;
711
+ if (
712
+ typeof candidate?.sessionId !== "string" ||
713
+ typeof candidate.locator?.repo !== "string" ||
714
+ path.resolve(candidate.locator.repo) !== path.resolve(params.cwd)
715
+ )
716
+ continue;
717
+ if (discovered.has(candidate.sessionId))
718
+ throw new AcpSdkAdapterError("conflict", `Broker returned duplicate session id: ${candidate.sessionId}`);
719
+ discovered.add(candidate.sessionId);
720
+ const knownCwd = this.#knownSessionCwds.get(candidate.sessionId);
721
+ if (knownCwd && path.resolve(knownCwd) !== path.resolve(params.cwd))
722
+ throw new AcpSdkAdapterError(
723
+ "conflict",
724
+ `ACP session ${candidate.sessionId} has conflicting cwd authority.`,
725
+ );
726
+ this.#knownSessionCwds.set(candidate.sessionId, params.cwd);
727
+ }
728
+ }
729
+ return paginateAcpSessions(listed, params.cwd ?? undefined, this.#cursor(params.cursor));
692
730
  }
693
731
 
694
732
  async closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
695
733
  const record = this.#sessions.get(params.sessionId);
696
- if (!record) {
734
+ const cwd = record?.cwd ?? this.#knownSessionCwds.get(params.sessionId);
735
+ // ACP close has no cwd. Only connection-owned sessions may reach broker lifecycle control.
736
+ if (!cwd) return {};
737
+ this.#beginTeardown(params.sessionId);
738
+ try {
739
+ await this.#teardownSession(params.sessionId, "closed", true);
740
+ this.#knownSessionCwds.delete(params.sessionId);
697
741
  return {};
742
+ } finally {
743
+ this.#finishTeardown(params.sessionId);
698
744
  }
699
- this.#rejectIfTerminal(record);
700
- await this.#closeManagedSession(params.sessionId, record);
701
- this.#invalidateAuthority();
702
- return {};
703
745
  }
704
746
 
705
- /**
706
- * Hard delete an ACP session. `DeleteSessionRequest` carries only `sessionId`;
707
- * the server owns authorization through the immutable canonical cwd scope and
708
- * the complete strict scoped inventory snapshot.
709
- *
710
- * - No scope / unknown / already-deleted id → lookup-free `{}`.
711
- * - Duplicate/conflict id → visible error; neither transcript nor artifacts change.
712
- * - Active session → reserve `deleting`, drain/cancel prompt, flush, strict
713
- * dispose/close, recheck identity/state, then verified artifact-first deletion.
714
- * - Concurrent delete callers join the terminal promise.
715
- */
716
747
  async deleteSession(params: DeleteSessionRequest): Promise<DeleteSessionResponse> {
717
- // Before scope: return {} without scanning/listing storage.
718
- if (this.#canonicalCwdScope === undefined) {
719
- return {};
720
- }
721
- if (this.#shuttingDown) {
722
- throw new Error("ACP session delete is unavailable during shutdown");
723
- }
724
748
  const record = this.#sessions.get(params.sessionId);
725
- if (record) {
726
- return await this.#deleteActiveSession(params.sessionId, record);
749
+ const cwd = record?.cwd ?? this.#knownSessionCwds.get(params.sessionId);
750
+ // ACP's delete request has no cwd. Only delete sessions this connection has
751
+ // already scoped through the broker; unknown ids remain the protocol no-op.
752
+ if (!cwd) return {};
753
+ this.#beginTeardown(params.sessionId);
754
+ try {
755
+ await this.#teardownSession(params.sessionId, "deleted", true);
756
+ let saved: string;
757
+ try {
758
+ saved = await this.#resolveSavedSession(params.sessionId, cwd);
759
+ } catch (error) {
760
+ if (error instanceof AcpSdkAdapterError && error.code === "not_found") {
761
+ this.#knownSessionCwds.delete(params.sessionId);
762
+ return {};
763
+ }
764
+ throw error;
765
+ }
766
+ await (await this.#brokerAdapter()).global(
767
+ "session.delete",
768
+ { sessionId: params.sessionId, sessionPath: saved, cwd, target: { path: cwd } },
769
+ this.#lifecycleIdempotencyKey(params.sessionId, "session.delete"),
770
+ );
771
+ this.#knownSessionCwds.delete(params.sessionId);
772
+ return {};
773
+ } finally {
774
+ this.#finishTeardown(params.sessionId);
727
775
  }
728
- // Inactive: read authority from the strict inventory snapshot.
729
- return await this.#deleteInactiveSession(params.sessionId);
730
776
  }
731
777
 
732
778
  async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
733
- const record = this.#getSessionRecord(params.sessionId);
734
- this.#rejectIfTerminal(record);
735
- this.#applyModeChange(record.session, params.modeId);
736
- await this.#connection.sessionUpdate({
737
- sessionId: record.session.sessionId,
738
- update: this.#buildCurrentModeUpdate(record.session),
779
+ if (params.modeId !== ACP_DEFAULT_MODE_ID && params.modeId !== ACP_PLAN_MODE_ID)
780
+ throw new Error(`Unsupported ACP mode: ${params.modeId}`);
781
+ await this.#adapter(params.sessionId).control("mode.plan.set", { on: params.modeId === ACP_PLAN_MODE_ID });
782
+ await this.#publishSessionUpdate(params.sessionId, {
783
+ sessionId: params.sessionId,
784
+ update: { sessionUpdate: "current_mode_update", currentModeId: params.modeId },
739
785
  });
740
- await this.#pushConfigOptionUpdate(record);
741
786
  return {};
742
787
  }
743
788
 
744
789
  async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
745
- const record = this.#getSessionRecord(params.sessionId);
746
- this.#rejectIfTerminal(record);
747
- if (typeof params.value === "boolean") {
790
+ if (typeof params.value !== "string")
748
791
  throw new Error(`Unsupported boolean ACP config option: ${params.configId}`);
749
- }
750
-
751
792
  switch (params.configId) {
752
793
  case MODE_CONFIG_ID:
753
- this.#applyModeChange(record.session, params.value);
794
+ await this.setSessionMode({ sessionId: params.sessionId, modeId: params.value });
754
795
  break;
755
796
  case MODEL_CONFIG_ID:
756
- await this.#setModelById(record.session, params.value);
797
+ await this.#adapter(params.sessionId).setModel(params.value);
757
798
  break;
758
799
  case THINKING_CONFIG_ID:
759
- this.#setThinkingLevelById(record.session, params.value);
800
+ await this.#adapter(params.sessionId).control("thinking.set", { level: params.value });
760
801
  break;
761
- default:
762
- throw new Error(`Unknown ACP config option: ${params.configId}`);
802
+ default: {
803
+ const operation = ACP_CONFIG_CONTROL_OPERATIONS[params.configId];
804
+ if (!operation) throw new Error(`Unknown ACP config option: ${params.configId}`);
805
+ await this.#adapter(params.sessionId).control(operation, { mode: params.value });
806
+ }
763
807
  }
808
+ const state = await this.#sessionState(params.sessionId);
809
+ await this.#publishSessionUpdate(params.sessionId, {
810
+ sessionId: params.sessionId,
811
+ update: { sessionUpdate: "config_option_update", configOptions: state.configOptions ?? [] },
812
+ });
813
+ return { configOptions: state.configOptions ?? [] };
814
+ }
764
815
 
765
- // When mode is changed via the generic config-option API, mirror the
766
- // `current_mode_update` notification that `setSessionMode` emits so
767
- // ACP clients tracking session-mode state see a consistent transition.
768
- if (params.configId === MODE_CONFIG_ID) {
769
- await this.#connection.sessionUpdate({
770
- sessionId: record.session.sessionId,
771
- update: this.#buildCurrentModeUpdate(record.session),
816
+ async prompt(params: PromptRequest): Promise<PromptResponse> {
817
+ const record = this.#sessions.get(params.sessionId);
818
+ if (!record) throw new AcpSdkAdapterError("not_found", `Unsupported ACP session: ${params.sessionId}`);
819
+ if (record.activePrompt) throw new AcpSdkAdapterError("conflict", "ACP session already has an active prompt.");
820
+ const payload = acpPromptPayload(params.prompt);
821
+ let waiter!: PromptWaiter;
822
+ const response = new Promise<PromptResponse>((resolve, reject) => {
823
+ waiter = {
824
+ cancelRequested: false,
825
+ acknowledged: false,
826
+ activityObserved: false,
827
+ steeringAtAcknowledgement: record.busy,
828
+ boundary: record.inboundSequence,
829
+ correlation: {},
830
+ resolve,
831
+ reject,
832
+ };
833
+ record.activePrompt = waiter;
834
+ });
835
+ try {
836
+ const acknowledgement = await record.adapter.prompt({
837
+ text: payload.text,
838
+ ...(payload.images.length ? { images: payload.images } : {}),
772
839
  });
840
+ waiter.steeringAtAcknowledgement = record.busy;
841
+ // Capture the ingress boundary after the command acknowledgement. Frames
842
+ // queued before this point are stale with respect to this ACP prompt.
843
+ waiter.boundary = record.inboundSequence;
844
+ waiter.correlation = correlationFrom(acknowledgement);
845
+ waiter.acknowledged = true;
846
+ this.#settlePrompt(record, waiter);
847
+ } catch (error) {
848
+ if (record.activePrompt === waiter) record.activePrompt = undefined;
849
+ throw error;
773
850
  }
851
+ return await response;
852
+ }
774
853
 
775
- // For `thinking` the lifetime subscription pushes post-bootstrap; only
776
- // push here when it's not yet installed so pre-bootstrap callers still
777
- // see the change without a post-bootstrap duplicate.
778
- const thinkingHandledBySubscription =
779
- params.configId === THINKING_CONFIG_ID && record.lifetimeUnsubscribe !== undefined;
780
- if (!thinkingHandledBySubscription) {
781
- await this.#pushConfigOptionUpdate(record);
782
- }
783
- return { configOptions: this.#buildConfigOptions(record.session) };
854
+ async cancel(params: { sessionId: string }): Promise<void> {
855
+ const record = this.#sessions.get(params.sessionId);
856
+ if (!record) throw new AcpSdkAdapterError("not_found", `Unsupported ACP session: ${params.sessionId}`);
857
+ const waiter = record.activePrompt;
858
+ const acknowledgement = await record.adapter.cancel();
859
+ const result = object(object(acknowledgement)?.result) ?? object(acknowledgement);
860
+ if (result?.aborted !== true)
861
+ throw new AcpSdkAdapterError(
862
+ "abort_unacknowledged",
863
+ "SDK did not acknowledge cancellation of the active prompt.",
864
+ );
865
+ // Do not retroactively mark a waiter that already settled while the abort
866
+ // request was in flight. A cancelled response means the abort itself won.
867
+ if (waiter && record.activePrompt === waiter) waiter.cancelRequested = true;
784
868
  }
785
869
 
786
- async prompt(params: PromptRequest): Promise<PromptResponse> {
787
- const record = this.#getSessionRecord(params.sessionId);
788
- this.#rejectIfTerminal(record);
789
- const activeTurn = record.promptTurn;
790
- if (activeTurn && !activeTurn.settled && record.session.isStreaming) {
791
- throw new Error("ACP prompt already in progress for this session");
792
- }
793
- return await this.#queuePrompt(record, async () => {
794
- const previousTurn = record.promptTurn;
795
- if (previousTurn) {
796
- // Wait for any prompt that's still settling or whose cancel cleanup is
797
- // still in flight. We deliberately swallow the prompt rejection (the
798
- // owning caller already received it) but let cleanup rejections
799
- // propagate — a timed-out cancel must fail this queued prompt instead
800
- // of letting it run on a session that is about to be closed.
801
- await previousTurn.promise.catch(() => undefined);
802
- await previousTurn.cleanup;
870
+ async extMethod(method: string, params: JsonObject): Promise<JsonObject> {
871
+ try {
872
+ if (method === "_gjc/sdk/global") {
873
+ const result = await (await this.#brokerAdapter()).handle(method, params);
874
+ return object(result) ?? {};
875
+ }
876
+ if (method === "_gjc/sdk/control" || method === "_gjc/sdk/query") {
877
+ const id = typeof params.sessionId === "string" ? params.sessionId : undefined;
878
+ if (!id) throw new AcpSdkAdapterError("invalid_input", "sessionId is required.");
879
+ const result = await this.#adapter(id).handle(method, params);
880
+ return object(result) ?? {};
803
881
  }
882
+ throw new AcpSdkAdapterError("method_not_found", `Unknown ACP ext method: ${method}`);
883
+ } catch (error) {
884
+ const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : "internal";
885
+ const message = error instanceof Error ? error.message : String(error);
886
+ return { ok: false, error: { code, message } };
887
+ }
888
+ }
804
889
 
805
- const converted = this.#convertPromptBlocks(params.prompt);
806
- const pendingPrompt = Promise.withResolvers<PromptResponse>();
807
- const metaUserMessageId = params._meta?.userMessageId;
808
- record.promptTurn = {
809
- userMessageId: typeof metaUserMessageId === "string" ? metaUserMessageId : crypto.randomUUID(),
810
- cancelRequested: false,
811
- settled: false,
812
- cleanup: undefined,
813
- usageBaseline: this.#cloneUsageStatistics(record.session.sessionManager.getUsageStatistics()),
814
- unsubscribe: undefined,
815
- resolve: pendingPrompt.resolve,
816
- reject: pendingPrompt.reject,
817
- promise: pendingPrompt.promise,
818
- };
890
+ async extNotification(_method: string, _params: JsonObject): Promise<void> {}
891
+ get signal(): AbortSignal {
892
+ return this.#connection.signal;
893
+ }
894
+ get closed(): Promise<void> {
895
+ return this.#connection.closed;
896
+ }
819
897
 
820
- this.#ensureLifetimeSubscription(record);
821
- record.promptTurn.unsubscribe = record.session.subscribe(event => {
822
- void this.#handlePromptEvent(record, event);
823
- });
898
+ #sessionEpoch(id: string): number {
899
+ return this.#sessionEpochs.get(id) ?? 0;
900
+ }
824
901
 
825
- this.#runPromptOrCommand(record, converted.text, converted.images).catch((error: unknown) => {
826
- this.#finishPrompt(record, undefined, error);
827
- });
902
+ #advanceSessionEpoch(id: string): void {
903
+ this.#sessionEpochs.set(id, this.#sessionEpoch(id) + 1);
904
+ }
828
905
 
829
- return await pendingPrompt.promise;
830
- });
906
+ #assertSessionEpoch(id: string, epoch: number): void {
907
+ if (this.#disposed || this.#tearingDown.has(id) || this.#sessionEpoch(id) !== epoch)
908
+ throw new AcpSdkAdapterError("connection_closed", `ACP session ${id} was closed while attaching.`);
831
909
  }
832
910
 
833
- async #queuePrompt(record: ManagedSessionRecord, run: () => Promise<PromptResponse>): Promise<PromptResponse> {
834
- const nextQueue = Promise.withResolvers<void>();
835
- const releaseQueue = nextQueue.resolve;
836
- const previousQueue = record.promptQueue;
837
- record.promptQueue = {
838
- promise: nextQueue.promise,
839
- release: releaseQueue,
840
- };
841
- await previousQueue.promise;
911
+ #beginTeardown(id: string): void {
912
+ this.#tearingDown.set(id, (this.#tearingDown.get(id) ?? 0) + 1);
913
+ }
914
+
915
+ #finishTeardown(id: string): void {
916
+ const remaining = (this.#tearingDown.get(id) ?? 1) - 1;
917
+ if (remaining > 0) this.#tearingDown.set(id, remaining);
918
+ else this.#tearingDown.delete(id);
919
+ }
920
+
921
+ #lifecycleIdempotencyKey(id: string, operation: "session.close" | "session.delete"): string {
922
+ return `acp:${operation}:${id}`;
923
+ }
924
+
925
+ #isAlreadyGone(error: unknown): boolean {
926
+ return (
927
+ typeof error === "object" &&
928
+ error !== null &&
929
+ "code" in error &&
930
+ ((error.code === "not_found" || error.code === "resource_gone") as boolean)
931
+ );
932
+ }
933
+
934
+ async #attachExisting(id: string, cwd: string): Promise<void> {
935
+ const epoch = this.#sessionEpoch(id);
936
+ const attached = this.#sessions.get(id);
937
+ if (attached) {
938
+ if (path.resolve(attached.cwd) !== path.resolve(cwd))
939
+ throw new AcpSdkAdapterError("conflict", `ACP session ${id} has conflicting cwd authority.`);
940
+ return;
941
+ }
942
+ const knownCwd = this.#knownSessionCwds.get(id);
943
+ if (knownCwd && path.resolve(knownCwd) !== path.resolve(cwd))
944
+ throw new AcpSdkAdapterError("conflict", `ACP session ${id} has conflicting cwd authority.`);
945
+ const resolving = this.#resolvingExisting.get(id);
946
+ if (resolving?.epoch === epoch) {
947
+ await resolving.task;
948
+ this.#assertSessionEpoch(id, epoch);
949
+ const resolved = this.#sessions.get(id);
950
+ if (!resolved) throw new AcpSdkAdapterError("unavailable", `ACP session ${id} did not attach.`);
951
+ if (path.resolve(resolved.cwd) !== path.resolve(cwd))
952
+ throw new AcpSdkAdapterError("conflict", `ACP session ${id} has conflicting cwd authority.`);
953
+ return;
954
+ }
955
+
956
+ const task = this.#resolveExistingAttachment(id, cwd, epoch);
957
+ const pending = { epoch, task };
958
+ this.#resolvingExisting.set(id, pending);
842
959
  try {
843
- return await run();
960
+ await task;
961
+ this.#assertSessionEpoch(id, epoch);
844
962
  } finally {
845
- releaseQueue();
846
- if (record.promptQueue.release === releaseQueue) {
847
- record.promptQueue.release = undefined;
848
- }
963
+ if (this.#resolvingExisting.get(id) === pending) this.#resolvingExisting.delete(id);
849
964
  }
850
965
  }
851
966
 
852
- async #runPromptOrCommand(record: ManagedSessionRecord, text: string, images: AgentImageContent[]): Promise<void> {
853
- // Namespaced skill commands cannot collide with ACP builtins; handle
854
- // them before builtin dispatch. Bare `/name` aliases are intentionally
855
- // not skill commands.
856
- if (text.startsWith("/skill:") && (await this.#tryRunSkillCommand(record, text))) {
967
+ async #resolveExistingAttachment(id: string, cwd: string, epoch: number): Promise<void> {
968
+ this.#assertSessionEpoch(id, epoch);
969
+ const indexed = await this.#scopedBrokerSession(id, cwd);
970
+ this.#assertSessionEpoch(id, epoch);
971
+ if (indexed?.live) {
972
+ const result = await this.#brokerEndpoint(id, indexed.endpointGeneration);
973
+ this.#assertSessionEpoch(id, epoch);
974
+ await this.#attach(id, cwd, endpoint(result), epoch);
857
975
  return;
858
976
  }
859
977
 
860
- const builtinResult = await executeAcpBuiltinSlashCommand(text, {
861
- session: record.session,
862
- sessionManager: record.session.sessionManager,
863
- settings: record.session.settings,
864
- cwd: record.session.sessionManager.getCwd(),
865
- output: output => this.#emitCommandOutput(record, output),
866
- refreshCommands: () => this.#emitAvailableCommandsUpdate(record),
867
- reloadPlugins: () => this.#reloadPluginState(record),
868
- notifyTitleChanged: async () => {
869
- await this.#connection.sessionUpdate({
870
- sessionId: record.session.sessionId,
871
- update: {
872
- sessionUpdate: "session_info_update",
873
- title: record.session.sessionName,
874
- updatedAt: new Date().toISOString(),
875
- },
876
- });
877
- },
878
- notifyConfigChanged: async () => {
879
- await this.#pushConfigOptionUpdate(record);
880
- },
881
- });
882
- if (builtinResult !== false) {
883
- if ("prompt" in builtinResult) {
884
- await record.session.prompt(builtinResult.prompt, { images });
885
- return;
886
- }
887
- const promptTurn = record.promptTurn;
888
- this.#finishPrompt(record, {
889
- stopReason: "end_turn",
890
- usage: this.#buildTurnUsage(
891
- promptTurn?.usageBaseline ??
892
- this.#cloneUsageStatistics(record.session.sessionManager.getUsageStatistics()),
893
- record.session.sessionManager.getUsageStatistics(),
894
- ),
895
- _meta: promptTurn ? { userMessageId: promptTurn.userMessageId } : undefined,
896
- });
897
- return;
898
- }
899
-
900
- if (await this.#tryRunSkillCommand(record, text, { directAliasMayCollide: false })) {
901
- return;
902
- }
903
-
904
- await record.session.prompt(text, { images });
905
- }
906
-
907
- async #tryRunSkillCommand(
908
- record: ManagedSessionRecord,
909
- text: string,
910
- options: { directAliasMayCollide?: boolean } = {},
911
- ): Promise<boolean> {
912
- if (!record.session.skillsSettings?.enableSkillCommands) {
913
- return false;
914
- }
915
- const skillsByCommandName = new Map(
916
- record.session.skills
917
- .filter(skill => skill.hide !== true)
918
- .flatMap(skill => getSkillSlashCommandNames(skill).map(commandName => [commandName, skill] as const)),
978
+ const saved = await this.#resolveSavedSession(id, cwd);
979
+ this.#assertSessionEpoch(id, epoch);
980
+ const result = await (await this.#brokerAdapter()).global(
981
+ "session.resume",
982
+ { cwd, sessionId: id, sessionPath: saved, target: { path: cwd } },
983
+ randomUUID(),
919
984
  );
920
- const invocations = parseSkillInvocations(text, skillsByCommandName);
921
- if (invocations.length > 0) {
922
- for (let index = 0; index < invocations.length; index += 1) {
923
- const invocation = invocations[index];
924
- if (!invocation) continue;
925
- const activationResult = await resolveSubskillActivationForSkillInvocation({
926
- cwd: record.session.sessionManager.getCwd(),
927
- sessionId: record.session.sessionId,
928
- skillName: invocation.skill.name,
929
- args: invocation.args,
930
- });
931
- const built = await buildSkillPromptMessage(invocation.skill, activationResult.cleanedArgs, {
932
- subskillActivation: activationResult.activation,
933
- subskillActivationSet: activationResult.activeSubskillsToPersist,
934
- cwd: record.session.sessionManager.getCwd(),
935
- sessionId: record.session.sessionId,
936
- });
937
- if (index === invocations.length - 1) {
938
- await record.session.promptCustomMessage({
939
- customType: SKILL_PROMPT_MESSAGE_TYPE,
940
- content: built.message,
941
- display: true,
942
- details: built.details,
943
- attribution: "user",
944
- });
945
- } else {
946
- await record.session.sendCustomMessage({
947
- customType: SKILL_PROMPT_MESSAGE_TYPE,
948
- content: built.message,
949
- display: true,
950
- details: built.details,
951
- attribution: "user",
952
- });
953
- }
954
- }
955
- return true;
956
- }
957
- const slashText = text.trimStart();
958
- if (!slashText.startsWith("/")) {
959
- return false;
960
- }
961
- const spaceIndex = slashText.indexOf(" ");
962
- const commandName = spaceIndex === -1 ? slashText.slice(1) : slashText.slice(1, spaceIndex);
963
- const args = spaceIndex === -1 ? "" : slashText.slice(spaceIndex + 1).trim();
964
- if (
965
- !isNamespacedSkillSlashCommandName(commandName) &&
966
- options.directAliasMayCollide !== true &&
967
- (await this.#directSkillAliasCollides(record, commandName))
968
- ) {
969
- return false;
970
- }
971
- const skill = record.session.skills.find(candidate => isSkillSlashCommandName(commandName, candidate));
972
- if (!skill || skill.hide === true) {
973
- return false;
974
- }
975
- const activationResult = await resolveSubskillActivationForSkillInvocation({
976
- cwd: record.session.sessionManager.getCwd(),
977
- sessionId: record.session.sessionId,
978
- skillName: skill.name,
979
- args,
980
- });
981
- const built = await buildSkillPromptMessage(skill, activationResult.cleanedArgs, {
982
- subskillActivation: activationResult.activation,
983
- subskillActivationSet: activationResult.activeSubskillsToPersist,
984
- cwd: record.session.sessionManager.getCwd(),
985
- sessionId: record.session.sessionId,
986
- });
987
- await record.session.promptCustomMessage({
988
- customType: SKILL_PROMPT_MESSAGE_TYPE,
989
- content: built.message,
990
- display: true,
991
- details: built.details,
992
- attribution: "user",
993
- });
994
- return true;
985
+ this.#assertSessionEpoch(id, epoch);
986
+ await this.#attach(id, cwd, endpoint(result), epoch);
995
987
  }
996
988
 
997
- async #directSkillAliasCollides(record: ManagedSessionRecord, commandName: string): Promise<boolean> {
998
- if (record.session.customCommands.some(command => command.command.name === commandName)) {
999
- return true;
989
+ async #scopedBrokerSession(id: string, cwd: string): Promise<BrokerSession | undefined> {
990
+ const response = object(await (await this.#brokerAdapter()).global("session.list", { cwd }));
991
+ const result = object(response?.result) ?? response;
992
+ const matches: BrokerSession[] = [];
993
+ for (const item of Array.isArray(result?.sessions) ? result.sessions : []) {
994
+ const session = object(item) as BrokerSession | undefined;
995
+ if (session?.sessionId !== id) continue;
996
+ if (typeof session.locator?.repo !== "string" || path.resolve(session.locator.repo) !== path.resolve(cwd))
997
+ throw new AcpSdkAdapterError("conflict", `Broker returned conflicting session scope for ${id}.`);
998
+ matches.push(session);
1000
999
  }
1001
- const fileCommands = await loadSlashCommands({ cwd: record.session.sessionManager.getCwd() });
1002
- return fileCommands.some(command => command.name === commandName);
1000
+ if (matches.length > 1) throw new AcpSdkAdapterError("conflict", `Broker returned duplicate session id: ${id}`);
1001
+ return matches[0];
1003
1002
  }
1004
1003
 
1005
- async cancel(params: { sessionId: string }): Promise<void> {
1006
- const record = this.#getSessionRecord(params.sessionId);
1007
- const promptTurn = record.promptTurn;
1008
- if (!promptTurn || promptTurn.settled) {
1004
+ async #attach(id: string, cwd: string, discovered: Endpoint, epoch = this.#sessionEpoch(id)): Promise<void> {
1005
+ this.#assertSessionEpoch(id, epoch);
1006
+ const existing = this.#sessions.get(id);
1007
+ if (existing) {
1008
+ if (path.resolve(existing.cwd) !== path.resolve(cwd))
1009
+ throw new AcpSdkAdapterError("conflict", `ACP session ${id} has conflicting cwd authority.`);
1009
1010
  return;
1010
1011
  }
1011
- const cleanup = this.#beginCancelCleanup(record, promptTurn);
1012
- try {
1013
- await cleanup;
1014
- } catch (error: unknown) {
1015
- // A terminal delete owns the record; let the delete handle the failure
1016
- // rather than racing it with an independent close.
1017
- if (record.terminalState !== "active") {
1018
- return;
1019
- }
1020
- logger.warn("ACP cancel cleanup timed out; closing session", { sessionId: record.session.sessionId, error });
1021
- await this.#closeManagedSession(record.session.sessionId, record);
1022
- }
1023
- }
1024
-
1025
- /**
1026
- * Transition a still-running turn into cancellation: mark intent, drop the live-event
1027
- * subscription, start the bounded `abort()` race, and resolve the ACP prompt response
1028
- * with `stopReason: "cancelled"` so the client sees acceptance immediately. The
1029
- * returned promise is the cleanup barrier — it resolves when `abort()` completes and
1030
- * rejects when the timeout fires. Idempotent: a second call returns the same barrier.
1031
- */
1032
- #beginCancelCleanup(record: ManagedSessionRecord, promptTurn: PromptTurnState): Promise<void> {
1033
- if (promptTurn.cleanup) {
1034
- return promptTurn.cleanup;
1035
- }
1036
- promptTurn.cancelRequested = true;
1037
- promptTurn.unsubscribe?.();
1038
- void this.#emitCancelledPromptIdleUpdate(record);
1039
- const cleanup = this.#runCancelCleanup(record, promptTurn);
1040
- promptTurn.cleanup = cleanup;
1041
- this.#finishPrompt(record, {
1042
- stopReason: "cancelled",
1043
- usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()),
1044
- _meta: { userMessageId: promptTurn.userMessageId },
1045
- });
1046
- return cleanup;
1047
- }
1048
-
1049
- async #runCancelCleanup(record: ManagedSessionRecord, promptTurn: PromptTurnState): Promise<void> {
1050
- let timer: NodeJS.Timeout | undefined;
1051
- const timeout = new Promise<never>((_, reject) => {
1052
- timer = setTimeout(() => reject(new Error("ACP cancel cleanup timed out")), this.#cancelCleanupTimeoutMs);
1053
- });
1054
- try {
1055
- await Promise.race([record.session.abort(), timeout]);
1056
- } finally {
1057
- if (timer) clearTimeout(timer);
1058
- // Order matters: clear `cleanup` before evicting the slot so the slot-eviction
1059
- // branch matches what `#finishPrompt` saw if it ran first.
1060
- promptTurn.cleanup = undefined;
1061
- if (promptTurn.settled && record.promptTurn === promptTurn) {
1062
- record.promptTurn = undefined;
1063
- }
1064
- }
1065
- }
1066
-
1067
- async extMethod(method: string, params: { [key: string]: unknown }): Promise<{ [key: string]: unknown }> {
1068
- switch (method) {
1069
- case "_gjc/sessions/listAll": {
1070
- const limit = typeof params.limit === "number" ? Math.max(1, Math.min(5000, params.limit as number)) : 1000;
1071
- const sessions = await SessionManager.listAll();
1072
- const sorted = sessions.sort((l, r) => r.modified.getTime() - l.modified.getTime()).slice(0, limit);
1073
- return {
1074
- sessions: sorted.map(s => this.#toSessionInfo(s)),
1075
- total: sessions.length,
1076
- };
1077
- }
1078
- case "_gjc/projects/list": {
1079
- const sessions = await SessionManager.listAll();
1080
- const buckets = new Map<
1081
- string,
1082
- { cwd: string; sessionCount: number; lastActivityAt: number; lastTitle: string }
1083
- >();
1084
- for (const s of sessions) {
1085
- if (!s.cwd) continue;
1086
- const ts = s.modified.getTime();
1087
- const existing = buckets.get(s.cwd);
1088
- if (existing) {
1089
- existing.sessionCount += 1;
1090
- if (ts > existing.lastActivityAt) {
1091
- existing.lastActivityAt = ts;
1092
- existing.lastTitle = s.title ?? "";
1093
- }
1094
- } else {
1095
- buckets.set(s.cwd, {
1096
- cwd: s.cwd,
1097
- sessionCount: 1,
1098
- lastActivityAt: ts,
1099
- lastTitle: s.title ?? "",
1100
- });
1101
- }
1102
- }
1103
- const projects = Array.from(buckets.values()).sort((a, b) => b.lastActivityAt - a.lastActivityAt);
1104
- return { projects, totalSessions: sessions.length };
1105
- }
1106
- case "_gjc/chats/byCwd": {
1107
- const cwd = typeof params.cwd === "string" ? (params.cwd as string) : undefined;
1108
- if (!cwd) throw new Error("cwd required");
1109
- const limit = typeof params.limit === "number" ? Math.max(1, Math.min(500, params.limit as number)) : 100;
1110
- const sessions = await SessionManager.list(cwd);
1111
- const sorted = sessions.sort((l, r) => r.modified.getTime() - l.modified.getTime()).slice(0, limit);
1112
- return { sessions: sorted.map(s => this.#toSessionInfo(s)) };
1113
- }
1114
- case "_gjc/usage": {
1115
- const [firstRecord] = this.#sessions.values();
1116
- const target = firstRecord?.session ?? this.#initialSession;
1117
- if (!target) {
1118
- return { reports: [] };
1119
- }
1120
- const reports = await target.fetchUsageReports();
1121
- return { reports: reports ?? [] };
1122
- }
1123
- case "_gjc/extensions": {
1124
- const cwd = typeof params.cwd === "string" ? (params.cwd as string) : undefined;
1125
- const sm = await Settings.init();
1126
- const disabledIds = (sm.get("disabledExtensions") as string[] | undefined) ?? [];
1127
- const extensions = await loadAllExtensions(cwd, disabledIds);
1128
- return { extensions: extensions as unknown as Array<{ [key: string]: unknown }> };
1129
- }
1130
- case "_gjc/extensions/toggle": {
1131
- const providerId = params.providerId;
1132
- if (typeof providerId !== "string") throw new Error("providerId required");
1133
- if (params.enabled === false) {
1134
- disableProvider(providerId);
1135
- return { enabled: false };
1136
- }
1137
- enableProvider(providerId);
1138
- return { enabled: true };
1139
- }
1140
- default:
1141
- throw new Error(`Unknown ACP ext method: ${method}`);
1142
- }
1143
- }
1144
-
1145
- async extNotification(_method: string, _params: { [key: string]: unknown }): Promise<void> {}
1146
-
1147
- get signal(): AbortSignal {
1148
- return this.#connection.signal;
1149
- }
1150
-
1151
- get closed(): Promise<void> {
1152
- return this.#connection.closed;
1153
- }
1154
-
1155
- #registerConnectionCleanup(): void {
1156
- if (this.#cleanupRegistered) {
1012
+ const attaching = this.#attaching.get(id);
1013
+ if (attaching?.epoch === epoch) {
1014
+ await attaching.task;
1015
+ this.#assertSessionEpoch(id, epoch);
1016
+ const attached = this.#sessions.get(id);
1017
+ if (!attached) throw new AcpSdkAdapterError("unavailable", `ACP session ${id} did not attach.`);
1018
+ if (path.resolve(attached.cwd) !== path.resolve(cwd))
1019
+ throw new AcpSdkAdapterError("conflict", `ACP session ${id} has conflicting cwd authority.`);
1157
1020
  return;
1158
1021
  }
1159
- this.#cleanupRegistered = true;
1160
- this.#connection.signal.addEventListener(
1161
- "abort",
1162
- () => {
1163
- this.#shuttingDown = true;
1164
- // Mark shutdown under mutex (no actual mutex needed — single-threaded),
1165
- // reject new scope/inventory/lifecycle work, then await terminal work.
1166
- this.#shutdownPromise = (async () => {
1167
- // Join lifecycle preparation that has not registered yet. Each tracked
1168
- // operation rechecks shutdown and disposes its prepared session before settling.
1169
- await Promise.allSettled(Array.from(this.#lifecycleOperations));
1170
- const terminalPromises = Array.from(this.#sessions.values())
1171
- .map(r => r.terminalPromise)
1172
- .filter((p): p is Promise<DeleteSessionResponse> => p !== undefined);
1173
- await Promise.allSettled(terminalPromises);
1174
- await this.#disposeAllSessions();
1175
- // Clear connection-local authority.
1176
- this.#authoritySnapshot = undefined;
1177
- this.#canonicalCwdScope = undefined;
1178
- })();
1179
- },
1180
- { once: true },
1181
- );
1182
- }
1183
-
1184
- /**
1185
- * Connection-local shutdown promise. Resolves once all terminal work (dispose,
1186
- * active deletes, abort cleanup) has settled. `acp-mode.ts` awaits this after
1187
- * transport closure so `process.exit` never kills in-flight deletion/disposal.
1188
- */
1189
- get shutdownPromise(): Promise<void> {
1190
- return this.#shutdownPromise ?? Promise.resolve();
1191
- }
1192
1022
 
1193
- async #trackLifecycle<T>(operation: () => Promise<T>): Promise<T> {
1194
- this.#rejectIfShuttingDown();
1195
- const start = Promise.withResolvers<void>();
1196
- const promise = start.promise.then(operation);
1197
- this.#lifecycleOperations.add(promise);
1198
- start.resolve();
1023
+ const task = this.#attachEndpoint(id, cwd, discovered, epoch);
1024
+ const pending = { epoch, task };
1025
+ this.#attaching.set(id, pending);
1199
1026
  try {
1200
- return await promise;
1027
+ await task;
1028
+ this.#assertSessionEpoch(id, epoch);
1201
1029
  } finally {
1202
- this.#lifecycleOperations.delete(promise);
1030
+ if (this.#attaching.get(id) === pending) this.#attaching.delete(id);
1203
1031
  }
1204
1032
  }
1205
1033
 
1206
- async #createNewSessionRecord(cwd: string, mcpServers: McpServer[]): Promise<ManagedSessionRecord> {
1207
- const session = await this.#createSession(path.resolve(cwd));
1034
+ async #attachEndpoint(id: string, cwd: string, discovered: Endpoint, epoch: number): Promise<void> {
1035
+ let adapter: AcpSdkAdapter | undefined;
1208
1036
  try {
1209
- await session.sessionManager.ensureOnDisk();
1037
+ adapter = await AcpSdkAdapter.connect({
1038
+ url: discovered.url,
1039
+ token: discovered.token,
1040
+ connection: this.#reverseConnection(id),
1041
+ providers: this.#providers(),
1042
+ });
1043
+ this.#assertSessionEpoch(id, epoch);
1044
+ const record: SessionRecord = {
1045
+ cwd,
1046
+ adapter,
1047
+ unsubscribe: () => {},
1048
+ reconnectUnsubscribe: () => {},
1049
+ frameTail: Promise.resolve(),
1050
+ inboundSequence: 0,
1051
+ busy: false,
1052
+ };
1053
+ record.unsubscribe = adapter.onFrame(frame => this.#enqueueSdkFrame(id, adapter!, frame));
1054
+ record.reconnectUnsubscribe = adapter.onReconnectFailed(error =>
1055
+ this.#recoverSessionAfterTransportFailure(id, adapter!, error),
1056
+ );
1057
+ this.#sessions.set(id, record);
1058
+ this.#knownSessionCwds.set(id, cwd);
1059
+ await applyAcpPermissionMode(adapter, this.#clientCapabilities);
1060
+ this.#assertSessionEpoch(id, epoch);
1210
1061
  } catch (error) {
1211
- await this.#disposeStandaloneSession(session);
1062
+ if (adapter && this.#sessions.get(id)?.adapter === adapter) {
1063
+ try {
1064
+ await this.#teardownSession(id, "attachment failed", false);
1065
+ } finally {
1066
+ this.#knownSessionCwds.delete(id);
1067
+ }
1068
+ } else if (adapter) {
1069
+ try {
1070
+ await adapter.close();
1071
+ } catch {}
1072
+ }
1212
1073
  throw error;
1213
1074
  }
1214
- return await this.#registerPreparedSession(session, mcpServers);
1215
1075
  }
1216
1076
 
1217
- async #loadManagedSession(sessionId: string, cwd: string, mcpServers: McpServer[]): Promise<ManagedSessionRecord> {
1218
- const existing = this.#sessions.get(sessionId);
1219
- if (existing) {
1220
- this.#rejectIfTerminal(existing);
1221
- this.#assertMatchingCwd(existing.session, cwd);
1222
- await this.#configureMcpServers(existing, mcpServers);
1223
- return existing;
1224
- }
1225
-
1226
- const candidate = this.#resolveStrictLifecycleCandidate(sessionId, cwd);
1227
- return await this.#openStoredSession(candidate, cwd, mcpServers, sessionId);
1077
+ #recoverSessionAfterTransportFailure(id: string, adapter: AcpSdkAdapter, error: Error): void {
1078
+ const record = this.#sessions.get(id);
1079
+ if (!record || record.adapter !== adapter) return;
1080
+ const detail = error.message || "SDK transport reconnect failed.";
1081
+ const terminal = new AcpSdkAdapterError("connection_closed", `ACP session transport was lost: ${detail}`);
1082
+ void this.#recoverSessionAfterTransportFailureAsync(id, adapter, record.cwd, terminal);
1228
1083
  }
1229
1084
 
1230
- async #resumeManagedSession(sessionId: string, cwd: string, mcpServers: McpServer[]): Promise<ManagedSessionRecord> {
1231
- const existing = this.#sessions.get(sessionId);
1232
- if (existing) {
1233
- this.#rejectIfTerminal(existing);
1234
- this.#assertMatchingCwd(existing.session, cwd);
1235
- await this.#configureMcpServers(existing, mcpServers);
1236
- return existing;
1085
+ async #recoverSessionAfterTransportFailureAsync(
1086
+ id: string,
1087
+ adapter: AcpSdkAdapter,
1088
+ cwd: string,
1089
+ error: AcpSdkAdapterError,
1090
+ ): Promise<void> {
1091
+ await this.#failSession(id, adapter, error);
1092
+ if (this.#disposed || this.#knownSessionCwds.get(id) !== cwd) return;
1093
+ try {
1094
+ await this.#attachExisting(id, cwd);
1095
+ } catch {
1096
+ // The affected prompt was rejected and the stale adapter was removed. A later load/resume retries discovery.
1237
1097
  }
1098
+ }
1238
1099
 
1239
- const candidate = this.#resolveStrictLifecycleCandidate(sessionId, cwd);
1240
- return await this.#openStoredSession(candidate, cwd, mcpServers, sessionId);
1100
+ async #discardNewSession(id: string): Promise<void> {
1101
+ await this.#teardownSession(id, "discarded", true);
1102
+ this.#knownSessionCwds.delete(id);
1241
1103
  }
1242
1104
 
1243
1105
  /**
1244
- * Resolve a single exact-one scoped candidate via a fresh strict inventory.
1245
- * Rejects duplicates and inventory failures — never falls back to a forgiving
1246
- * first-match lookup. Used by load/resume so authoritative lifecycle paths bind
1247
- * exact identity before opening a stored session.
1106
+ * All local session disposal follows one path: remove ownership and reject a
1107
+ * waiting prompt before any awaited socket or broker work. A failed close is
1108
+ * terminally uncertain, not a reason to leave a usable-looking ACP record.
1248
1109
  */
1249
- #resolveStrictLifecycleCandidate(sessionId: string, scope: string): StrictInventoryCandidate {
1250
- const snapshot = this.#buildFreshAuthoritySnapshot(scope);
1251
- const entry = snapshot.entries.get(sessionId);
1252
- if (!entry) {
1253
- throw new Error(`ACP session not found: ${sessionId}`);
1254
- }
1255
- if (entry.kind !== "candidate") {
1256
- throw new Error(`ACP session ${sessionId} load failed: ${entry.reason}`);
1257
- }
1258
- return entry.candidate;
1259
- }
1260
-
1261
- async #forkManagedSession(params: ForkSessionRequest): Promise<ManagedSessionRecord> {
1262
- const sourcePath = await this.#resolveForkSourceSessionPath(params.sessionId);
1263
- const session = await this.#createSession(path.resolve(params.cwd));
1110
+ async #teardownSession(id: string, reason: string, closeRemote: boolean): Promise<void> {
1111
+ const record = this.#sessions.get(id);
1112
+ const ownershipBound = record !== undefined || this.#knownSessionCwds.has(id);
1113
+ this.#beginTeardown(id);
1264
1114
  try {
1265
- const success = await session.switchSession(sourcePath);
1266
- if (!success) {
1267
- throw new Error(`ACP session fork was cancelled: ${params.sessionId}`);
1268
- }
1269
- const forked = await session.fork();
1270
- if (!forked) {
1271
- throw new Error(`ACP session fork failed: ${params.sessionId}`);
1115
+ this.#advanceSessionEpoch(id);
1116
+ if (record) {
1117
+ this.#sessions.delete(id);
1118
+ record.unsubscribe();
1119
+ record.reconnectUnsubscribe();
1120
+ const waiter = record.activePrompt;
1121
+ record.activePrompt = undefined;
1122
+ waiter?.reject(new AcpSdkAdapterError("connection_closed", `ACP session was ${reason}.`));
1272
1123
  }
1273
- } catch (error) {
1274
- await this.#disposeStandaloneSession(session);
1275
- throw error;
1276
- }
1277
- return await this.#registerPreparedSession(session, params.mcpServers ?? []);
1278
- }
1279
1124
 
1280
- async #openStoredSession(
1281
- issued: StrictInventoryCandidate,
1282
- cwd: string,
1283
- mcpServers: McpServer[],
1284
- sessionId: string,
1285
- ): Promise<ManagedSessionRecord> {
1286
- const session = await this.#createSession(path.resolve(cwd));
1287
- try {
1288
- const current = this.#resolveStrictLifecycleCandidate(sessionId, cwd);
1289
- if (
1290
- current.path !== issued.path ||
1291
- current.cwd !== issued.cwd ||
1292
- current.identity.dev !== issued.identity.dev ||
1293
- current.identity.ino !== issued.identity.ino
1294
- ) {
1295
- throw new Error(`ACP session ${sessionId} changed while opening`);
1125
+ const failures: unknown[] = [];
1126
+ try {
1127
+ await record?.adapter.close();
1128
+ } catch (error) {
1129
+ failures.push(error);
1130
+ }
1131
+ if (closeRemote) {
1132
+ try {
1133
+ await (await this.#brokerAdapter()).global(
1134
+ "session.close",
1135
+ { sessionId: id },
1136
+ this.#lifecycleIdempotencyKey(id, "session.close"),
1137
+ );
1138
+ } catch (error) {
1139
+ if (!(ownershipBound && this.#isAlreadyGone(error))) failures.push(error);
1140
+ }
1296
1141
  }
1297
- const success = await session.switchSession(current.path);
1298
- if (!success) {
1299
- throw new Error(`ACP session load was cancelled: ${sessionId}`);
1142
+ if (failures.length > 0) {
1143
+ const detail = failures
1144
+ .map(failure => (failure instanceof Error ? failure.message : String(failure)))
1145
+ .join("; ");
1146
+ throw aggregateAcpFailure("terminal_uncertain", `ACP session cleanup is uncertain: ${detail}`, failures);
1300
1147
  }
1301
- } catch (error) {
1302
- await this.#disposeStandaloneSession(session);
1303
- throw error;
1148
+ } finally {
1149
+ this.#finishTeardown(id);
1304
1150
  }
1305
- return await this.#registerPreparedSession(session, mcpServers);
1306
1151
  }
1307
1152
 
1308
- async #registerPreparedSession(session: AgentSession, mcpServers: McpServer[]): Promise<ManagedSessionRecord> {
1309
- const record = this.#createManagedSessionRecord(session);
1310
- session.setClientBridge(createAcpClientBridge(this.#connection, session.sessionId, this.#clientCapabilities));
1311
- // The lifetime subscription normally follows the bootstrap race guard, but
1312
- // prompt() installs it eagerly once the client has demonstrated session ownership.
1153
+ async #failSession(id: string, adapter: AcpSdkAdapter, error: AcpSdkAdapterError): Promise<void> {
1154
+ const record = this.#sessions.get(id);
1155
+ if (!record || record.adapter !== adapter) return;
1156
+ this.#advanceSessionEpoch(id);
1157
+ this.#sessions.delete(id);
1158
+ record.unsubscribe();
1159
+ record.reconnectUnsubscribe();
1160
+ const waiter = record.activePrompt;
1161
+ record.activePrompt = undefined;
1162
+ waiter?.reject(error);
1313
1163
  try {
1314
- await this.#configureExtensions(record);
1315
- await this.#configureMcpServers(record, mcpServers);
1316
- if (this.#shuttingDown) {
1317
- await this.#disposeSessionRecord(record);
1318
- throw new Error("ACP session lifecycle is unavailable during shutdown");
1319
- }
1320
- this.#sessions.set(session.sessionId, record);
1321
- return record;
1322
- } catch (error) {
1323
- await this.#disposeSessionRecord(record);
1324
- throw error;
1325
- }
1164
+ await adapter.close();
1165
+ } catch {}
1326
1166
  }
1327
1167
 
1328
- #createManagedSessionRecord(session: AgentSession): ManagedSessionRecord {
1329
- return {
1330
- session,
1331
- mcpManager: undefined,
1332
- promptTurn: undefined,
1333
- promptQueue: { promise: Promise.resolve(), release: undefined },
1334
- liveMessageId: undefined,
1335
- liveMessageProgress: undefined,
1336
- toolArgsById: new Map(),
1337
- extensionsConfigured: false,
1338
- lifetimeUnsubscribe: undefined,
1339
- terminalState: "active",
1340
- terminalFailure: undefined,
1341
- terminalPromise: undefined,
1342
- };
1168
+ async #brokerAdapter(): Promise<AcpSdkAdapter> {
1169
+ return (await this.#brokerConnection()).adapter;
1343
1170
  }
1344
1171
 
1345
- #ensureLifetimeSubscription(record: ManagedSessionRecord): void {
1346
- if (record.lifetimeUnsubscribe) {
1347
- return;
1348
- }
1349
- record.lifetimeUnsubscribe = record.session.subscribe(event => {
1350
- void this.#handleLifetimeEvent(record, event);
1351
- });
1172
+ /** Machine-local endpoint lookup; never routed through ACP extension methods. */
1173
+ async #brokerEndpoint(sessionId: string, endpointGeneration: number | undefined): Promise<unknown> {
1174
+ const input = { sessionId, ...(endpointGeneration === undefined ? {} : { endpointGeneration }) };
1175
+ return await (await this.#brokerConnection()).client.global("session.get_endpoint", input);
1352
1176
  }
1353
1177
 
1354
- async #handleLifetimeEvent(record: ManagedSessionRecord, event: AgentSessionEvent): Promise<void> {
1355
- if (event.type === "auto_compaction_start" || event.type === "auto_compaction_end") {
1356
- // Prompt-bound compaction is normally forwarded by #handlePromptEvent. The lifetime
1357
- // subscription covers idle maintenance and the end event after prompt cancellation.
1358
- const promptTurn = record.promptTurn;
1359
- if (
1360
- isPromptTurnInFlight(promptTurn) &&
1361
- (!promptTurn.cancelRequested || event.type === "auto_compaction_start")
1362
- ) {
1363
- return;
1364
- }
1365
- for (const notification of mapAgentWireEventPayloadToAcpSessionUpdates(
1366
- toAgentWireEventPayload(event),
1367
- record.session.sessionId,
1368
- {
1369
- cwd: record.session.sessionManager.getCwd(),
1370
- compactionEndPhase: "idle",
1371
- },
1372
- )) {
1373
- await this.#connection.sessionUpdate(notification);
1374
- }
1375
- return;
1376
- }
1377
- if (event.type !== "thinking_level_changed") {
1378
- return;
1178
+ async #brokerConnection(): Promise<BrokerConnection> {
1179
+ if (!this.#broker) {
1180
+ let pending!: Promise<BrokerConnection>;
1181
+ pending = (async () => {
1182
+ await ensureBroker({ agentDir: this.#agentDir });
1183
+ const discovery = await readSdkBrokerDiscovery(this.#agentDir);
1184
+ if (!discovery) throw new AcpSdkAdapterError("unavailable", "SDK broker discovery is unavailable.");
1185
+ const client = await SdkClient.connect(discovery.url, discovery.token);
1186
+ const adapter = new AcpSdkAdapter({ url: discovery.url, token: discovery.token, client });
1187
+ adapter.onReconnectFailed(() => {
1188
+ if (this.#broker === pending) this.#broker = undefined;
1189
+ void adapter.close().catch(() => undefined);
1190
+ });
1191
+ await adapter.start();
1192
+ return { adapter, client };
1193
+ })();
1194
+ this.#broker = pending;
1379
1195
  }
1196
+ const pending = this.#broker;
1380
1197
  try {
1381
- await this.#pushConfigOptionUpdate(record);
1198
+ return await pending;
1382
1199
  } catch (error) {
1383
- logger.warn("Failed to push thinking-level config_option_update", {
1384
- sessionId: record.session.sessionId,
1385
- error,
1386
- });
1200
+ if (this.#broker === pending) this.#broker = undefined;
1201
+ throw error;
1387
1202
  }
1388
1203
  }
1389
1204
 
1390
- #getSessionRecord(sessionId: string): ManagedSessionRecord {
1391
- const record = this.#sessions.get(sessionId);
1392
- if (!record) {
1393
- throw new Error(`Unsupported ACP session: ${sessionId}`);
1394
- }
1395
- return record;
1205
+ #adapter(id: string): AcpSdkAdapter {
1206
+ const record = this.#sessions.get(id);
1207
+ if (!record) throw new AcpSdkAdapterError("not_found", `Unsupported ACP session: ${id}`);
1208
+ return record.adapter;
1396
1209
  }
1397
1210
 
1398
- #assertMatchingCwd(session: AgentSession, cwd: string): void {
1399
- const expected = path.resolve(cwd);
1400
- const actual = path.resolve(session.sessionManager.getCwd());
1401
- if (actual !== expected) {
1402
- throw new Error(`ACP session ${session.sessionId} is already loaded for ${actual}, not ${expected}`);
1403
- }
1211
+ async #resolveSavedSession(id: string, cwd: string): Promise<string> {
1212
+ const response = object(
1213
+ await (await this.#brokerAdapter()).global("session.list", { resolveSessionId: id, cwd }),
1214
+ );
1215
+ const result = object(response?.result) ?? response;
1216
+ const saved = object(result?.savedSession);
1217
+ if (saved?.id !== id || typeof saved.path !== "string")
1218
+ throw new AcpSdkAdapterError("not_found", `Saved ACP session does not exist: ${id}`);
1219
+ return saved.path;
1404
1220
  }
1405
1221
 
1406
- async #resolveForkSourceSessionPath(sessionId: string): Promise<string> {
1407
- const loaded = this.#sessions.get(sessionId);
1408
- if (loaded) {
1409
- if (loaded.terminalState !== "active") {
1410
- throw new Error(`ACP session fork is unavailable while it is in terminal state: ${sessionId}`);
1411
- }
1412
- if (isPromptTurnInFlight(loaded.promptTurn)) {
1413
- throw new Error(`ACP session fork is unavailable while a prompt is in progress: ${sessionId}`);
1414
- }
1415
- await loaded.session.sessionManager.flush();
1416
- const sessionPath = loaded.session.sessionManager.getSessionFile();
1417
- if (!sessionPath) {
1418
- throw new Error(`ACP session cannot be forked before it is persisted: ${sessionId}`);
1419
- }
1420
- return sessionPath;
1421
- }
1422
-
1423
- // Inactive fork source: exact-one scoped lookup only. Never use global
1424
- // listAll/first-match — that was the P1 data-loss path.
1425
- const scope = this.#canonicalCwdScope;
1426
- if (!scope) {
1427
- throw new Error(`ACP session not found (no scope established): ${sessionId}`);
1428
- }
1429
- const snapshot = this.#buildFreshAuthoritySnapshot(scope);
1430
- const entry = snapshot.entries.get(sessionId);
1431
- if (!entry) {
1432
- throw new Error(`ACP session not found: ${sessionId}`);
1433
- }
1434
- if (entry.kind !== "candidate") {
1435
- throw new Error(`ACP session fork failed: ${entry.reason}`);
1436
- }
1437
- return entry.candidate.path;
1222
+ #providers(): AcpProviderRegistration[] {
1223
+ return acpProviderRegistrations(this.#clientCapabilities);
1438
1224
  }
1439
1225
 
1440
- async #handlePromptEvent(record: ManagedSessionRecord, event: AgentSessionEvent): Promise<void> {
1441
- const promptTurn = record.promptTurn;
1442
- if (!promptTurn || promptTurn.settled || promptTurn.cancelRequested) {
1443
- return;
1444
- }
1445
-
1446
- if (event.type === "tool_execution_start" || event.type === "tool_execution_update") {
1447
- record.toolArgsById.set(event.toolCallId, event.args);
1448
- }
1449
-
1450
- this.#prepareLiveAssistantMessage(record, event);
1451
- for (const notification of mapAgentWireEventPayloadToAcpSessionUpdates(
1452
- toAgentWireEventPayload(event),
1453
- record.session.sessionId,
1454
- {
1455
- getMessageId: message => this.#getLiveMessageId(record, message),
1456
- getMessageProgress: message => this.#getLiveMessageProgress(record, message),
1457
- getToolArgs: toolCallId => record.toolArgsById.get(toolCallId),
1458
- cwd: record.session.sessionManager.getCwd(),
1459
- compactionEndPhase: "responding",
1226
+ #reverseConnection(sessionId: string): AcpReverseConnection {
1227
+ const methods: Record<string, string> = {
1228
+ "fs.readTextFile": "readTextFile",
1229
+ "fs.writeTextFile": "writeTextFile",
1230
+ "terminal.create": "createTerminal",
1231
+ "permission.request": "requestPermission",
1232
+ "ui.elicit": "unstable_createElicitation",
1233
+ };
1234
+ return {
1235
+ request: async (method: string, params: JsonObject): Promise<unknown> => {
1236
+ const name = methods[method] ?? method;
1237
+ const target = (this.#connection as unknown as Record<string, unknown>)[name];
1238
+ if (typeof target !== "function")
1239
+ throw new AcpSdkAdapterError("acp_reverse_unavailable", `ACP reverse method is unavailable: ${method}`);
1240
+ const request = method === "permission.request" ? { ...params, sessionId } : params;
1241
+ return await (target as (input: JsonObject) => Promise<unknown>)(request);
1460
1242
  },
1461
- )) {
1462
- await this.#connection.sessionUpdate(notification);
1463
- }
1464
- if (event.type === "tool_execution_end") {
1465
- record.toolArgsById.delete(event.toolCallId);
1466
- }
1467
- this.#clearLiveAssistantMessageAfterEvent(record, event);
1468
-
1469
- if (event.type === "agent_end") {
1470
- await this.#emitEndOfTurnUpdates(record);
1471
- await this.#waitForAcpPromptIdle(record);
1472
- this.#finishPrompt(record, {
1473
- stopReason: this.#resolveStopReason(event, promptTurn.cancelRequested),
1474
- usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()),
1475
- _meta: { userMessageId: promptTurn.userMessageId },
1476
- });
1477
- }
1243
+ };
1478
1244
  }
1479
1245
 
1480
- async #waitForAcpPromptIdle(record: ManagedSessionRecord): Promise<void> {
1481
- for (let pass = 0; pass < ACP_ASYNC_DELIVERY_DRAIN_MAX_PASSES; pass++) {
1482
- await record.session.waitForIdle();
1483
- const delivered = await record.session.drainAsyncJobDeliveriesForAcp({
1484
- timeoutMs: ACP_ASYNC_DELIVERY_DRAIN_TIMEOUT_MS,
1485
- });
1486
- if (!delivered) {
1487
- return;
1488
- }
1489
- }
1490
-
1491
- await record.session.waitForIdle();
1246
+ #observeSessionActivity(record: SessionRecord, frame: JsonObject): void {
1247
+ const event = receivedSdkEvent(frame)?.event;
1248
+ if (event?.type === "agent_start") record.busy = true;
1249
+ else if (event?.type === "agent_end") record.busy = false;
1492
1250
  }
1493
1251
 
1494
- #prepareLiveAssistantMessage(record: ManagedSessionRecord, event: AgentSessionEvent): void {
1495
- if (
1496
- (event.type === "message_start" || event.type === "message_update" || event.type === "message_end") &&
1497
- event.message.role === "assistant" &&
1498
- (event.type === "message_start" || !record.liveMessageId || !record.liveMessageProgress)
1499
- ) {
1500
- record.liveMessageId = crypto.randomUUID();
1501
- record.liveMessageProgress = { textEmitted: false, thoughtEmitted: false };
1502
- }
1252
+ #frameProcessingFailure(error: unknown): AcpSdkAdapterError {
1253
+ if (error instanceof AcpSdkAdapterError && error.code === "frame_processing_failed") return error;
1254
+ const detail = error instanceof Error ? error.message : String(error);
1255
+ return new AcpSdkAdapterError("frame_processing_failed", `ACP session frame processing failed: ${detail}`);
1503
1256
  }
1504
1257
 
1505
- #clearLiveAssistantMessageAfterEvent(record: ManagedSessionRecord, event: AgentSessionEvent): void {
1506
- if ((event.type === "message_end" && event.message.role === "assistant") || event.type === "agent_end") {
1507
- record.liveMessageId = undefined;
1508
- record.liveMessageProgress = undefined;
1509
- }
1258
+ #enqueueSdkFrame(id: string, adapter: AcpSdkAdapter, frame: JsonObject): void {
1259
+ const record = this.#sessions.get(id);
1260
+ if (!record || record.adapter !== adapter) return;
1261
+ // Sequence and busy state are captured at ingress, before queued work begins.
1262
+ // A frame received before acknowledgement stays before that prompt's boundary.
1263
+ this.#observeSessionActivity(record, frame);
1264
+ const sequence = ++record.inboundSequence;
1265
+ const task = record.frameTail.then(async () => await this.#handleSdkFrame(id, adapter, frame, sequence));
1266
+ record.frameTail = task.catch(
1267
+ async error => await this.#failSession(id, adapter, this.#frameProcessingFailure(error)),
1268
+ );
1510
1269
  }
1511
1270
 
1512
- #getLiveMessageId(record: ManagedSessionRecord, message: unknown): string | undefined {
1513
- if (typeof message !== "object" || message === null) {
1514
- return undefined;
1271
+ async #handleSdkFrame(id: string, adapter: AcpSdkAdapter, frame: JsonObject, sequence: number): Promise<void> {
1272
+ const record = this.#sessions.get(id);
1273
+ if (!record || record.adapter !== adapter) return;
1274
+ const received = receivedSdkEvent(frame);
1275
+ if (!received) return;
1276
+ const { event, wirePayload } = received;
1277
+ const correlation = correlationFrom(frame, event);
1278
+ const activePrompt = record.activePrompt;
1279
+ // Prompt ownership is updated before publishing client notifications, but a
1280
+ // terminal waiter is resolved only after publication succeeds so a failed
1281
+ // sessionUpdate rejects the prompt instead of reporting false completion.
1282
+ if (
1283
+ activePrompt?.acknowledged &&
1284
+ sequence > activePrompt.boundary &&
1285
+ !correlationsConflict(activePrompt.correlation, correlation)
1286
+ ) {
1287
+ if (isPromptActivity(String(event.type))) {
1288
+ activePrompt.activityObserved = true;
1289
+ } else if (event.type === "agent_end") {
1290
+ if (
1291
+ correlationsMatch(activePrompt.correlation, correlation) ||
1292
+ activePrompt.activityObserved ||
1293
+ activePrompt.steeringAtAcknowledgement
1294
+ )
1295
+ activePrompt.pendingTerminal = correlation;
1296
+ }
1515
1297
  }
1516
- record.liveMessageId ??= crypto.randomUUID();
1517
- return record.liveMessageId;
1518
- }
1519
-
1520
- #getLiveMessageProgress(
1521
- record: ManagedSessionRecord,
1522
- message: unknown,
1523
- ): { textEmitted: boolean; thoughtEmitted: boolean } | undefined {
1524
- if (typeof message !== "object" || message === null) {
1525
- return undefined;
1298
+ if (wirePayload) {
1299
+ for (const notification of mapAgentWireEventPayloadToAcpSessionUpdates(wirePayload as never, id, {
1300
+ cwd: record.cwd,
1301
+ }))
1302
+ await this.#publishSessionUpdate(id, notification, adapter);
1526
1303
  }
1527
- record.liveMessageProgress ??= { textEmitted: false, thoughtEmitted: false };
1528
- return record.liveMessageProgress;
1304
+ if (event.type === "agent_end") await this.#emitEndOfTurnUpdates(id, adapter);
1305
+ if (activePrompt) this.#settlePrompt(record, activePrompt);
1529
1306
  }
1530
1307
 
1531
- #finishPrompt(record: ManagedSessionRecord, response?: PromptResponse, error?: unknown): void {
1532
- const promptTurn = record.promptTurn;
1533
- if (!promptTurn || promptTurn.settled) {
1534
- return;
1535
- }
1536
- promptTurn.settled = true;
1537
- promptTurn.unsubscribe?.();
1538
- // Keep the slot occupied until cancel cleanup finishes — `#runCancelCleanup`
1539
- // evicts the slot in its finally block once both flags say it's safe.
1540
- if (!promptTurn.cleanup && record.promptTurn === promptTurn) {
1541
- record.promptTurn = undefined;
1542
- }
1543
- if (error !== undefined) {
1544
- promptTurn.reject(error);
1308
+ #settlePrompt(record: SessionRecord, waiter: PromptWaiter): void {
1309
+ if (record.activePrompt !== waiter || !waiter.acknowledged || !waiter.pendingTerminal) return;
1310
+ if (correlationsConflict(waiter.correlation, waiter.pendingTerminal)) return;
1311
+ if (
1312
+ !correlationsMatch(waiter.correlation, waiter.pendingTerminal) &&
1313
+ !waiter.activityObserved &&
1314
+ !waiter.steeringAtAcknowledgement
1315
+ )
1545
1316
  return;
1546
- }
1547
- promptTurn.resolve(response ?? { stopReason: "end_turn" });
1548
- }
1549
-
1550
- #resolveStopReason(
1551
- event: Extract<AgentSessionEvent, { type: "agent_end" }>,
1552
- cancelRequested: boolean,
1553
- ): PromptResponse["stopReason"] {
1554
- if (cancelRequested) {
1555
- return "cancelled";
1556
- }
1557
- const lastAssistant = [...event.messages]
1558
- .reverse()
1559
- .find((message): message is AssistantMessage => message.role === "assistant");
1560
- const reason = lastAssistant?.stopReason;
1561
- switch (reason) {
1562
- case "aborted":
1563
- return "cancelled";
1564
- case "length":
1565
- return "max_tokens";
1566
- case "error": {
1567
- if (lastAssistant?.errorKind === "provider_safety_stop") {
1568
- return "refusal";
1569
- }
1570
- if (isLegacyProviderSafetyStopMessage(lastAssistant?.errorMessage ?? "")) {
1571
- return "refusal";
1572
- }
1573
- return "end_turn";
1574
- }
1575
- default:
1576
- return "end_turn";
1577
- }
1317
+ record.activePrompt = undefined;
1318
+ waiter.resolve({ stopReason: waiter.cancelRequested ? "cancelled" : "end_turn" });
1578
1319
  }
1579
1320
 
1580
- async #emitCommandOutput(record: ManagedSessionRecord, text: string): Promise<void> {
1581
- if (!text) {
1582
- return;
1321
+ async #emitEndOfTurnUpdates(id: string, adapter: AcpSdkAdapter): Promise<void> {
1322
+ let usage: JsonObject | undefined;
1323
+ try {
1324
+ const response = object(await adapter.query("context.get"));
1325
+ const result = object(response?.result) ?? response;
1326
+ usage = object(result?.usage);
1327
+ } catch {
1328
+ // Context usage is advisory ACP metadata; prompt completion remains authoritative.
1329
+ }
1330
+ if (typeof usage?.tokens === "number" && typeof usage.contextWindow === "number") {
1331
+ await this.#publishSessionUpdate(
1332
+ id,
1333
+ {
1334
+ sessionId: id,
1335
+ update: {
1336
+ sessionUpdate: "usage_update",
1337
+ size: usage.contextWindow,
1338
+ used: usage.tokens,
1339
+ },
1340
+ },
1341
+ adapter,
1342
+ );
1583
1343
  }
1584
- await this.#connection.sessionUpdate({
1585
- sessionId: record.session.sessionId,
1586
- update: {
1587
- sessionUpdate: "agent_message_chunk",
1588
- content: { type: "text", text },
1589
- messageId: crypto.randomUUID(),
1344
+ await this.#publishSessionUpdate(
1345
+ id,
1346
+ {
1347
+ sessionId: id,
1348
+ update: {
1349
+ sessionUpdate: "session_info_update",
1350
+ updatedAt: new Date().toISOString(),
1351
+ _meta: { gjcPhase: "idle", running: false, gjcRunning: false },
1352
+ },
1590
1353
  },
1591
- });
1592
- }
1593
-
1594
- #assertAbsoluteCwd(cwd: string): void {
1595
- if (!path.isAbsolute(cwd)) {
1596
- throw new Error(`ACP cwd must be absolute: ${cwd}`);
1597
- }
1598
- }
1599
-
1600
- /**
1601
- * Validate the cwd and reject cross-cwd calls against an already-committed scope,
1602
- * WITHOUT committing. Returns the canonical (resolved) cwd. Pair with
1603
- * {@link #commitCanonicalScope} after the lifecycle operation succeeds so a failed
1604
- * first new/load/resume/fork/list never pins the connection to a cwd it never served.
1605
- */
1606
- #stageCanonicalScope(cwd: string): string {
1607
- this.#assertAbsoluteCwd(cwd);
1608
- const canonical = path.resolve(cwd);
1609
- if (this.#canonicalCwdScope !== undefined && this.#canonicalCwdScope !== canonical) {
1610
- throw new Error(`ACP connection is scoped to ${this.#canonicalCwdScope}, not ${canonical}`);
1611
- }
1612
- return canonical;
1354
+ adapter,
1355
+ );
1613
1356
  }
1614
1357
 
1615
- /** Commit the canonical cwd scope on the first successful explicit-cwd call. */
1616
- #commitCanonicalScope(canonical: string): void {
1617
- if (this.#canonicalCwdScope === undefined) {
1618
- this.#canonicalCwdScope = canonical;
1358
+ async #publishSessionUpdate(
1359
+ id: string,
1360
+ notification: SessionNotification,
1361
+ expectedAdapter?: AcpSdkAdapter,
1362
+ ): Promise<void> {
1363
+ const record = this.#sessions.get(id);
1364
+ if (!record || (expectedAdapter && record.adapter !== expectedAdapter)) return;
1365
+ try {
1366
+ await this.#connection.sessionUpdate(notification);
1367
+ } catch (error) {
1368
+ const failure = this.#frameProcessingFailure(error);
1369
+ await this.#failSession(id, record.adapter, failure);
1370
+ throw failure;
1371
+ }
1372
+ }
1373
+
1374
+ async #sessionState(id: string): Promise<Pick<NewSessionResponse, "configOptions" | "modes">> {
1375
+ const record = this.#sessions.get(id);
1376
+ if (!record) throw new AcpSdkAdapterError("not_found", `Unsupported ACP session: ${id}`);
1377
+ const [config, models] = await Promise.all([
1378
+ record.adapter.query("config.list/get"),
1379
+ record.adapter.query("models.list/current"),
1380
+ ]);
1381
+ return acpSessionStateFromConfig(config, models);
1382
+ }
1383
+
1384
+ async #replaySession(id: string): Promise<void> {
1385
+ const adapter = this.#adapter(id);
1386
+ let cursor: string | undefined;
1387
+ let imageLimitationReported = false;
1388
+ for (let pageCount = 0; pageCount < MAX_ACP_REPLAY_PAGES; pageCount++) {
1389
+ const response = object(await adapter.query("transcript.list", {}, cursor));
1390
+ const result = object(response?.result) ?? response;
1391
+ const page = object(result?.page);
1392
+ for (const item of Array.isArray(page?.items) ? page.items : []) {
1393
+ const message = object(item);
1394
+ if (!message || (message.role !== "user" && message.role !== "assistant")) continue;
1395
+ const content = transcriptReplayContent(message);
1396
+ if (!imageLimitationReported) {
1397
+ imageLimitationReported = true;
1398
+ await this.#publishSessionUpdate(
1399
+ id,
1400
+ {
1401
+ sessionId: id,
1402
+ update: {
1403
+ sessionUpdate: "session_info_update",
1404
+ _meta: { gjcTranscriptImageReplay: content.images },
1405
+ },
1406
+ },
1407
+ adapter,
1408
+ );
1409
+ }
1410
+ const messageId = typeof message.id === "string" ? message.id : undefined;
1411
+ for (const block of content.blocks) {
1412
+ await this.#publishSessionUpdate(
1413
+ id,
1414
+ {
1415
+ sessionId: id,
1416
+ update: {
1417
+ sessionUpdate: message.role === "user" ? "user_message_chunk" : "agent_message_chunk",
1418
+ content: block,
1419
+ ...(messageId ? { messageId } : {}),
1420
+ },
1421
+ },
1422
+ adapter,
1423
+ );
1424
+ }
1425
+ }
1426
+ cursor = typeof page?.continuationCursor === "string" ? page.continuationCursor : undefined;
1427
+ if (!cursor) return;
1619
1428
  }
1429
+ throw new AcpSdkAdapterError("resource_exhausted", "ACP transcript replay exceeded the page limit.");
1620
1430
  }
1621
1431
 
1622
- /** Bump the generation and discard the authority snapshot on lifecycle changes. */
1623
- #invalidateAuthority(): void {
1624
- this.#authorityGeneration += 1;
1625
- this.#authoritySnapshot = undefined;
1432
+ #scheduleBootstrap(id: string): void {
1433
+ setTimeout(() => {
1434
+ const record = this.#sessions.get(id);
1435
+ if (!record || this.#connection.signal.aborted) return;
1436
+ void this.#publishSessionUpdate(
1437
+ id,
1438
+ {
1439
+ sessionId: id,
1440
+ update: {
1441
+ sessionUpdate: "session_info_update",
1442
+ _meta: { gjcPhase: "idle", running: false, gjcRunning: false },
1443
+ },
1444
+ },
1445
+ record.adapter,
1446
+ ).catch(() => undefined);
1447
+ }, ACP_BOOTSTRAP_RACE_GUARD_MS);
1626
1448
  }
1627
1449
 
1628
- #rejectIfTerminal(record: ManagedSessionRecord): void {
1629
- if (record.terminalState !== "active") {
1630
- throw new Error(`ACP session ${record.session.sessionId} is in terminal state: ${record.terminalState}`);
1631
- }
1450
+ #cursor(cursor: string | null | undefined): number {
1451
+ if (!cursor) return 0;
1452
+ const value = Number.parseInt(cursor, 10);
1453
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid ACP session cursor: ${cursor}`);
1454
+ return value;
1632
1455
  }
1633
1456
 
1634
- #rejectIfShuttingDown(): void {
1635
- if (this.#shuttingDown) {
1636
- throw new Error("ACP session lifecycle is unavailable during shutdown");
1637
- }
1457
+ #assertAbsoluteCwd(cwd: string): void {
1458
+ if (!path.isAbsolute(cwd)) throw new Error(`ACP cwd must be an absolute path: ${cwd}`);
1638
1459
  }
1639
1460
 
1640
- /**
1641
- * Build (or reuse) the complete authorization snapshot from a strict raw scoped
1642
- * inventory. The cached pagination snapshot is reused for display/list paging.
1643
- * Any inventory failure throws a sanitized error — it grants zero authority.
1644
- * The snapshot binds every discovered id to its exact-identity candidate or a
1645
- * conflict tombstone BEFORE pagination, so duplicates beyond page 1 are known
1646
- * before the first page response.
1647
- */
1648
- #buildAuthoritySnapshot(scope: string): AcpAuthoritySnapshot {
1649
- if (this.#authoritySnapshot?.scope === scope) {
1650
- return this.#authoritySnapshot;
1651
- }
1652
- return this.#rebuildAuthoritySnapshot(scope, true);
1461
+ #assertNoMcpServers(params: { mcpServers?: unknown[] }): void {
1462
+ if (params.mcpServers && params.mcpServers.length > 0)
1463
+ throw new AcpSdkAdapterError("unsupported", "MCP servers are unsupported under SDK-backed ACP.");
1653
1464
  }
1654
1465
 
1655
- /**
1656
- * Build a FRESH authorization snapshot, ignoring the cached pagination snapshot.
1657
- * Destructive authority (delete, inactive fork) must read storage at mutation
1658
- * time so an external create/remove/replace/duplicate between list and delete
1659
- * cannot yield a stale no-op or wrong mutation. The result is NOT cached: it
1660
- * must not poison the generation-aware pagination cursor contract.
1661
- */
1662
- #buildFreshAuthoritySnapshot(scope: string): AcpAuthoritySnapshot {
1663
- return this.#rebuildAuthoritySnapshot(scope, false);
1466
+ #beginDispose(): void {
1467
+ if (this.#disposePromise) return;
1468
+ this.#disposePromise = this.#dispose();
1469
+ // AbortSignal listeners cannot return a promise to their caller. Retain the
1470
+ // aggregate cleanup result while attaching a rejection handler so disposal
1471
+ // never creates a detached unhandled rejection.
1472
+ void this.#disposePromise.catch(() => undefined);
1664
1473
  }
1665
1474
 
1666
- #rebuildAuthoritySnapshot(scope: string, cache: boolean): AcpAuthoritySnapshot {
1667
- const inventory = SessionManager.inventorySessionsStrict(scope);
1668
- if (inventory.kind === "failure") {
1669
- const messages = inventory.failures.map(f => `${f.kind}: ${f.message}`);
1670
- throw new Error(`ACP scoped session inventory is incomplete: ${messages.join("; ")}`);
1671
- }
1672
- const entries = new Map<string, AcpAuthorityEntry>();
1673
- const orderedIds: string[] = [];
1674
- for (const candidate of inventory.candidates) {
1675
- const existing = entries.get(candidate.id);
1676
- if (existing) {
1677
- entries.set(candidate.id, { kind: "conflict", reason: "Duplicate session id in scoped inventory" });
1678
- } else {
1679
- entries.set(candidate.id, { kind: "candidate", candidate });
1680
- orderedIds.push(candidate.id);
1475
+ async #dispose(): Promise<void> {
1476
+ if (this.#disposed) return;
1477
+ this.#disposed = true;
1478
+ const failures: unknown[] = [];
1479
+ for (const id of [...this.#sessions.keys()]) {
1480
+ try {
1481
+ await this.#teardownSession(id, "connection closed", false);
1482
+ } catch (error) {
1483
+ failures.push(error);
1681
1484
  }
1682
1485
  }
1683
- const snapshot: AcpAuthoritySnapshot = {
1684
- scope,
1685
- entries,
1686
- orderedIds,
1687
- generation: this.#authorityGeneration,
1688
- };
1689
- if (cache) {
1690
- this.#authoritySnapshot = snapshot;
1691
- }
1692
- return snapshot;
1693
- }
1694
-
1695
- /**
1696
- * Delete an active (loaded) session via the per-record terminal state machine.
1697
- * Reserves `deleting` before the first await; concurrent deletes join the
1698
- * terminal promise. On failure the state transitions to `pre_dispatch_retryable`
1699
- * (close_failed_retryable), `cleanup_pending` (verified-delete partial), or
1700
- * `terminal_failure` (close_unknown / quiescence / unexpected) — all non-`active`
1701
- * states reject prompt/config/close/load/resume/fork. Only `pre_dispatch_retryable`
1702
- * and `cleanup_pending` permit a retry delete; `terminal_failure` never reopens.
1703
- */
1704
- async #deleteActiveSession(sessionId: string, record: ManagedSessionRecord): Promise<DeleteSessionResponse> {
1705
- // Concurrent delete joins the in-progress terminal promise.
1706
- if (record.terminalPromise) {
1707
- return await record.terminalPromise;
1708
- }
1709
- // terminal_failure: never reopen — share the settled error.
1710
- if (record.terminalState === "terminal_failure") {
1711
- throw record.terminalFailure ?? new Error(`ACP session ${sessionId} has a settled terminal failure`);
1486
+ this.#attaching.clear();
1487
+ this.#resolvingExisting.clear();
1488
+ this.#knownSessionCwds.clear();
1489
+ this.#tearingDown.clear();
1490
+ if (this.#broker) {
1491
+ const broker = this.#broker;
1492
+ this.#broker = undefined;
1493
+ try {
1494
+ await (await broker).adapter.close();
1495
+ } catch (error) {
1496
+ failures.push(error);
1497
+ }
1712
1498
  }
1713
- // Only active / pre_dispatch_retryable / cleanup_pending may start a delete.
1714
- if (record.terminalState === "deleting") {
1715
- throw new Error(`ACP session ${sessionId} is being deleted`);
1499
+ if (failures.length > 0) {
1500
+ const detail = failures
1501
+ .map(failure => (failure instanceof Error ? failure.message : String(failure)))
1502
+ .join("; ");
1503
+ throw aggregateAcpFailure("terminal_uncertain", `ACP connection cleanup is uncertain: ${detail}`, failures);
1716
1504
  }
1717
- record.terminalState = "deleting";
1718
- const scope = this.#canonicalCwdScope!;
1719
- const terminalPromise = (async (): Promise<DeleteSessionResponse> => {
1720
- try {
1721
- // Quiesce — failure is terminal (never swallow).
1722
- try {
1723
- await this.#quiesceForDelete(sessionId, record);
1724
- } catch (quiesceError) {
1725
- record.terminalState = "terminal_failure";
1726
- record.terminalFailure = quiesceError instanceof Error ? quiesceError : new Error(String(quiesceError));
1727
- throw quiesceError;
1728
- }
1729
- const manager = record.session.sessionManager;
1730
- // Strict close — outcome determines retryability.
1731
- const closeOutcome = await record.session.closeWriterStrict();
1732
- if (closeOutcome.kind !== "closed") {
1733
- if (closeOutcome.kind === "close_failed_retryable") {
1734
- record.terminalState = "pre_dispatch_retryable";
1735
- } else {
1736
- record.terminalState = "terminal_failure";
1737
- record.terminalFailure = closeOutcome.error;
1738
- }
1739
- const reason =
1740
- closeOutcome.kind === "close_unknown"
1741
- ? "writer close outcome is unknown"
1742
- : "writer close failed before dispatch";
1743
- throw new Error(`ACP session ${sessionId} cannot be deleted: ${reason}`);
1744
- }
1745
- // Recheck: the record must still own this exact session.
1746
- if (this.#sessions.get(sessionId) !== record || record.terminalState !== "deleting") {
1747
- record.terminalState = "terminal_failure";
1748
- record.terminalFailure = new Error(`ACP session ${sessionId} state changed during deletion`);
1749
- throw new Error(`ACP session ${sessionId} state changed during deletion`);
1750
- }
1751
- const sessionFile = manager.getSessionFile();
1752
- const sessionDir = manager.getSessionDir();
1753
- if (!sessionFile) {
1754
- record.terminalState = "terminal_failure";
1755
- record.terminalFailure = new Error(`ACP session ${sessionId} is not persisted and cannot be deleted`);
1756
- throw new Error(`ACP session ${sessionId} is not persisted and cannot be deleted`);
1757
- }
1758
- const snapshot = this.#buildFreshAuthoritySnapshot(scope);
1759
- const entry = snapshot.entries.get(sessionId);
1760
- if (entry?.kind !== "candidate") {
1761
- record.terminalState = "terminal_failure";
1762
- record.terminalFailure = new Error(`ACP session ${sessionId} is not an exact-one scoped candidate`);
1763
- throw new Error(`ACP session ${sessionId} is not an exact-one scoped candidate`);
1764
- }
1765
- if (entry.candidate.path !== sessionFile) {
1766
- record.terminalState = "terminal_failure";
1767
- record.terminalFailure = new Error(`ACP session ${sessionId} transcript path changed during deletion`);
1768
- throw new Error(`ACP session ${sessionId} transcript path changed during deletion`);
1769
- }
1770
- this.#assertPendingTranscriptIdentity(sessionId, entry.candidate.identity);
1771
- const sessionsRoot = path.dirname(sessionDir);
1772
- const target: VerifiedSessionDeleteTarget = {
1773
- sessionsRoot,
1774
- transcriptPath: sessionFile,
1775
- sessionId,
1776
- cwd: manager.getCwd(),
1777
- transcriptIdentity: {
1778
- dev: entry.candidate.identity.dev,
1779
- ino: entry.candidate.identity.ino,
1780
- },
1781
- ...this.#pendingArtifactsIdentity(sessionId),
1782
- };
1783
- const outcome = await manager.deleteSessionVerified(target);
1784
- if (outcome.kind !== "deleted") {
1785
- record.terminalState = "cleanup_pending";
1786
- this.#recordPendingDeleteEvidence(sessionId, outcome);
1787
- throw new Error(
1788
- `ACP session ${sessionId} delete is incomplete: ${outcome.phase} cleanup pending (${outcome.error.message})`,
1789
- );
1790
- }
1791
- // Fully deleted: clear retry evidence, remove from active map, dispose.
1792
- record.terminalState = "deleted";
1793
- this.#pendingDeleteEvidence.delete(sessionId);
1794
- this.#sessions.delete(sessionId);
1795
- this.#invalidateAuthority();
1796
- await this.#disposeSessionRecord(record);
1797
- return {};
1798
- } catch (error) {
1799
- // terminalState was set at the failure point above. For any
1800
- // unexpected error that left the state as `deleting`, treat it
1801
- // as terminal_failure so the record never silently reopens.
1802
- if (record.terminalState === "deleting") {
1803
- record.terminalState = "terminal_failure";
1804
- record.terminalFailure = error instanceof Error ? error : new Error(String(error));
1805
- }
1806
- record.terminalPromise = undefined;
1807
- this.#invalidateAuthority();
1808
- throw error;
1809
- }
1810
- })();
1811
- record.terminalPromise = terminalPromise;
1812
- return await terminalPromise;
1813
- }
1814
-
1815
- /**
1816
- * Delete an inactive (not currently loaded) session. The ID must have been
1817
- * issued by the last complete scoped authority snapshot; an absent/unissued ID
1818
- * returns lookup-free `{}` with NO inventory scan. Only after issuance may a
1819
- * fresh strict inventory revalidate the same exact candidate before mutation.
1820
- * A repeat-unknown (issued but gone from the fresh inventory) is also lookup-free `{}`.
1821
- */
1822
- async #deleteInactiveSession(sessionId: string): Promise<DeleteSessionResponse> {
1823
- const scope = this.#canonicalCwdScope!;
1824
- // Gate: only IDs issued by the last complete scoped authority snapshot
1825
- // may trigger a fresh inventory scan. Absent/unissued → lookup-free {}.
1826
- const issued = this.#authoritySnapshot?.entries.get(sessionId);
1827
- if (!issued) {
1828
- return {};
1829
- }
1830
- if (issued.kind === "conflict") {
1831
- throw new Error(`ACP session delete failed: ${issued.reason}`);
1832
- }
1833
- // Fresh strict inventory to revalidate the same exact candidate.
1834
- const snapshot = this.#buildFreshAuthoritySnapshot(scope);
1835
- const entry = snapshot.entries.get(sessionId);
1836
- if (!entry) {
1837
- // Repeat unknown: issued but no longer present in the fresh inventory.
1838
- return {};
1839
- }
1840
- if (entry.kind === "conflict") {
1841
- throw new Error(`ACP session delete failed: ${entry.reason}`);
1842
- }
1843
- const candidate = entry.candidate;
1844
- if (
1845
- candidate.path !== issued.candidate.path ||
1846
- candidate.cwd !== issued.candidate.cwd ||
1847
- candidate.identity.dev !== issued.candidate.identity.dev ||
1848
- candidate.identity.ino !== issued.candidate.identity.ino
1849
- ) {
1850
- throw new Error(`ACP session ${sessionId} changed since authority was issued`);
1851
- }
1852
- this.#assertPendingTranscriptIdentity(sessionId, candidate.identity);
1853
- const sessionsRoot = path.dirname(candidate.path);
1854
- const target: VerifiedSessionDeleteTarget = {
1855
- sessionsRoot,
1856
- transcriptPath: candidate.path,
1857
- sessionId,
1858
- cwd: candidate.cwd,
1859
- transcriptIdentity: { dev: candidate.identity.dev, ino: candidate.identity.ino },
1860
- ...this.#pendingArtifactsIdentity(sessionId),
1861
- };
1862
- const storage = new FileSessionStorage();
1863
- const outcome = await storage.deleteSessionVerified(target);
1864
- if (outcome.kind !== "deleted") {
1865
- this.#recordPendingDeleteEvidence(sessionId, outcome);
1866
- throw new Error(
1867
- `ACP session ${sessionId} delete is incomplete: ${outcome.phase} cleanup pending (${outcome.error.message})`,
1868
- );
1869
- }
1870
- this.#pendingDeleteEvidence.delete(sessionId);
1871
- this.#invalidateAuthority();
1872
- return {};
1873
- }
1874
-
1875
- #convertPromptBlocks(blocks: PromptRequest["prompt"]): { text: string; images: AgentImageContent[] } {
1876
- const textParts: string[] = [];
1877
- const images: AgentImageContent[] = [];
1878
- for (const block of blocks) {
1879
- switch (block.type) {
1880
- case "text":
1881
- textParts.push(block.text);
1882
- break;
1883
- case "image":
1884
- images.push({ type: "image", data: block.data, mimeType: block.mimeType });
1885
- break;
1886
- case "resource":
1887
- if ("text" in block.resource) {
1888
- textParts.push(block.resource.text);
1889
- } else if (typeof block.resource.mimeType === "string" && block.resource.mimeType.startsWith("image/")) {
1890
- // `embeddedContext: true` covers both text and blob resources, but
1891
- // blobs aren't directly consumable by the LLM. Route image blobs
1892
- // to the images array so the user's intent survives; everything
1893
- // else falls back to the URI placeholder below.
1894
- images.push({ type: "image", data: block.resource.blob, mimeType: block.resource.mimeType });
1895
- } else {
1896
- textParts.push(`[embedded resource: ${block.resource.uri}]`);
1897
- }
1898
- break;
1899
- case "resource_link":
1900
- textParts.push(block.title ?? block.name ?? block.uri);
1901
- break;
1902
- case "audio":
1903
- textParts.push("[audio omitted]");
1904
- break;
1905
- }
1906
- }
1907
- return {
1908
- text: textParts.join("\n\n").trim(),
1909
- images,
1910
- };
1911
- }
1912
-
1913
- async #pushConfigOptionUpdate(record: ManagedSessionRecord): Promise<void> {
1914
- await this.#connection.sessionUpdate({
1915
- sessionId: record.session.sessionId,
1916
- update: {
1917
- sessionUpdate: "config_option_update",
1918
- configOptions: this.#buildConfigOptions(record.session),
1919
- },
1920
- });
1921
- }
1922
-
1923
- #buildConfigOptions(session: AgentSession): SessionConfigOption[] {
1924
- const currentModeId = this.#getCurrentModeId(session);
1925
- const modeOptions = this.#getAvailableModes(session).map(mode => ({
1926
- value: mode.id,
1927
- name: mode.name,
1928
- description: mode.description,
1929
- }));
1930
- const configOptions: SessionConfigOption[] = [
1931
- {
1932
- id: MODE_CONFIG_ID,
1933
- name: "Mode",
1934
- category: "mode",
1935
- type: "select",
1936
- currentValue: currentModeId,
1937
- options: modeOptions,
1938
- },
1939
- ];
1940
-
1941
- const models = session.getAvailableModels();
1942
- const currentModel = session.model;
1943
- if (models.length > 0) {
1944
- configOptions.push({
1945
- id: MODEL_CONFIG_ID,
1946
- name: "Model",
1947
- category: "model",
1948
- type: "select",
1949
- currentValue: currentModel ? this.#toModelId(currentModel) : this.#toModelId(models[0]),
1950
- options: models.map(model => ({
1951
- value: this.#toModelId(model),
1952
- name: model.name,
1953
- description: `${model.provider}/${model.id}`,
1954
- })),
1955
- });
1956
- }
1957
-
1958
- configOptions.push({
1959
- id: THINKING_CONFIG_ID,
1960
- name: "Thinking",
1961
- category: "thought_level",
1962
- type: "select",
1963
- currentValue: this.#toThinkingConfigValue(session.thinkingLevel),
1964
- options: this.#buildThinkingOptions(session),
1965
- });
1966
- return configOptions;
1967
- }
1968
-
1969
- #buildThinkingOptions(session: AgentSession): Array<{ value: string; name: string; description?: string }> {
1970
- return [
1971
- { value: THINKING_OFF, name: "Off" },
1972
- ...session.getAvailableThinkingLevels().map(level => ({
1973
- value: level,
1974
- name: level,
1975
- })),
1976
- ];
1977
- }
1978
-
1979
- #toThinkingConfigValue(value: string | undefined): string {
1980
- return value && value !== "inherit" ? value : THINKING_OFF;
1981
- }
1982
-
1983
- async #setModelById(session: AgentSession, modelId: string): Promise<void> {
1984
- const model = session.getAvailableModels().find(candidate => this.#toModelId(candidate) === modelId);
1985
- if (!model) {
1986
- throw new Error(`Unknown ACP model: ${modelId}`);
1987
- }
1988
- await session.setModel(model);
1989
- }
1990
-
1991
- #setThinkingLevelById(session: AgentSession, value: string): void {
1992
- const thinkingLevel = parseThinkingLevel(value);
1993
- if (!thinkingLevel) {
1994
- throw new Error(`Unknown ACP thinking level: ${value}`);
1995
- }
1996
- session.setThinkingLevel(thinkingLevel);
1997
- }
1998
-
1999
- #toModelId(model: Model): string {
2000
- return `${model.provider}/${model.id}`;
2001
- }
2002
-
2003
- #getAvailableModes(session: AgentSession): Array<{ id: string; name: string; description: string }> {
2004
- const modes = [{ id: ACP_DEFAULT_MODE_ID, name: "Default", description: "Standard ACP headless mode" }];
2005
- if (session.settings.get("plan.enabled")) {
2006
- modes.push({
2007
- id: ACP_PLAN_MODE_ID,
2008
- name: "Plan",
2009
- description: "Read-only planning mode that drafts a plan to a markdown file before any code changes",
2010
- });
2011
- }
2012
- void session;
2013
- return modes;
2014
- }
2015
-
2016
- #getCurrentModeId(session: AgentSession): string {
2017
- return session.getPlanModeState()?.enabled ? ACP_PLAN_MODE_ID : ACP_DEFAULT_MODE_ID;
2018
- }
2019
-
2020
- #applyModeChange(session: AgentSession, modeId: string): void {
2021
- const availableModes = this.#getAvailableModes(session);
2022
- if (!availableModes.some(mode => mode.id === modeId)) {
2023
- throw new Error(`Unsupported ACP mode: ${modeId}`);
2024
- }
2025
- if (modeId === ACP_PLAN_MODE_ID) {
2026
- const previous = session.getPlanModeState();
2027
- session.setPlanModeState({
2028
- enabled: true,
2029
- planFilePath: previous?.planFilePath ?? DEFAULT_PLAN_FILE_URL,
2030
- workflow: previous?.workflow ?? "parallel",
2031
- reentry: previous !== undefined,
2032
- });
2033
- } else {
2034
- session.setPlanModeState(undefined);
2035
- }
2036
- }
2037
-
2038
- #buildModeState(session: AgentSession): SessionModeState {
2039
- return {
2040
- availableModes: this.#getAvailableModes(session),
2041
- currentModeId: this.#getCurrentModeId(session),
2042
- };
2043
- }
2044
-
2045
- #buildCurrentModeUpdate(session: AgentSession): SessionUpdate {
2046
- return {
2047
- sessionUpdate: "current_mode_update",
2048
- currentModeId: this.#getCurrentModeId(session),
2049
- };
2050
- }
2051
-
2052
- async #buildAvailableCommands(session: AgentSession): Promise<AvailableCommand[]> {
2053
- const commands: AvailableCommand[] = [];
2054
- const seenNames = new Set<string>();
2055
- const appendCommand = (command: AvailableCommand): void => {
2056
- if (seenNames.has(command.name)) {
2057
- return;
2058
- }
2059
- seenNames.add(command.name);
2060
- commands.push(command);
2061
- };
2062
-
2063
- // Advertise builtins first, then custom/user commands, then file-based
2064
- // slash commands, then namespaced `/skill:<name>` skills.
2065
- for (const command of ACP_BUILTIN_SLASH_COMMANDS) {
2066
- appendCommand(command);
2067
- }
2068
-
2069
- for (const command of session.customCommands) {
2070
- appendCommand({
2071
- name: command.command.name,
2072
- description: command.command.description,
2073
- input: { hint: "arguments" },
2074
- });
2075
- }
2076
-
2077
- for (const command of await loadSlashCommands({ cwd: session.sessionManager.getCwd() })) {
2078
- appendCommand({
2079
- name: command.name,
2080
- description: command.description,
2081
- });
2082
- }
2083
-
2084
- if (session.skillsSettings?.enableSkillCommands) {
2085
- for (const skill of session.skills) {
2086
- if (skill.hide === true) continue;
2087
- for (const name of getSkillSlashCommandNames(skill)) {
2088
- appendCommand({
2089
- name,
2090
- description: skill.description || `Run ${skill.name} skill`,
2091
- input: { hint: "arguments" },
2092
- });
2093
- }
2094
- }
2095
- }
2096
-
2097
- return commands;
2098
- }
2099
-
2100
- #toSessionInfo(session: StoredSessionInfo): SessionInfo {
2101
- return {
2102
- sessionId: session.id,
2103
- cwd: session.cwd,
2104
- title: session.title,
2105
- updatedAt: session.modified.toISOString(),
2106
- _meta: {
2107
- messageCount: session.messageCount,
2108
- size: session.size,
2109
- },
2110
- };
2111
- }
2112
-
2113
- #scheduleBootstrapUpdates(sessionId: string): void {
2114
- // Defer first notifications until the response has reached the client.
2115
- // Zed's agent-client-protocol reader dispatches responses and
2116
- // notifications to different async tasks; sending the first
2117
- // `available_commands_update` from `setTimeout(0)` reliably loses the
2118
- // race against the response handler and Zed logs `Received session
2119
- // notification for unknown session` then drops the update — leaving
2120
- // the slash-command palette empty (#1015 follow-up; see
2121
- // zed-industries/zed#55965 for the same race biting other ACP agents).
2122
- // `ACP_BOOTSTRAP_RACE_GUARD_MS` is invisible to the operator and large
2123
- // enough that the response future has scheduled before our timer fires
2124
- // on stdio-only transports.
2125
- //
2126
- // The session-lifetime subscription normally shares this guard so extension
2127
- // work cannot notify an unknown session id. prompt() may install it earlier:
2128
- // receipt of a prompt proves the client already owns the returned session id,
2129
- // and closes the cancellation window before this timer fires.
2130
- setTimeout(() => {
2131
- if (this.#connection.signal.aborted) {
2132
- return;
2133
- }
2134
- const record = this.#sessions.get(sessionId);
2135
- if (!record) {
2136
- return;
2137
- }
2138
- this.#ensureLifetimeSubscription(record);
2139
- void this.#emitBootstrapUpdates(sessionId, record);
2140
- }, ACP_BOOTSTRAP_RACE_GUARD_MS);
2141
- }
2142
-
2143
- async #emitBootstrapUpdates(sessionId: string, record: ManagedSessionRecord): Promise<void> {
2144
- if (this.#sessions.get(sessionId) !== record) {
2145
- return;
2146
- }
2147
- await this.#connection.sessionUpdate({
2148
- sessionId,
2149
- update: {
2150
- sessionUpdate: "available_commands_update",
2151
- availableCommands: await this.#buildAvailableCommands(record.session),
2152
- },
2153
- });
2154
- await this.#connection.sessionUpdate({
2155
- sessionId,
2156
- update: {
2157
- sessionUpdate: "session_info_update",
2158
- title: record.session.sessionName,
2159
- updatedAt: record.session.sessionManager.getHeader()?.timestamp,
2160
- },
2161
- });
2162
- }
2163
-
2164
- async #emitAvailableCommandsUpdate(record: ManagedSessionRecord): Promise<void> {
2165
- await this.#connection.sessionUpdate({
2166
- sessionId: record.session.sessionId,
2167
- update: {
2168
- sessionUpdate: "available_commands_update",
2169
- availableCommands: await this.#buildAvailableCommands(record.session),
2170
- },
2171
- });
2172
- }
2173
-
2174
- /** Ensure cancellation clears a previously emitted busy/compacting phase even if abort never produces an end event. */
2175
- async #emitCancelledPromptIdleUpdate(record: ManagedSessionRecord): Promise<void> {
2176
- if (this.#connection.signal.aborted) {
2177
- return;
2178
- }
2179
- try {
2180
- await this.#connection.sessionUpdate({
2181
- sessionId: record.session.sessionId,
2182
- update: {
2183
- sessionUpdate: "session_info_update",
2184
- title: record.session.sessionName,
2185
- updatedAt: new Date().toISOString(),
2186
- _meta: {
2187
- gjcPhase: "idle",
2188
- running: false,
2189
- gjcRunning: false,
2190
- },
2191
- },
2192
- });
2193
- } catch (error) {
2194
- logger.warn("Failed to emit cancelled ACP prompt idle update", { error });
2195
- }
2196
- }
2197
-
2198
- /**
2199
- * Reload plugin/registry state for an ACP session. Mirrors the interactive
2200
- * `/reload-plugins` and `/move` flows: invalidates the plugin-roots cache,
2201
- * resets the capability cache, refreshes the session's slash-command state,
2202
- * then re-advertises commands so the client sees newly installed/disabled
2203
- * plugins.
2204
- */
2205
- async #reloadPluginState(record: ManagedSessionRecord): Promise<void> {
2206
- const cwd = record.session.sessionManager.getCwd();
2207
- const projectPath = await resolveActiveProjectRegistryPath(cwd);
2208
- clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined);
2209
- resetCapabilities();
2210
- const fileCommands = await loadSlashCommands({ cwd });
2211
- record.session.setSlashCommands(fileCommands);
2212
- await record.session.refreshSshTool({ activateIfAvailable: true });
2213
- await this.#emitAvailableCommandsUpdate(record);
2214
- }
2215
-
2216
- async #emitEndOfTurnUpdates(record: ManagedSessionRecord): Promise<void> {
2217
- const sessionId = record.session.sessionId;
2218
-
2219
- const contextUsage = record.session.getContextUsage();
2220
- if (contextUsage?.tokens != null) {
2221
- const usageStats = record.session.sessionManager.getUsageStatistics();
2222
- await this.#connection.sessionUpdate({
2223
- sessionId,
2224
- update: {
2225
- sessionUpdate: "usage_update",
2226
- size: contextUsage.contextWindow,
2227
- used: contextUsage.tokens,
2228
- cost: usageStats.cost > 0 ? { amount: usageStats.cost, currency: "USD" } : undefined,
2229
- },
2230
- });
2231
- }
2232
-
2233
- await this.#connection.sessionUpdate({
2234
- sessionId,
2235
- update: {
2236
- sessionUpdate: "session_info_update",
2237
- title: record.session.sessionName,
2238
- updatedAt: new Date().toISOString(),
2239
- _meta: {
2240
- gjcPhase: "idle",
2241
- running: false,
2242
- gjcRunning: false,
2243
- },
2244
- },
2245
- });
2246
- }
2247
-
2248
- #cloneUsageStatistics(usage: UsageStatistics): UsageStatistics {
2249
- return {
2250
- input: usage.input,
2251
- output: usage.output,
2252
- cacheRead: usage.cacheRead,
2253
- cacheWrite: usage.cacheWrite,
2254
- premiumRequests: usage.premiumRequests,
2255
- cost: usage.cost,
2256
- };
2257
- }
2258
-
2259
- #buildTurnUsage(previous: UsageStatistics, current: UsageStatistics): Usage | undefined {
2260
- const inputTokens = Math.max(0, current.input - previous.input);
2261
- const outputTokens = Math.max(0, current.output - previous.output);
2262
- const cachedReadTokens = Math.max(0, current.cacheRead - previous.cacheRead);
2263
- const cachedWriteTokens = Math.max(0, current.cacheWrite - previous.cacheWrite);
2264
- const totalTokens = inputTokens + outputTokens + cachedReadTokens + cachedWriteTokens;
2265
-
2266
- if (totalTokens === 0) {
2267
- return undefined;
2268
- }
2269
-
2270
- const usage: Usage = {
2271
- inputTokens,
2272
- outputTokens,
2273
- totalTokens,
2274
- };
2275
- if (cachedReadTokens > 0) {
2276
- usage.cachedReadTokens = cachedReadTokens;
2277
- }
2278
- if (cachedWriteTokens > 0) {
2279
- usage.cachedWriteTokens = cachedWriteTokens;
2280
- }
2281
- return usage;
2282
- }
2283
-
2284
- async #listStoredSessions(cwd?: string): Promise<StoredSessionInfo[]> {
2285
- const sessions = cwd ? await SessionManager.list(cwd) : await SessionManager.listAll();
2286
- return sessions.sort((left, right) => right.modified.getTime() - left.modified.getTime());
2287
- }
2288
-
2289
- #parseCursor(cursor: string | undefined): number {
2290
- if (!cursor) {
2291
- return 0;
2292
- }
2293
- const parsed = Number.parseInt(cursor, 10);
2294
- if (!Number.isFinite(parsed) || parsed < 0) {
2295
- throw new Error(`Invalid ACP session cursor: ${cursor}`);
2296
- }
2297
- return parsed;
2298
- }
2299
-
2300
- /**
2301
- * Parse a scoped cursor of the form `${generation}:${offset}` and reject it when
2302
- * its embedded generation no longer matches the current authority generation.
2303
- * Unlike the cwd-less display cursor, the scoped cursor binds the page offset to
2304
- * the exact authority snapshot that produced it, so a lifecycle invalidation
2305
- * between pages is observable instead of silently rebuilt.
2306
- */
2307
- #parseScopedCursor(cursor: string | undefined, currentGeneration: number): number {
2308
- if (!cursor) {
2309
- return 0;
2310
- }
2311
- const separator = cursor.indexOf(":");
2312
- if (separator <= 0) {
2313
- throw new Error(`Invalid ACP session cursor: ${cursor}`);
2314
- }
2315
- const generation = Number.parseInt(cursor.slice(0, separator), 10);
2316
- const offset = Number.parseInt(cursor.slice(separator + 1), 10);
2317
- if (!Number.isFinite(generation) || generation < 0 || !Number.isFinite(offset) || offset < 0) {
2318
- throw new Error(`Invalid ACP session cursor: ${cursor}`);
2319
- }
2320
- if (generation !== currentGeneration) {
2321
- throw new Error("ACP session list cursor is stale; the scoped session inventory changed");
2322
- }
2323
- return offset;
2324
- }
2325
-
2326
- async #replaySessionHistory(record: ManagedSessionRecord): Promise<void> {
2327
- const cwd = record.session.sessionManager.getCwd();
2328
- const replayedToolCallIds = new Set<string>();
2329
- const replayedToolCallArgs = new Map<string, unknown>();
2330
- for (const message of record.session.sessionManager.buildSessionContext().messages as ReplayableMessage[]) {
2331
- for (const notification of this.#messageToReplayNotifications(
2332
- record.session.sessionId,
2333
- message,
2334
- cwd,
2335
- replayedToolCallIds,
2336
- replayedToolCallArgs,
2337
- )) {
2338
- await this.#connection.sessionUpdate(notification);
2339
- }
2340
- }
2341
- }
2342
-
2343
- #messageToReplayNotifications(
2344
- sessionId: string,
2345
- message: ReplayableMessage,
2346
- cwd: string,
2347
- replayedToolCallIds: Set<string>,
2348
- replayedToolCallArgs: Map<string, unknown>,
2349
- ): SessionNotification[] {
2350
- if (message.role === "assistant") {
2351
- return this.#replayAssistantMessage(sessionId, message, cwd, replayedToolCallIds, replayedToolCallArgs);
2352
- }
2353
- if (
2354
- message.role === "user" ||
2355
- message.role === "developer" ||
2356
- message.role === "custom" ||
2357
- message.role === "hookMessage"
2358
- ) {
2359
- return this.#wrapReplayContent(
2360
- sessionId,
2361
- this.#extractReplayContent(message.content, undefined),
2362
- "user_message_chunk",
2363
- crypto.randomUUID(),
2364
- );
2365
- }
2366
- if (
2367
- message.role === "toolResult" &&
2368
- typeof message.toolCallId === "string" &&
2369
- typeof message.toolName === "string"
2370
- ) {
2371
- return this.#replayToolResult(
2372
- sessionId,
2373
- cwd,
2374
- {
2375
- ...message,
2376
- toolCallId: message.toolCallId,
2377
- toolName: message.toolName,
2378
- },
2379
- {
2380
- includeStart: !replayedToolCallIds.has(message.toolCallId),
2381
- toolArgs: replayedToolCallArgs.get(message.toolCallId),
2382
- },
2383
- );
2384
- }
2385
- if (
2386
- message.role === "bashExecution" ||
2387
- message.role === "pythonExecution" ||
2388
- message.role === "compactionSummary"
2389
- ) {
2390
- return this.#wrapReplayContent(
2391
- sessionId,
2392
- this.#extractReplayContent(message.content, undefined),
2393
- "user_message_chunk",
2394
- crypto.randomUUID(),
2395
- );
2396
- }
2397
- return [];
2398
- }
2399
-
2400
- #replayAssistantMessage(
2401
- sessionId: string,
2402
- message: ReplayableMessage,
2403
- cwd: string,
2404
- replayedToolCallIds: Set<string>,
2405
- replayedToolCallArgs: Map<string, unknown>,
2406
- ): SessionNotification[] {
2407
- const notifications: SessionNotification[] = [];
2408
- const messageId = crypto.randomUUID();
2409
- if (Array.isArray(message.content)) {
2410
- for (const item of message.content) {
2411
- if (typeof item !== "object" || item === null || !("type" in item)) {
2412
- continue;
2413
- }
2414
- if (item.type === "text" && "text" in item && typeof item.text === "string" && item.text.length > 0) {
2415
- notifications.push({
2416
- sessionId,
2417
- update: {
2418
- sessionUpdate: "agent_message_chunk",
2419
- content: { type: "text", text: item.text },
2420
- messageId,
2421
- },
2422
- });
2423
- continue;
2424
- }
2425
- if (
2426
- item.type === "thinking" &&
2427
- "thinking" in item &&
2428
- typeof item.thinking === "string" &&
2429
- item.thinking.length > 0
2430
- ) {
2431
- notifications.push({
2432
- sessionId,
2433
- update: {
2434
- sessionUpdate: "agent_thought_chunk",
2435
- content: { type: "text", text: item.thinking },
2436
- messageId,
2437
- },
2438
- });
2439
- continue;
2440
- }
2441
- const toolItem = item as ReplayableToolItem;
2442
- if (
2443
- (toolItem.type === "toolCall" || toolItem.type === "tool_use") &&
2444
- typeof toolItem.id === "string" &&
2445
- typeof toolItem.name === "string"
2446
- ) {
2447
- const args = this.#buildReplayAssistantToolArgs(toolItem);
2448
- const update = buildToolCallStartUpdate({
2449
- toolCallId: toolItem.id,
2450
- toolName: toolItem.name,
2451
- args,
2452
- status: "completed",
2453
- cwd,
2454
- });
2455
- notifications.push({ sessionId, update });
2456
- replayedToolCallIds.add(toolItem.id);
2457
- replayedToolCallArgs.set(toolItem.id, args);
2458
- }
2459
- }
2460
- }
2461
- if (notifications.length === 0 && message.errorMessage && !isSilentAbort(message.errorMessage)) {
2462
- notifications.push({
2463
- sessionId,
2464
- update: {
2465
- sessionUpdate: "agent_message_chunk",
2466
- content: { type: "text", text: message.errorMessage },
2467
- messageId,
2468
- },
2469
- });
2470
- }
2471
- return notifications;
2472
- }
2473
-
2474
- #buildReplayAssistantToolArgs(item: ReplayableToolItem): unknown {
2475
- if ("arguments" in item) {
2476
- return normalizeReplayToolArguments(item.arguments).args;
2477
- }
2478
- if (item.type === "tool_use" && "input" in item) {
2479
- return item.input;
2480
- }
2481
- return {};
2482
- }
2483
-
2484
- #replayToolResult(
2485
- sessionId: string,
2486
- cwd: string,
2487
- message: Required<Pick<ReplayableMessage, "toolCallId" | "toolName">> & ReplayableMessage,
2488
- options: { includeStart?: boolean; toolArgs?: unknown } = {},
2489
- ): SessionNotification[] {
2490
- const args = this.#buildReplayToolArgs(message.details);
2491
- const startEvent: AgentSessionEvent = {
2492
- type: "tool_execution_start",
2493
- toolCallId: message.toolCallId,
2494
- toolName: message.toolName,
2495
- args,
2496
- };
2497
- const endEvent: AgentSessionEvent = {
2498
- type: "tool_execution_end",
2499
- toolCallId: message.toolCallId,
2500
- toolName: message.toolName,
2501
- isError: message.isError === true,
2502
- result: {
2503
- content: message.content,
2504
- details: message.details,
2505
- errorMessage: message.errorMessage,
2506
- },
2507
- };
2508
- const notifications = mapAgentWireEventPayloadToAcpSessionUpdates(toAgentWireEventPayload(endEvent), sessionId, {
2509
- cwd,
2510
- getToolArgs: toolCallId => (toolCallId === message.toolCallId ? options.toolArgs : undefined),
2511
- });
2512
- if (options.includeStart === false) {
2513
- return notifications;
2514
- }
2515
- return [
2516
- ...mapAgentWireEventPayloadToAcpSessionUpdates(toAgentWireEventPayload(startEvent), sessionId, { cwd }),
2517
- ...notifications,
2518
- ];
2519
- }
2520
-
2521
- #buildReplayToolArgs(details: unknown): { path?: string } {
2522
- if (typeof details !== "object" || details === null || !("path" in details)) {
2523
- return {};
2524
- }
2525
- const value = (details as { path?: unknown }).path;
2526
- return typeof value === "string" && value.length > 0 ? { path: value } : {};
2527
- }
2528
-
2529
- #wrapReplayContent(
2530
- sessionId: string,
2531
- content: PromptRequest["prompt"],
2532
- kind: "agent_message_chunk" | "user_message_chunk",
2533
- messageId: string,
2534
- ): SessionNotification[] {
2535
- return content.map(block => ({
2536
- sessionId,
2537
- update: {
2538
- sessionUpdate: kind,
2539
- content: block,
2540
- messageId,
2541
- },
2542
- }));
2543
- }
2544
-
2545
- #extractReplayContent(content: unknown, errorMessage: string | undefined): PromptRequest["prompt"] {
2546
- const replay: PromptRequest["prompt"] = [];
2547
- if (Array.isArray(content)) {
2548
- for (const item of content) {
2549
- if (typeof item !== "object" || item === null || !("type" in item)) {
2550
- continue;
2551
- }
2552
- if (item.type === "text" && "text" in item && typeof item.text === "string" && item.text.length > 0) {
2553
- replay.push({ type: "text", text: item.text });
2554
- continue;
2555
- }
2556
- if (
2557
- item.type === "image" &&
2558
- "data" in item &&
2559
- "mimeType" in item &&
2560
- typeof item.data === "string" &&
2561
- typeof item.mimeType === "string"
2562
- ) {
2563
- replay.push({ type: "image", data: item.data, mimeType: item.mimeType });
2564
- }
2565
- }
2566
- }
2567
- if (replay.length === 0 && errorMessage) {
2568
- replay.push({ type: "text", text: errorMessage });
2569
- }
2570
- return replay;
2571
- }
2572
-
2573
- async #configureExtensions(record: ManagedSessionRecord): Promise<void> {
2574
- if (record.extensionsConfigured) {
2575
- return;
2576
- }
2577
-
2578
- const extensionRunner = record.session.extensionRunner;
2579
- if (!extensionRunner) {
2580
- record.extensionsConfigured = true;
2581
- return;
2582
- }
2583
-
2584
- extensionRunner.initialize(
2585
- {
2586
- sendMessage: (message, options) => {
2587
- record.session.sendCustomMessage(message, options).catch((error: unknown) => {
2588
- logger.warn("ACP extension sendMessage failed", { error });
2589
- });
2590
- },
2591
- sendUserMessage: (content, options) => {
2592
- record.session.sendUserMessage(content, options).catch((error: unknown) => {
2593
- logger.warn("ACP extension sendUserMessage failed", { error });
2594
- });
2595
- },
2596
- appendEntry: (customType, data) => {
2597
- record.session.sessionManager.appendCustomEntry(customType, data);
2598
- },
2599
- setLabel: (targetId, label) => {
2600
- record.session.sessionManager.appendLabelChange(targetId, label);
2601
- },
2602
- getActiveTools: () => record.session.getActiveToolNames(),
2603
- getAllTools: () => record.session.getAllToolNames(),
2604
- setActiveTools: toolNames => record.session.setActiveToolsByName(toolNames),
2605
- getCommands: () => getSessionSlashCommands(record.session),
2606
- setModel: async model => {
2607
- const apiKey = await record.session.modelRegistry.getApiKey(model);
2608
- if (!apiKey) {
2609
- return false;
2610
- }
2611
- await record.session.setModel(model);
2612
- return true;
2613
- },
2614
- getThinkingLevel: () => record.session.thinkingLevel,
2615
- setThinkingLevel: level => record.session.setThinkingLevel(level),
2616
- getSessionName: () => record.session.sessionManager.getSessionName(),
2617
- setSessionName: async name => {
2618
- await record.session.sessionManager.setSessionName(name, "user");
2619
- },
2620
- },
2621
- {
2622
- getModel: () => record.session.model,
2623
- isIdle: () => !record.session.isStreaming,
2624
- abort: () => {
2625
- void record.session.abort();
2626
- },
2627
- hasPendingMessages: () => record.session.queuedMessageCount > 0,
2628
- shutdown: () => {},
2629
- getContextUsage: () => record.session.getContextUsage(),
2630
- getSystemPrompt: () => record.session.systemPrompt,
2631
- compact: instructionsOrOptions => runExtensionCompact(record.session, instructionsOrOptions),
2632
- },
2633
- {
2634
- getContextUsage: () => record.session.getContextUsage(),
2635
- waitForIdle: () => record.session.agent.waitForIdle(),
2636
- newSession: async options => {
2637
- const success = await record.session.newSession({ parentSession: options?.parentSession });
2638
- if (success && options?.setup) {
2639
- await options.setup(record.session.sessionManager);
2640
- }
2641
- return { cancelled: !success };
2642
- },
2643
- branch: async entryId => {
2644
- const result = await record.session.branch(entryId);
2645
- return { cancelled: result.cancelled };
2646
- },
2647
- navigateTree: async (targetId, options) => {
2648
- const result = await record.session.navigateTree(targetId, { summarize: options?.summarize });
2649
- return { cancelled: result.cancelled };
2650
- },
2651
- switchSession: async sessionPath => {
2652
- const success = await record.session.switchSession(sessionPath);
2653
- return { cancelled: !success };
2654
- },
2655
- reload: async () => {
2656
- await record.session.reload();
2657
- },
2658
- compact: instructionsOrOptions => runExtensionCompact(record.session, instructionsOrOptions),
2659
- },
2660
- // Per-session getter: `record.session.sessionId` reads through to
2661
- // `sessionManager.getSessionId()` (it's a getter, not a field), so an
2662
- // extension command that calls `ctx.newSession` / `ctx.switchSession`
2663
- // — both exposed in the block just above — mutates the underlying id
2664
- // mid-flight. Reading lazily on each elicitation matches every other
2665
- // `sessionUpdate` call in this file. Hoisting the factory to an
2666
- // `AcpAgent` field would still be wrong because it would also lose
2667
- // the per-`record` binding.
2668
- createAcpExtensionUiContext(this.#connection, () => record.session.sessionId, this.#clientCapabilities),
2669
- );
2670
- await extensionRunner.emit({ type: "session_start" });
2671
- record.extensionsConfigured = true;
2672
- }
2673
-
2674
- async #configureMcpServers(record: ManagedSessionRecord, servers: McpServer[]): Promise<void> {
2675
- if (record.mcpManager) {
2676
- await record.mcpManager.disconnectAll();
2677
- }
2678
- if (servers.length === 0) {
2679
- record.mcpManager = undefined;
2680
- await record.session.refreshMCPTools([]);
2681
- return;
2682
- }
2683
-
2684
- const manager = new MCPManager(record.session.sessionManager.getCwd());
2685
- const configs: MCPConfigMap = {};
2686
- const sources: MCPSourceMap = {};
2687
- for (const server of servers) {
2688
- configs[server.name] = this.#toMcpConfig(server);
2689
- sources[server.name] = {
2690
- provider: "acp",
2691
- providerName: "ACP Client",
2692
- path: `acp://${server.name}`,
2693
- level: "project",
2694
- };
2695
- }
2696
-
2697
- const result = await manager.connectServers(configs, sources);
2698
- if (result.errors.size > 0) {
2699
- throw new Error(
2700
- Array.from(result.errors.entries())
2701
- .map(([name, message]) => `${name}: ${message}`)
2702
- .join("; "),
2703
- );
2704
- }
2705
-
2706
- record.mcpManager = manager;
2707
- await record.session.refreshMCPTools(result.tools);
2708
- }
2709
-
2710
- #toMcpConfig(server: McpServer): MCPServerConfig {
2711
- if ("command" in server) {
2712
- return {
2713
- type: "stdio",
2714
- command: server.command,
2715
- args: server.args,
2716
- env: this.#toNameValueMap(server.env),
2717
- };
2718
- }
2719
- if (server.type === "http") {
2720
- return {
2721
- type: "http",
2722
- url: server.url,
2723
- headers: this.#toNameValueMap(server.headers),
2724
- };
2725
- }
2726
- if (server.type === "sse") {
2727
- return {
2728
- type: "sse",
2729
- url: server.url,
2730
- headers: this.#toNameValueMap(server.headers),
2731
- };
2732
- }
2733
- throw new Error(`Unsupported ACP MCP transport: ${server.type}`);
2734
- }
2735
-
2736
- #toNameValueMap(values: Array<{ name: string; value: string }>): { [name: string]: string } {
2737
- const mapped: { [name: string]: string } = {};
2738
- for (const value of values) {
2739
- mapped[value.name] = value.value;
2740
- }
2741
- return mapped;
2742
- }
2743
-
2744
- async #closeManagedSession(sessionId: string, record: ManagedSessionRecord): Promise<void> {
2745
- this.#sessions.delete(sessionId);
2746
- await this.#cancelPromptForClose(record);
2747
- await this.#disposeSessionRecord(record);
2748
- }
2749
-
2750
- async #cancelPromptForClose(record: ManagedSessionRecord): Promise<void> {
2751
- const promptTurn = record.promptTurn;
2752
- if (!isPromptTurnInFlight(promptTurn)) {
2753
- return;
2754
- }
2755
- const cleanup = promptTurn.cleanup ?? this.#beginCancelCleanup(record, promptTurn);
2756
- try {
2757
- await cleanup;
2758
- } catch (error) {
2759
- logger.warn("Failed to abort ACP prompt during session close", { error });
2760
- }
2761
- }
2762
- /**
2763
- * Delete-specific strict prompt quiescence barrier. Mirrors the cancel setup of
2764
- * {@link #cancelPromptForClose} but NEVER swallows abort/timeout failures, then
2765
- * waits for full idle + async-delivery drain. A quiescence failure blocks the
2766
- * destructive mutation that follows — it is not catch-and-logged. The best-effort
2767
- * close path is preserved separately for nondestructive shutdown.
2768
- */
2769
- async #quiesceForDelete(sessionId: string, record: ManagedSessionRecord): Promise<void> {
2770
- const promptTurn = record.promptTurn;
2771
- if (isPromptTurnInFlight(promptTurn)) {
2772
- const cleanup = promptTurn.cleanup ?? this.#beginCancelCleanup(record, promptTurn);
2773
- await cleanup;
2774
- }
2775
- await this.#waitForAcpPromptIdle(record);
2776
- // Explicit post-drain status check: the drain loop's boolean return is
2777
- // not trusted as proof. Treat a drain false/timeout/no-progress/max-pass
2778
- // as unproven and verify the owner-scoped delivery state directly. Any
2779
- // remaining queued/delivering work blocks the destructive mutation.
2780
- const delivery = record.session.getAsyncDeliveryStateForAcp();
2781
- if (delivery.queued > 0 || delivery.delivering) {
2782
- throw new Error(`ACP session ${sessionId} cannot be deleted: async delivery is not quiesced`);
2783
- }
2784
- }
2785
-
2786
- /**
2787
- * Record phase-aware same-connection retry evidence for an incomplete verified
2788
- * delete. The transcript identity (carried by every cleanup_pending) is always
2789
- * recorded so a retry can reject a replaced transcript; the artifacts identity
2790
- * is recorded only at the artifacts phase.
2791
- */
2792
- #recordPendingDeleteEvidence(sessionId: string, result: VerifiedSessionDeleteResult): void {
2793
- if (result.kind !== "cleanup_pending") {
2794
- return;
2795
- }
2796
- if (result.phase === "artifacts") {
2797
- this.#pendingDeleteEvidence.set(sessionId, {
2798
- transcriptIdentity: result.transcriptIdentity,
2799
- artifactsIdentity: result.artifactsIdentity,
2800
- });
2801
- } else {
2802
- // Transcript phase: artifacts were already removed successfully, so only
2803
- // the transcript identity remains for retry binding.
2804
- this.#pendingDeleteEvidence.set(sessionId, {
2805
- transcriptIdentity: result.transcriptIdentity,
2806
- });
2807
- }
2808
- }
2809
-
2810
- /** Build the optional `expectedArtifactsIdentity` field for a retry target. */
2811
- #pendingArtifactsIdentity(sessionId: string): { expectedArtifactsIdentity?: SessionStorageFileIdentity } {
2812
- const pending = this.#pendingDeleteEvidence.get(sessionId);
2813
- return pending?.artifactsIdentity ? { expectedArtifactsIdentity: pending.artifactsIdentity } : {};
2814
- }
2815
-
2816
- /**
2817
- * Phase-aware retry gate: a preserved transcript identity must match the fresh
2818
- * candidate's transcript identity before that candidate can authorize a
2819
- * replacement. A mismatch (the transcript was externally replaced between the
2820
- * cleanup_pending failure and the retry) fails closed — it never authorizes a
2821
- * wrong mutation.
2822
- */
2823
- #assertPendingTranscriptIdentity(sessionId: string, candidateIdentity: { dev: bigint; ino: bigint }): void {
2824
- const pending = this.#pendingDeleteEvidence.get(sessionId);
2825
- if (
2826
- pending &&
2827
- (pending.transcriptIdentity.dev !== candidateIdentity.dev ||
2828
- pending.transcriptIdentity.ino !== candidateIdentity.ino)
2829
- ) {
2830
- throw new Error(
2831
- `ACP session ${sessionId} transcript identity changed since pending cleanup; replacement rejected`,
2832
- );
2833
- }
2834
- }
2835
-
2836
- async #disposeSessionRecord(record: ManagedSessionRecord): Promise<void> {
2837
- record.lifetimeUnsubscribe?.();
2838
- if (record.mcpManager) {
2839
- try {
2840
- await record.mcpManager.disconnectAll();
2841
- } catch (error) {
2842
- logger.warn("Failed to disconnect ACP MCP servers", { error });
2843
- }
2844
- record.mcpManager = undefined;
2845
- }
2846
- try {
2847
- await record.session.dispose();
2848
- } catch (error) {
2849
- logger.warn("Failed to dispose ACP session", { error });
2850
- }
2851
- }
2852
-
2853
- async #disposeStandaloneSession(session: AgentSession): Promise<void> {
2854
- try {
2855
- await session.dispose();
2856
- } catch (error) {
2857
- logger.warn("Failed to dispose ACP session", { error });
2858
- }
2859
- }
2860
-
2861
- async #disposeAllSessions(): Promise<void> {
2862
- if (this.#disposePromise) {
2863
- await this.#disposePromise;
2864
- return;
2865
- }
2866
-
2867
- this.#disposePromise = (async () => {
2868
- const records = Array.from(this.#sessions.entries());
2869
- this.#sessions.clear();
2870
- await Promise.all(
2871
- records.map(async ([sessionId, record]) => {
2872
- try {
2873
- await this.#cancelPromptForClose(record);
2874
- await this.#disposeSessionRecord(record);
2875
- } catch (error) {
2876
- logger.warn("Failed to clean up ACP session", { sessionId, error });
2877
- }
2878
- }),
2879
- );
2880
-
2881
- const initialSession = this.#initialSession;
2882
- this.#initialSession = undefined;
2883
- if (initialSession) {
2884
- await this.#disposeStandaloneSession(initialSession);
2885
- }
2886
- })();
2887
-
2888
- await this.#disposePromise;
2889
1505
  }
2890
1506
  }