@gajae-code/coding-agent 0.13.3 → 0.14.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 (599) hide show
  1. package/CHANGELOG.md +184 -2
  2. package/README.md +2 -0
  3. package/bin/gajaessi.js +4 -0
  4. package/dist/types/async/job-manager.d.ts +55 -2
  5. package/dist/types/async/support.d.ts +14 -0
  6. package/dist/types/capability/mcp.d.ts +6 -0
  7. package/dist/types/capability/types.d.ts +6 -0
  8. package/dist/types/cli/accounts-cli.d.ts +19 -0
  9. package/dist/types/cli/args.d.ts +3 -0
  10. package/dist/types/cli/config-cli.d.ts +6 -0
  11. package/dist/types/cli/crash-cli.d.ts +10 -0
  12. package/dist/types/cli/customize-doctor.d.ts +145 -0
  13. package/dist/types/cli/daemon-cli.d.ts +3 -1
  14. package/dist/types/cli/mcp-cli.d.ts +11 -0
  15. package/dist/types/cli/notify-cli.d.ts +1 -1
  16. package/dist/types/cli/root-flags.d.ts +12 -0
  17. package/dist/types/cli/setup-cli.d.ts +3 -1
  18. package/dist/types/cli/skills-cli.d.ts +2 -1
  19. package/dist/types/cli/update-cli.d.ts +19 -1
  20. package/dist/types/cli.d.ts +6 -0
  21. package/dist/types/commands/accounts.d.ts +40 -0
  22. package/dist/types/commands/crash.d.ts +24 -0
  23. package/dist/types/commands/customize.d.ts +22 -0
  24. package/dist/types/commands/daemon.d.ts +1 -1
  25. package/dist/types/commands/launch.d.ts +6 -0
  26. package/dist/types/commands/setup.d.ts +6 -0
  27. package/dist/types/commands/skills.d.ts +6 -1
  28. package/dist/types/commands/update.d.ts +7 -0
  29. package/dist/types/config/atomic-yaml-patch-worker.d.ts +25 -0
  30. package/dist/types/config/atomic-yaml-patch.d.ts +62 -2
  31. package/dist/types/config/config-schema-version.d.ts +10 -0
  32. package/dist/types/config/file-lock.d.ts +18 -0
  33. package/dist/types/config/model-profile-activation.d.ts +3 -1
  34. package/dist/types/config/model-registry.d.ts +9 -1
  35. package/dist/types/config/models-config-schema.d.ts +2 -0
  36. package/dist/types/config/settings-schema.d.ts +70 -6
  37. package/dist/types/config/settings.d.ts +63 -0
  38. package/dist/types/config/skill-settings-defaults.d.ts +26 -0
  39. package/dist/types/config/task-delegation.d.ts +39 -0
  40. package/dist/types/config/update-channel.d.ts +10 -0
  41. package/dist/types/coordinator-mcp/server.d.ts +5 -1
  42. package/dist/types/crash/index-store.d.ts +87 -0
  43. package/dist/types/crash/nudge.d.ts +50 -0
  44. package/dist/types/crash/record-loader.d.ts +17 -0
  45. package/dist/types/crash/report.d.ts +134 -0
  46. package/dist/types/crash/sanitize.d.ts +25 -0
  47. package/dist/types/cursor.d.ts +13 -2
  48. package/dist/types/customization/import.d.ts +37 -0
  49. package/dist/types/customization/index.d.ts +4 -0
  50. package/dist/types/customization/inventory.d.ts +25 -0
  51. package/dist/types/customization/mutations.d.ts +32 -0
  52. package/dist/types/customization/types.d.ts +115 -0
  53. package/dist/types/daemon/control-types.d.ts +8 -1
  54. package/dist/types/daemon/operator-contract.d.ts +4 -4
  55. package/dist/types/discovery/claude.d.ts +39 -1
  56. package/dist/types/discovery/codex.d.ts +39 -1
  57. package/dist/types/discovery/index.d.ts +2 -0
  58. package/dist/types/discovery/mcp-compat.d.ts +30 -0
  59. package/dist/types/edit/renderer.d.ts +23 -0
  60. package/dist/types/extensibility/extensions/runner.d.ts +3 -0
  61. package/dist/types/extensibility/extensions/types.d.ts +21 -4
  62. package/dist/types/extensibility/gjc-plugins/observability.d.ts +6 -1
  63. package/dist/types/extensibility/gjc-plugins/registry.d.ts +8 -1
  64. package/dist/types/extensibility/hooks/loader.d.ts +19 -5
  65. package/dist/types/extensibility/runtime-skill-discovery.d.ts +22 -1
  66. package/dist/types/extensibility/skill-management.d.ts +107 -0
  67. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +3 -1
  68. package/dist/types/gjc-runtime/launch-worktree.d.ts +8 -0
  69. package/dist/types/gjc-runtime/linux-proc.d.ts +3 -0
  70. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +10 -6
  71. package/dist/types/gjc-runtime/session-state-lock.d.ts +104 -0
  72. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +120 -2
  73. package/dist/types/gjc-runtime/state-writer.d.ts +10 -1
  74. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +4 -2
  75. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -6
  76. package/dist/types/gjc-runtime/workflow-settings.d.ts +109 -0
  77. package/dist/types/hooks/events.d.ts +60 -0
  78. package/dist/types/hooks/normalize.d.ts +83 -0
  79. package/dist/types/hooks/skill-state.d.ts +9 -2
  80. package/dist/types/main.d.ts +1 -1
  81. package/dist/types/modes/acp/abort-scope.d.ts +13 -0
  82. package/dist/types/modes/acp/acp-agent.d.ts +7 -4
  83. package/dist/types/modes/components/customization/customization-dashboard.d.ts +44 -0
  84. package/dist/types/modes/components/customization/import-wizard.d.ts +21 -0
  85. package/dist/types/modes/components/customization/index.d.ts +1 -0
  86. package/dist/types/modes/components/gajae-pet-widget.d.ts +8 -30
  87. package/dist/types/modes/components/irc-sidebar.d.ts +1 -0
  88. package/dist/types/modes/components/iterm-pet-transport.d.ts +122 -0
  89. package/dist/types/modes/components/oauth-selector.d.ts +10 -4
  90. package/dist/types/modes/components/pet-capability.d.ts +11 -25
  91. package/dist/types/modes/components/status-line/gh.d.ts +6 -2
  92. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  93. package/dist/types/modes/controllers/selector-controller.d.ts +7 -8
  94. package/dist/types/modes/controllers/stt-controller.d.ts +1 -1
  95. package/dist/types/modes/interactive-mode.d.ts +1 -0
  96. package/dist/types/modes/types.d.ts +1 -0
  97. package/dist/types/modes/utils/injected-user-submission.d.ts +10 -0
  98. package/dist/types/runtime-mcp/config.d.ts +9 -0
  99. package/dist/types/runtime-mcp/manager.d.ts +26 -1
  100. package/dist/types/runtime-mcp/oauth-discovery.d.ts +4 -0
  101. package/dist/types/runtime-mcp/oauth-flow.d.ts +24 -1
  102. package/dist/types/runtime-mcp/pool-key.d.ts +2 -0
  103. package/dist/types/runtime-mcp/protocol.d.ts +278 -0
  104. package/dist/types/runtime-mcp/scope-config.d.ts +29 -0
  105. package/dist/types/runtime-mcp/tool-bridge.d.ts +15 -13
  106. package/dist/types/runtime-mcp/transports/http.d.ts +9 -0
  107. package/dist/types/runtime-mcp/types.d.ts +102 -10
  108. package/dist/types/sdk/acp/adapter.d.ts +26 -5
  109. package/dist/types/sdk/broker/broker.d.ts +3 -55
  110. package/dist/types/sdk/broker/lifecycle.d.ts +18 -12
  111. package/dist/types/sdk/broker/session-index.d.ts +14 -5
  112. package/dist/types/sdk/broker/startup-failure.d.ts +16 -1
  113. package/dist/types/sdk/bus/chat-daemon-control.d.ts +51 -1
  114. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -41
  115. package/dist/types/sdk/bus/config.d.ts +11 -0
  116. package/dist/types/sdk/bus/conversation-store.d.ts +0 -1
  117. package/dist/types/sdk/bus/daemon-paths.d.ts +6 -0
  118. package/dist/types/sdk/bus/discord-conversation.d.ts +2 -0
  119. package/dist/types/sdk/bus/discord-daemon.d.ts +17 -12
  120. package/dist/types/sdk/bus/existing-thread-readiness.d.ts +4 -34
  121. package/dist/types/sdk/bus/inbound-reaction-ordering.d.ts +61 -0
  122. package/dist/types/sdk/bus/index.d.ts +85 -99
  123. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +29 -7
  124. package/dist/types/sdk/bus/managed-daemon.d.ts +2 -32
  125. package/dist/types/sdk/bus/notification-service.d.ts +52 -0
  126. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -1
  127. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +16 -5
  128. package/dist/types/sdk/bus/reconciliation-store.d.ts +113 -5
  129. package/dist/types/sdk/bus/session-control.d.ts +25 -1
  130. package/dist/types/sdk/bus/slack-conversation.d.ts +4 -0
  131. package/dist/types/sdk/bus/slack-daemon.d.ts +9 -17
  132. package/dist/types/sdk/bus/slack-live-provider.d.ts +3 -1
  133. package/dist/types/sdk/bus/slack-provider.d.ts +4 -0
  134. package/dist/types/sdk/bus/slack-thread-binding.d.ts +6 -40
  135. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +30 -104
  136. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +10 -0
  137. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +262 -19
  138. package/dist/types/sdk/bus/telegram-daemon-orphan-reap.d.ts +125 -0
  139. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +22 -0
  140. package/dist/types/sdk/bus/telegram-daemon.d.ts +119 -310
  141. package/dist/types/sdk/bus/telegram-reference.d.ts +1 -47
  142. package/dist/types/sdk/bus/threaded-render.d.ts +1 -0
  143. package/dist/types/sdk/bus/topic-registry.d.ts +33 -17
  144. package/dist/types/sdk/cli/rows.d.ts +2 -11
  145. package/dist/types/sdk/cli/session-cli.d.ts +15 -25
  146. package/dist/types/sdk/client/client.d.ts +97 -1
  147. package/dist/types/sdk/host/control/dispatch.d.ts +0 -7
  148. package/dist/types/sdk/host/control/index.d.ts +1 -1
  149. package/dist/types/sdk/host/control/operations.d.ts +18 -5
  150. package/dist/types/sdk/host/control/runtime-gate.d.ts +19 -0
  151. package/dist/types/sdk/host/host.d.ts +15 -1
  152. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  153. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  154. package/dist/types/sdk/host/session-runtime.d.ts +127 -7
  155. package/dist/types/sdk/host/types.d.ts +1 -1
  156. package/dist/types/sdk/host/websocket-transport.d.ts +1 -1
  157. package/dist/types/sdk/index.d.ts +2 -1
  158. package/dist/types/sdk/lifecycle/broker-client.d.ts +8 -0
  159. package/dist/types/sdk/lifecycle/client.d.ts +59 -0
  160. package/dist/types/sdk/lifecycle/index.d.ts +3 -0
  161. package/dist/types/sdk/lifecycle/service.d.ts +232 -0
  162. package/dist/types/sdk/mcp/server.d.ts +9 -6
  163. package/dist/types/sdk/prompt-deadline-lease.d.ts +43 -0
  164. package/dist/types/sdk/prompt-deadline-manager.d.ts +28 -0
  165. package/dist/types/sdk/prompt-status.d.ts +10 -3
  166. package/dist/types/sdk/protocol/operation-registry.d.ts +5 -0
  167. package/dist/types/sdk/receipt-state.d.ts +17 -0
  168. package/dist/types/sdk/reconciliation-extensions.d.ts +2 -0
  169. package/dist/types/sdk/router/index.d.ts +1 -0
  170. package/dist/types/sdk/router/session-router.d.ts +152 -0
  171. package/dist/types/sdk/session-list.d.ts +22 -0
  172. package/dist/types/sdk/session-reconnect.d.ts +43 -1
  173. package/dist/types/sdk/session.d.ts +34 -1
  174. package/dist/types/sdk/transport/serve-cli.d.ts +1 -4
  175. package/dist/types/session/account-inventory.d.ts +86 -0
  176. package/dist/types/session/agent-session.d.ts +145 -6
  177. package/dist/types/session/agent-storage.d.ts +10 -0
  178. package/dist/types/session/coordinator-tool-label.d.ts +28 -0
  179. package/dist/types/session/internal/managed-session-storage.d.ts +23 -4
  180. package/dist/types/session/messages.d.ts +1 -1
  181. package/dist/types/session/secure-token-file.d.ts +3 -0
  182. package/dist/types/session/session-manager.d.ts +40 -1
  183. package/dist/types/session/startup-auth-config.d.ts +44 -0
  184. package/dist/types/session/terminal-abort.d.ts +378 -0
  185. package/dist/types/session/yield-queue.d.ts +7 -0
  186. package/dist/types/session-import/claude.d.ts +26 -0
  187. package/dist/types/session-import/codex.d.ts +88 -0
  188. package/dist/types/session-import/command.d.ts +35 -0
  189. package/dist/types/session-import/detect.d.ts +22 -0
  190. package/dist/types/session-import/index.d.ts +7 -0
  191. package/dist/types/session-import/provider-codex.d.ts +22 -0
  192. package/dist/types/session-import/provider-service.d.ts +38 -0
  193. package/dist/types/session-import/redact.d.ts +23 -0
  194. package/dist/types/session-import/service.d.ts +47 -0
  195. package/dist/types/session-import/types.d.ts +120 -0
  196. package/dist/types/setup/paseo/check.d.ts +16 -0
  197. package/dist/types/setup/paseo/install-saga.d.ts +115 -0
  198. package/dist/types/setup/paseo/json-publisher.d.ts +76 -0
  199. package/dist/types/setup/paseo/orchestration-preferences.d.ts +24 -0
  200. package/dist/types/setup/paseo/paseo-ownership.d.ts +87 -0
  201. package/dist/types/setup/paseo/paseo-setup.d.ts +29 -0
  202. package/dist/types/setup/paseo/provider-config.d.ts +35 -0
  203. package/dist/types/setup/paseo/remove.d.ts +14 -0
  204. package/dist/types/setup/paseo/result-types.d.ts +59 -0
  205. package/dist/types/setup/paseo/setup-deps.d.ts +73 -0
  206. package/dist/types/setup/paseo/skills-bridge.d.ts +44 -0
  207. package/dist/types/skill-state/active-state.d.ts +5 -1
  208. package/dist/types/slash-commands/acp-builtins.d.ts +5 -1
  209. package/dist/types/slash-commands/builtin-registry.d.ts +2 -0
  210. package/dist/types/slash-commands/helpers/credential-switch.d.ts +12 -0
  211. package/dist/types/slash-commands/helpers/usage-report.d.ts +5 -6
  212. package/dist/types/slash-commands/types.d.ts +7 -1
  213. package/dist/types/task/executor.d.ts +10 -0
  214. package/dist/types/task/index.d.ts +3 -1
  215. package/dist/types/tools/ask-answer-registry.d.ts +6 -3
  216. package/dist/types/tools/bash-sleep-advisory.d.ts +14 -0
  217. package/dist/types/tools/bash.d.ts +2 -6
  218. package/dist/types/tools/browser/attach.d.ts +11 -0
  219. package/dist/types/tools/browser/launch.d.ts +20 -0
  220. package/dist/types/tools/browser/profile-discovery.d.ts +6 -0
  221. package/dist/types/tools/browser/render.d.ts +1 -0
  222. package/dist/types/tools/browser.d.ts +3 -1
  223. package/dist/types/tools/image-gen.d.ts +9 -2
  224. package/dist/types/tools/index.d.ts +8 -0
  225. package/dist/types/tools/monitor.d.ts +1 -1
  226. package/dist/types/tools/skill-discovery.d.ts +8 -1
  227. package/dist/types/tools/subagent.d.ts +2 -0
  228. package/dist/types/tools/ultragoal-ask-guard.d.ts +2 -2
  229. package/dist/types/utils/herdr-pane.d.ts +76 -0
  230. package/package.json +19 -9
  231. package/scripts/build-binary.ts +2 -1
  232. package/scripts/build-sdk-package-smoke.ts +2 -13
  233. package/scripts/capture-sticky-viewport-showcase.ts +0 -1
  234. package/scripts/compile-args.ts +2 -0
  235. package/scripts/generate-sdk-adapter-parity-manifest.ts +31 -16
  236. package/scripts/generate-sdk-operation-inventory.ts +28 -0
  237. package/scripts/generate-telegram-baseline-manifest.ts +1 -0
  238. package/scripts/generate-tool-catalog.ts +6 -4
  239. package/scripts/verify-gjc-sdk-canonicalization.ts +5 -39
  240. package/src/async/job-manager.ts +352 -17
  241. package/src/async/support.ts +25 -0
  242. package/src/autoresearch/dashboard.ts +28 -13
  243. package/src/capability/index.ts +3 -1
  244. package/src/capability/mcp.ts +6 -0
  245. package/src/capability/types.ts +6 -0
  246. package/src/cli/accounts-cli.ts +395 -0
  247. package/src/cli/args.ts +23 -0
  248. package/src/cli/auth-broker-cli.ts +136 -66
  249. package/src/cli/auth-gateway-cli.ts +94 -70
  250. package/src/cli/config-cli.ts +27 -2
  251. package/src/cli/crash-cli.ts +78 -0
  252. package/src/cli/customize-doctor.ts +1605 -0
  253. package/src/cli/daemon-cli.ts +12 -3
  254. package/src/cli/fast-help.ts +1 -0
  255. package/src/cli/mcp-cli.ts +128 -60
  256. package/src/cli/notify-cli.ts +8 -14
  257. package/src/cli/root-flags.ts +8 -0
  258. package/src/cli/setup-cli.ts +145 -0
  259. package/src/cli/skills-cli.ts +38 -2
  260. package/src/cli/update-cli.ts +167 -12
  261. package/src/cli.ts +4 -0
  262. package/src/commands/accounts.ts +71 -0
  263. package/src/commands/crash.ts +35 -0
  264. package/src/commands/customize.ts +84 -0
  265. package/src/commands/daemon.ts +2 -2
  266. package/src/commands/deep-interview.ts +2 -0
  267. package/src/commands/launch.ts +1 -0
  268. package/src/commands/mcp.ts +12 -7
  269. package/src/commands/ralplan.ts +2 -0
  270. package/src/commands/sdk.ts +65 -32
  271. package/src/commands/setup.ts +5 -0
  272. package/src/commands/skills.ts +14 -4
  273. package/src/commands/ultragoal.ts +10 -1
  274. package/src/commands/update.ts +26 -15
  275. package/src/config/atomic-yaml-patch-worker.ts +57 -0
  276. package/src/config/atomic-yaml-patch.ts +769 -29
  277. package/src/config/config-schema-version.ts +10 -0
  278. package/src/config/file-lock.ts +70 -1
  279. package/src/config/mcp-schema.json +52 -14
  280. package/src/config/model-profile-activation.ts +20 -1
  281. package/src/config/model-profiles.ts +29 -19
  282. package/src/config/model-registry.ts +132 -21
  283. package/src/config/models-config-schema.ts +1 -1
  284. package/src/config/settings-schema.ts +76 -6
  285. package/src/config/settings.ts +4038 -199
  286. package/src/config/skill-settings-defaults.ts +41 -3
  287. package/src/config/task-delegation.ts +87 -0
  288. package/src/config/update-channel.ts +23 -0
  289. package/src/coordinator-mcp/server.ts +377 -308
  290. package/src/crash/index-store.ts +723 -0
  291. package/src/crash/nudge.ts +89 -0
  292. package/src/crash/record-loader.ts +149 -0
  293. package/src/crash/report.ts +551 -0
  294. package/src/crash/sanitize.ts +129 -0
  295. package/src/cursor.ts +36 -10
  296. package/src/customization/import.ts +946 -0
  297. package/src/customization/index.ts +4 -0
  298. package/src/customization/inventory.ts +335 -0
  299. package/src/customization/mutations.ts +141 -0
  300. package/src/customization/types.ts +223 -0
  301. package/src/daemon/control-types.ts +8 -1
  302. package/src/daemon/operator-contract.ts +6 -6
  303. package/src/defaults/gjc/skills/deep-interview/SKILL.md +11 -22
  304. package/src/defaults/gjc/skills/ralplan/SKILL.md +36 -23
  305. package/src/defaults/gjc/skills/ultragoal/SKILL.md +16 -0
  306. package/src/discovery/claude-hooks.ts +38 -0
  307. package/src/discovery/claude.ts +142 -209
  308. package/src/discovery/codex-hooks.ts +38 -0
  309. package/src/discovery/codex.ts +132 -198
  310. package/src/discovery/helpers.ts +8 -1
  311. package/src/discovery/index.ts +2 -0
  312. package/src/discovery/mcp-compat.ts +234 -0
  313. package/src/discovery/mcp-json.ts +6 -0
  314. package/src/edit/renderer.ts +40 -0
  315. package/src/edit/streaming.ts +4 -2
  316. package/src/exec/bash-executor.ts +24 -20
  317. package/src/export/html/index.ts +51 -2
  318. package/src/extensibility/extensions/loader.ts +9 -1
  319. package/src/extensibility/extensions/runner.ts +17 -4
  320. package/src/extensibility/extensions/types.ts +31 -6
  321. package/src/extensibility/gjc-plugins/constrained-hooks.ts +33 -0
  322. package/src/extensibility/gjc-plugins/installer.ts +9 -5
  323. package/src/extensibility/gjc-plugins/observability.ts +8 -2
  324. package/src/extensibility/gjc-plugins/registry.ts +13 -2
  325. package/src/extensibility/gjc-plugins/runtime-adapters.ts +3 -1
  326. package/src/extensibility/gjc-plugins/schema.ts +222 -39
  327. package/src/extensibility/hooks/loader.ts +154 -10
  328. package/src/extensibility/runtime-skill-discovery.ts +257 -60
  329. package/src/extensibility/skill-management.ts +368 -0
  330. package/src/extensibility/skills.ts +52 -16
  331. package/src/gjc-runtime/deep-interview-runtime.ts +45 -61
  332. package/src/gjc-runtime/launch-worktree.ts +56 -2
  333. package/src/gjc-runtime/linux-proc.ts +7 -1
  334. package/src/gjc-runtime/ralplan-runtime.ts +100 -164
  335. package/src/gjc-runtime/session-state-lock.ts +992 -0
  336. package/src/gjc-runtime/session-state-sidecar.ts +644 -142
  337. package/src/gjc-runtime/state-writer.ts +39 -14
  338. package/src/gjc-runtime/tmux-owner-isolation.ts +5 -4
  339. package/src/gjc-runtime/ultragoal-guard.ts +11 -4
  340. package/src/gjc-runtime/ultragoal-runtime.ts +44 -41
  341. package/src/gjc-runtime/workflow-settings.ts +596 -0
  342. package/src/goals/tools/goal-tool.ts +6 -1
  343. package/src/hooks/events.ts +291 -0
  344. package/src/hooks/native-skill-hook.ts +4 -0
  345. package/src/hooks/normalize.ts +369 -0
  346. package/src/hooks/skill-state.ts +165 -46
  347. package/src/internal-urls/docs-index.generated.ts +40 -32
  348. package/src/main.ts +32 -5
  349. package/src/migrate/mcp-mapper.ts +25 -2
  350. package/src/modes/acp/abort-scope.ts +29 -0
  351. package/src/modes/acp/acp-agent.ts +423 -98
  352. package/src/modes/components/assistant-message.ts +6 -1
  353. package/src/modes/components/customization/customization-dashboard.ts +443 -0
  354. package/src/modes/components/customization/import-wizard.ts +372 -0
  355. package/src/modes/components/customization/index.ts +1 -0
  356. package/src/modes/components/gajae-pet-widget.ts +539 -213
  357. package/src/modes/components/irc-sidebar.ts +11 -0
  358. package/src/modes/components/iterm-pet-transport.ts +585 -0
  359. package/src/modes/components/oauth-selector.ts +277 -86
  360. package/src/modes/components/pet-capability.ts +39 -40
  361. package/src/modes/components/settings-selector.ts +9 -0
  362. package/src/modes/components/status-line/gh.ts +45 -1
  363. package/src/modes/components/status-line/segments.ts +16 -1
  364. package/src/modes/components/tool-status-header.ts +13 -3
  365. package/src/modes/controllers/command-controller.ts +20 -8
  366. package/src/modes/controllers/extension-ui-controller.ts +56 -257
  367. package/src/modes/controllers/input-controller.ts +10 -0
  368. package/src/modes/controllers/runtime-mcp-command-controller.ts +16 -3
  369. package/src/modes/controllers/selector-controller.ts +177 -72
  370. package/src/modes/controllers/stt-controller.ts +3 -3
  371. package/src/modes/interactive-mode.ts +88 -16
  372. package/src/modes/print-mode.ts +35 -6
  373. package/src/modes/runtime-init.ts +21 -68
  374. package/src/modes/types.ts +1 -0
  375. package/src/modes/utils/injected-user-submission.ts +25 -0
  376. package/src/modes/utils/terminal-bell.ts +15 -1
  377. package/src/prompts/agent-fragments/ralplan-persistence.md +1 -1
  378. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  379. package/src/prompts/agents/critic.md +1 -1
  380. package/src/prompts/system/rlm-research.md +1 -1
  381. package/src/prompts/system/system-prompt.md +0 -2
  382. package/src/prompts/tools/bash.md +7 -1
  383. package/src/prompts/tools/browser.md +1 -1
  384. package/src/prompts/tools/cron.md +6 -6
  385. package/src/prompts/tools/read.md +0 -2
  386. package/src/prompts/tools/skill-discovery.md +4 -2
  387. package/src/runtime-mcp/client.ts +427 -23
  388. package/src/runtime-mcp/config.ts +37 -5
  389. package/src/runtime-mcp/manager-pool.test.ts +56 -0
  390. package/src/runtime-mcp/manager.ts +52 -2
  391. package/src/runtime-mcp/oauth-discovery.ts +13 -0
  392. package/src/runtime-mcp/oauth-flow.ts +46 -1
  393. package/src/runtime-mcp/pool-key.ts +5 -1
  394. package/src/runtime-mcp/pool.test.ts +9 -0
  395. package/src/runtime-mcp/protocol.test.ts +353 -0
  396. package/src/runtime-mcp/protocol.ts +624 -0
  397. package/src/runtime-mcp/scope-config.ts +78 -0
  398. package/src/runtime-mcp/tool-bridge.ts +44 -10
  399. package/src/runtime-mcp/transports/http.ts +84 -13
  400. package/src/runtime-mcp/types.ts +123 -12
  401. package/src/sdk/acp/adapter.ts +328 -97
  402. package/src/sdk/broker/broker.ts +160 -741
  403. package/src/sdk/broker/ensure.ts +9 -1
  404. package/src/sdk/broker/lifecycle.ts +510 -116
  405. package/src/sdk/broker/operation-receipt-ledger.ts +11 -2
  406. package/src/sdk/broker/process-incarnation.ts +19 -0
  407. package/src/sdk/broker/session-index.ts +516 -117
  408. package/src/sdk/broker/startup-failure.ts +28 -5
  409. package/src/sdk/broker/transport.ts +1 -13
  410. package/src/sdk/bus/chat-daemon-cli.ts +4 -2
  411. package/src/sdk/bus/chat-daemon-control.ts +67 -6
  412. package/src/sdk/bus/chat-daemon-runtime.ts +183 -859
  413. package/src/sdk/bus/config.ts +13 -0
  414. package/src/sdk/bus/conversation-store.ts +59 -171
  415. package/src/sdk/bus/daemon-paths.ts +20 -0
  416. package/src/sdk/bus/discord-conversation.ts +2 -0
  417. package/src/sdk/bus/discord-daemon.ts +524 -126
  418. package/src/sdk/bus/discord-live-provider.ts +206 -79
  419. package/src/sdk/bus/existing-thread-readiness.ts +4 -97
  420. package/src/sdk/bus/inbound-reaction-ordering.ts +122 -0
  421. package/src/sdk/bus/index.ts +2480 -456
  422. package/src/sdk/bus/kind-aware-reconciliation.ts +204 -34
  423. package/src/sdk/bus/lifecycle-commands.ts +2 -2
  424. package/src/sdk/bus/managed-daemon.ts +2 -145
  425. package/src/sdk/bus/notification-service.ts +196 -0
  426. package/src/sdk/bus/operator-runtime.ts +1 -1
  427. package/src/sdk/bus/prompt-reconciliation.ts +24 -15
  428. package/src/sdk/bus/reconciliation-store.ts +553 -24
  429. package/src/sdk/bus/session-control.ts +49 -3
  430. package/src/sdk/bus/slack-conversation.ts +4 -0
  431. package/src/sdk/bus/slack-daemon.ts +733 -134
  432. package/src/sdk/bus/slack-live-provider.ts +65 -15
  433. package/src/sdk/bus/slack-provider.ts +4 -0
  434. package/src/sdk/bus/slack-thread-binding.ts +9 -104
  435. package/src/sdk/bus/telegram-adoption-intent.ts +299 -209
  436. package/src/sdk/bus/telegram-daemon-cli.ts +194 -7
  437. package/src/sdk/bus/telegram-daemon-contract.ts +265 -19
  438. package/src/sdk/bus/telegram-daemon-control.ts +3 -13
  439. package/src/sdk/bus/telegram-daemon-orphan-reap.ts +498 -0
  440. package/src/sdk/bus/telegram-daemon-owner-registry.ts +176 -0
  441. package/src/sdk/bus/telegram-daemon.ts +2552 -2280
  442. package/src/sdk/bus/telegram-reference.ts +1 -226
  443. package/src/sdk/bus/telegram-setup.ts +2 -2
  444. package/src/sdk/bus/threaded-render.ts +1 -0
  445. package/src/sdk/bus/topic-registry.ts +279 -146
  446. package/src/sdk/cli/rows.ts +2 -35
  447. package/src/sdk/cli/session-cli.ts +776 -700
  448. package/src/sdk/client/client.ts +892 -1
  449. package/src/sdk/host/control/dispatch.ts +93 -58
  450. package/src/sdk/host/control/index.ts +1 -1
  451. package/src/sdk/host/control/operations.ts +18 -10
  452. package/src/sdk/host/control/runtime-gate.ts +40 -0
  453. package/src/sdk/host/host.ts +63 -12
  454. package/src/sdk/host/query/handlers.ts +66 -7
  455. package/src/sdk/host/reverse-leases.ts +2 -2
  456. package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
  457. package/src/sdk/host/session-runtime.test.ts +2787 -34
  458. package/src/sdk/host/session-runtime.ts +1489 -101
  459. package/src/sdk/host/surface-policy.ts +12 -8
  460. package/src/sdk/host/types.ts +1 -1
  461. package/src/sdk/host/websocket-transport.lifecycle.test.ts +31 -59
  462. package/src/sdk/host/websocket-transport.ts +85 -50
  463. package/src/sdk/index.ts +2 -1
  464. package/src/sdk/lifecycle/broker-client.ts +53 -0
  465. package/src/sdk/lifecycle/client.ts +234 -0
  466. package/src/sdk/lifecycle/index.ts +3 -0
  467. package/src/sdk/{bus/recent-activity.ts → lifecycle/recent-sessions.ts} +5 -2
  468. package/src/sdk/lifecycle/service.ts +720 -0
  469. package/src/sdk/mcp/server.ts +226 -188
  470. package/src/sdk/prompt-deadline-lease.ts +55 -0
  471. package/src/sdk/prompt-deadline-manager.ts +148 -0
  472. package/src/sdk/prompt-status.ts +11 -3
  473. package/src/sdk/protocol/operation-inventory.generated.json +272 -129
  474. package/src/sdk/protocol/operation-registry.ts +71 -8
  475. package/src/sdk/receipt-state.ts +43 -0
  476. package/src/sdk/reconciliation-extensions.ts +14 -0
  477. package/src/sdk/router/index.ts +1 -0
  478. package/src/sdk/router/session-router.ts +1658 -0
  479. package/src/sdk/session-list.ts +93 -0
  480. package/src/sdk/session-reconnect.ts +45 -1
  481. package/src/sdk/session.ts +716 -120
  482. package/src/sdk/transport/serve-cli.ts +18 -34
  483. package/src/session/account-inventory.ts +603 -0
  484. package/src/session/agent-session.ts +2857 -582
  485. package/src/session/agent-storage.ts +35 -0
  486. package/src/session/blob-store.ts +32 -1
  487. package/src/session/coordinator-tool-label.ts +40 -0
  488. package/src/session/internal/managed-session-scope.ts +4 -0
  489. package/src/session/internal/managed-session-storage.ts +173 -12
  490. package/src/session/messages.ts +1 -1
  491. package/src/session/secure-token-file.ts +80 -0
  492. package/src/session/session-manager.ts +730 -58
  493. package/src/session/session-storage.ts +25 -16
  494. package/src/session/startup-auth-config.ts +297 -0
  495. package/src/session/terminal-abort.ts +1253 -0
  496. package/src/session/yield-queue.ts +57 -15
  497. package/src/session-import/claude.ts +334 -0
  498. package/src/session-import/codex.ts +1033 -0
  499. package/src/session-import/command.ts +77 -0
  500. package/src/session-import/detect.ts +133 -0
  501. package/src/session-import/index.ts +7 -0
  502. package/src/session-import/provider-codex.ts +327 -0
  503. package/src/session-import/provider-service.ts +457 -0
  504. package/src/session-import/redact.ts +128 -0
  505. package/src/session-import/service.ts +1264 -0
  506. package/src/session-import/types.ts +148 -0
  507. package/src/setup/paseo/check.ts +213 -0
  508. package/src/setup/paseo/install-saga.ts +293 -0
  509. package/src/setup/paseo/json-publisher.ts +263 -0
  510. package/src/setup/paseo/orchestration-preferences.ts +86 -0
  511. package/src/setup/paseo/paseo-ownership.ts +224 -0
  512. package/src/setup/paseo/paseo-setup.ts +253 -0
  513. package/src/setup/paseo/provider-config.ts +139 -0
  514. package/src/setup/paseo/remove.ts +183 -0
  515. package/src/setup/paseo/result-types.ts +79 -0
  516. package/src/setup/paseo/setup-deps.ts +175 -0
  517. package/src/setup/paseo/skills-bridge.ts +260 -0
  518. package/src/skill-state/active-state.ts +21 -11
  519. package/src/slash-commands/acp-builtins.ts +9 -1
  520. package/src/slash-commands/builtin-registry.ts +98 -16
  521. package/src/slash-commands/helpers/credential-switch.ts +143 -0
  522. package/src/slash-commands/helpers/mcp.ts +9 -3
  523. package/src/slash-commands/helpers/usage-report.ts +61 -56
  524. package/src/slash-commands/types.ts +7 -2
  525. package/src/task/executor.ts +30 -11
  526. package/src/task/index.ts +66 -7
  527. package/src/task/render.ts +23 -9
  528. package/src/tools/ask-answer-registry.ts +23 -7
  529. package/src/tools/ask.ts +8 -4
  530. package/src/tools/bash-sleep-advisory.ts +115 -0
  531. package/src/tools/bash.ts +98 -18
  532. package/src/tools/browser/attach.ts +71 -3
  533. package/src/tools/browser/launch.ts +86 -13
  534. package/src/tools/browser/profile-discovery.ts +57 -7
  535. package/src/tools/browser/render.ts +8 -1
  536. package/src/tools/browser.ts +130 -21
  537. package/src/tools/computer.enforcement.test.ts +1 -1
  538. package/src/tools/computer.ts +3 -2
  539. package/src/tools/cron.ts +12 -1
  540. package/src/tools/image-gen.ts +27 -16
  541. package/src/tools/index.ts +8 -0
  542. package/src/tools/job.ts +33 -6
  543. package/src/tools/monitor.ts +41 -2
  544. package/src/tools/skill-discovery.ts +21 -29
  545. package/src/tools/subagent.ts +142 -24
  546. package/src/tools/tool-catalog.generated.ts +6 -6
  547. package/src/tools/ultragoal-ask-guard.ts +10 -3
  548. package/src/utils/herdr-pane.ts +345 -0
  549. package/src/utils/title-generator.ts +2 -0
  550. package/src/web/scrapers/index.ts +6 -4
  551. package/dist/types/cli/malloc-env-guard.test.d.ts +0 -1
  552. package/dist/types/defaults/gjc-defaults.test.d.ts +0 -1
  553. package/dist/types/memory-backend/lazy-loading.test.d.ts +0 -1
  554. package/dist/types/runtime/boot-ordering.test.d.ts +0 -1
  555. package/dist/types/runtime/lazy-service.test.d.ts +0 -1
  556. package/dist/types/runtime/network-prewarm-service.test.d.ts +0 -1
  557. package/dist/types/runtime/workspace-tree-service.test.d.ts +0 -1
  558. package/dist/types/runtime-mcp/config.test.d.ts +0 -1
  559. package/dist/types/runtime-mcp/http.test.d.ts +0 -1
  560. package/dist/types/runtime-mcp/manager-pool.test.d.ts +0 -1
  561. package/dist/types/runtime-mcp/pool-key.test.d.ts +0 -1
  562. package/dist/types/runtime-mcp/pool.test.d.ts +0 -1
  563. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +0 -117
  564. package/dist/types/sdk/bus/lifecycle-orchestrator.d.ts +0 -141
  565. package/dist/types/sdk/bus/telegram-cli.d.ts +0 -31
  566. package/dist/types/sdk/elevation/allowlist.d.ts +0 -20
  567. package/dist/types/sdk/elevation/audit.d.ts +0 -93
  568. package/dist/types/sdk/elevation/capability.d.ts +0 -3
  569. package/dist/types/sdk/elevation/digest.d.ts +0 -72
  570. package/dist/types/sdk/elevation/dispatch-receipt.d.ts +0 -85
  571. package/dist/types/sdk/elevation/grant-ledger.d.ts +0 -163
  572. package/dist/types/sdk/elevation/owner.d.ts +0 -48
  573. package/dist/types/sdk/host/import-graph.test.d.ts +0 -1
  574. package/dist/types/sdk/host/sdk-surface-parity.test.d.ts +0 -1
  575. package/dist/types/sdk/host/session-runtime.test.d.ts +0 -1
  576. package/dist/types/sdk/host/websocket-transport.lifecycle.test.d.ts +0 -1
  577. package/dist/types/session/auth-broker-config.d.ts +0 -13
  578. package/dist/types/session/heap-eviction-artifacts.test.d.ts +0 -1
  579. package/dist/types/skills/skills.test.d.ts +0 -1
  580. package/dist/types/slash-commands/notify-command.test.d.ts +0 -1
  581. package/dist/types/task/token-log.test.d.ts +0 -1
  582. package/dist/types/tools/computer.enforcement.test.d.ts +0 -1
  583. package/dist/types/tools/descriptors.test.d.ts +0 -1
  584. package/scripts/g005-daemon-smoke.ts +0 -180
  585. package/scripts/g011-daemon-path-smoke.ts +0 -153
  586. package/src/sdk/bus/lifecycle-control-runtime.ts +0 -1733
  587. package/src/sdk/bus/lifecycle-orchestrator.ts +0 -489
  588. package/src/sdk/bus/telegram-cli.ts +0 -212
  589. package/src/sdk/elevation/allowlist.ts +0 -54
  590. package/src/sdk/elevation/audit.ts +0 -208
  591. package/src/sdk/elevation/capability.ts +0 -41
  592. package/src/sdk/elevation/digest.ts +0 -129
  593. package/src/sdk/elevation/dispatch-receipt.ts +0 -184
  594. package/src/sdk/elevation/grant-ledger.ts +0 -1142
  595. package/src/sdk/elevation/owner.ts +0 -109
  596. package/src/session/auth-broker-config.ts +0 -109
  597. /package/dist/types/{ai-core-import-gate.test.d.ts → discovery/claude-hooks.d.ts} +0 -0
  598. /package/dist/types/{cli/fixture-report.test.d.ts → discovery/codex-hooks.d.ts} +0 -0
  599. /package/dist/types/sdk/{bus/recent-activity.d.ts → lifecycle/recent-sessions.d.ts} +0 -0
@@ -24,7 +24,6 @@ import { AsyncLocalStorage } from "node:async_hooks";
24
24
  import { execFile } from "node:child_process";
25
25
  import * as crypto from "node:crypto";
26
26
  import * as fs from "node:fs";
27
- import * as fsPromises from "node:fs/promises";
28
27
  import * as os from "node:os";
29
28
  import * as path from "node:path";
30
29
  import { promisify } from "node:util";
@@ -49,6 +48,7 @@ function sdkBusNatives(): NativeSdkBusBindings {
49
48
  type NotificationServer = NativeNotificationServer;
50
49
 
51
50
  import { $credentialEnv, logger, postmortem, VERSION } from "@gajae-code/utils";
51
+ import { AsyncJobManager } from "../../async";
52
52
  import { Settings, validateSettingPatch } from "../../config/settings";
53
53
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../../extensibility/extensions";
54
54
  import { INTERACTIVE_SELECTOR_RESUME_ORIGIN } from "../../extensibility/shared-events";
@@ -61,6 +61,12 @@ import {
61
61
  } from "../../modes/shared/agent-wire/workflow-gate-broker";
62
62
  import type { AgentSessionEvent } from "../../session/agent-session";
63
63
  import type { ClientBridge } from "../../session/client-bridge";
64
+ import {
65
+ boundTerminalRetentionState,
66
+ findOwnedRegistrationsForTurn,
67
+ isOwnedAttemptRegistrationIncomplete,
68
+ settleOwnedWork,
69
+ } from "../../session/terminal-abort";
64
70
  import { parseThinkingLevel } from "../../thinking";
65
71
  import type {
66
72
  AskAnswerRequest,
@@ -84,9 +90,16 @@ import { ensureBroker } from "../broker/ensure";
84
90
  import { publishSessionHostRuntimeEvidence, type SessionHostRuntimePublication } from "../broker/lifecycle";
85
91
  import { processIncarnation } from "../broker/process-incarnation";
86
92
  import { SessionIndex } from "../broker/session-index";
87
- import { elevationAuthorityPath, verifyElevationCapability } from "../elevation/capability";
88
- import { createSdkSurfaceFactory, type SessionSdkHost, SessionSdkSessionRuntime, shouldHostSdk } from "../host";
89
- import { type ControlSurface, controlRequestFromFrame, dispatchControl } from "../host/control";
93
+ import {
94
+ CAP_GATED_FRAME_KINDS,
95
+ createSdkSurfaceFactory,
96
+ type SessionSdkHost,
97
+ SessionSdkSessionRuntime,
98
+ shouldHostSdk,
99
+ TOOL_ACTIVITY_CAPABILITY,
100
+ } from "../host";
101
+ import { type AbortScope, type ControlSurface, dispatchControl, TypedControlError } from "../host/control";
102
+ import { BROKER_RUNTIME_CLOSE_CAPABILITY_FIELD } from "../host/control/runtime-gate";
90
103
  import { CursorRegistry, QueryHandlers, RevisionStore, type SessionSurface } from "../host/query";
91
104
  import type { SdkFrame } from "../host/types";
92
105
  import {
@@ -104,6 +117,7 @@ import {
104
117
  normalizeSdkStartupFailure,
105
118
  type SdkStartupFailure,
106
119
  } from "../startup-capability";
120
+ import type { TurnResultContent } from "../turn-result";
107
121
  import { registerTelegramFileSink } from "./attachment-registry";
108
122
  import { ensureDiscordDaemon, ensureSlackDaemon } from "./chat-daemon-control";
109
123
  import {
@@ -111,10 +125,12 @@ import {
111
125
  getNotificationConfig,
112
126
  isProviderEffectivelyEnabled,
113
127
  isSlackComplete,
128
+ isTelegramSessionEligible,
114
129
  type NotificationConfig,
115
130
  type NotificationSettingsReader,
116
131
  resolveGenericNotificationSessionEligibility,
117
132
  sessionTag,
133
+ tokenFingerprint,
118
134
  } from "./config";
119
135
  import { telegramControlCommandUsage } from "./config-commands";
120
136
  import {
@@ -129,20 +145,26 @@ import {
129
145
  isExistingThreadBindingRequested,
130
146
  } from "./existing-thread-readiness";
131
147
  import { imageAttachmentsFromMessage, notificationActionPayload, summaryFromMessage, truncate } from "./helpers";
132
- import { createKindAwareReconciliation } from "./kind-aware-reconciliation";
148
+ import {
149
+ createKindAwareReconciliation,
150
+ type KindAwareReconciliation,
151
+ type ReconciliationKind,
152
+ } from "./kind-aware-reconciliation";
133
153
  import { assertNativeRuntimeCompatibility } from "./native-runtime-compatibility";
134
154
  import { proposedTelegramIdentity } from "./notification-orchestration";
135
155
  import { createPromptReconciliation } from "./prompt-reconciliation";
136
- import { createReconciliationStore } from "./reconciliation-store";
156
+ import {
157
+ createReconciliationStore,
158
+ type DurableTerminalScopeRecord,
159
+ type EvictedTerminalKeyEntry,
160
+ resolveReconciliationSessionFile,
161
+ } from "./reconciliation-store";
137
162
  import { NotificationSessionController, type NotificationSessionRuntime } from "./session-control";
138
163
  import type { SlackConversation } from "./slack-conversation";
139
164
  import {
140
165
  ASK_SELECTED_ACK_CAPABILITY,
141
166
  type EnsureDaemonResult,
142
- endpointAuthorityDigest,
143
167
  ensureTelegramDaemonRunningDetailed,
144
- type RegisterNotificationRootResult,
145
- unregisterNotificationRoot,
146
168
  } from "./telegram-daemon";
147
169
 
148
170
  export type {
@@ -156,6 +178,29 @@ export {
156
178
  runIdentityControlTerminalPath,
157
179
  } from "./control-drain-lease";
158
180
 
181
+ export type NotificationInboundAdmission =
182
+ | { outcome: "accept" }
183
+ | { outcome: "drop"; reason: "inbound_fenced" | "policy_suspended" }
184
+ | { outcome: "defer"; reason: "policy_suspended" };
185
+
186
+ /** Exact production admission decision for daemon-originated session inbound. */
187
+ export function notificationInboundAdmission(input: {
188
+ inboundFenced: boolean;
189
+ policySuspended: boolean;
190
+ notificationOrigin: boolean;
191
+ controlCommand: boolean;
192
+ }): NotificationInboundAdmission {
193
+ if (input.inboundFenced) return { outcome: "drop", reason: "inbound_fenced" };
194
+ if (input.policySuspended && input.notificationOrigin) {
195
+ // Valid control commands are not dropped: they are deferred while policy is
196
+ // provisional and executed on activate. A terminal `dropped` ack would
197
+ // contradict that later execution and invite client-side retry duplication.
198
+ if (input.controlCommand) return { outcome: "defer", reason: "policy_suspended" };
199
+ return { outcome: "drop", reason: "policy_suspended" };
200
+ }
201
+ return { outcome: "accept" };
202
+ }
203
+
159
204
  const PROMPT_SETTLEMENT_DIAGNOSTIC_ENTRY_LIMIT = 8;
160
205
  const PROMPT_SETTLEMENT_DIAGNOSTIC_MAX_AGE_MS = 86_400_000;
161
206
  /**
@@ -193,6 +238,27 @@ function formatPromptTerminalFailureReason(reason: unknown): string {
193
238
  return rawReason ? rawReason.slice(0, PROMPT_TERMINAL_FAILURE_REASON_LOG_MAX) : "unreported";
194
239
  }
195
240
 
241
+ /**
242
+ * Thrown from a serialized durable terminal-scope transaction when the
243
+ * idempotency key is already owned by a DIFFERENT input (scope). The generic
244
+ * dispatch cache normally rejects this before the surface, but after its
245
+ * 256-entry eviction two concurrent requests can both pass the earlier
246
+ * snapshot check; the atomic recheck inside the transaction must reject the
247
+ * second instead of appending a duplicate-key row (review thread P2).
248
+ */
249
+ class TerminalIdempotencyConflictError extends Error {
250
+ constructor() {
251
+ super("Idempotency key was reused with different input.");
252
+ }
253
+ }
254
+
255
+ function endpointAuthorityDigest(url: string, token: string): string {
256
+ const parsed = new URL(url);
257
+ parsed.hash = "";
258
+ parsed.search = "";
259
+ parsed.hostname = parsed.hostname.toLowerCase();
260
+ return crypto.createHash("sha256").update(`${parsed.toString()}${token}`, "utf8").digest("hex");
261
+ }
196
262
  export function formatPromptSettlementDiagnostic(
197
263
  proof: Extract<RunSettlementProof, { status: "unfenced" }>,
198
264
  now = Date.now(),
@@ -210,15 +276,11 @@ export function formatPromptSettlementDiagnostic(
210
276
  }
211
277
 
212
278
  // ===========================================================================
213
- // Session lifecycle control protocol (TypeScript mirror of the Rust wire
214
- // contract in `crates/gjc-sdk/src/lifecycle.rs`).
215
- //
216
- // These describe the frames exchanged over the daemon-owned, session-independent
217
- // control endpoint for remote session create / close / resume. Field names are
218
- // camelCase on the wire; `type`/`kind` discriminators are snake_case. The Rust
219
- // ingress authenticates and forwards; the daemon (TypeScript) owns all policy,
220
- // spawn orchestration, idempotency, rate limiting, audit, and UX.
279
+ // Session lifecycle presentation contract
221
280
  // ===========================================================================
281
+ // Provider-neutral lifecycle command targets and credential-free presentation outcomes.
282
+ // SessionLifecycleService owns request authorization/idempotency; these types contain no
283
+ // control endpoint, process, tmux, session-state, or SDK endpoint authority.
222
284
 
223
285
  /** Where a `session_create` should run. Discriminated by `kind`. */
224
286
  export type SessionCreateTarget =
@@ -229,10 +291,6 @@ export type SessionCreateTarget =
229
291
  /** Identifies the session a `session_close` targets. */
230
292
  export interface SessionCloseTarget {
231
293
  sessionId: string;
232
- /** Expected GJC-managed tmux session name (defense-in-depth match). */
233
- tmuxSession?: string;
234
- /** Expected `@gjc-session-state-file` tag (defense-in-depth match). */
235
- sessionStateFile?: string;
236
294
  }
237
295
 
238
296
  /** Identifies the session a `session_resume` targets. */
@@ -242,110 +300,33 @@ export interface SessionResumeTarget {
242
300
  path?: string;
243
301
  }
244
302
 
245
- /** Create a new session. */
246
- export interface SessionCreateFrame {
247
- type: "session_create";
248
- requestId: string;
249
- /** Deterministic lifecycle marker preallocated by the daemon before spawn. */
250
- lifecycleRequestId: string;
251
- /** Session id the daemon preallocated and propagates to the child. */
252
- intendedSessionId: string;
253
- /** Telegram update id (idempotency key on the daemon side). */
254
- updateId: number;
255
- chatId: string;
256
- /** Control-endpoint token authorizing this frame. */
257
- token: string;
258
- target: SessionCreateTarget;
259
- /** Reserved for a future capability transport; any supplied value is rejected before lifecycle acceptance. */
260
- startupPromptRef?: string;
261
- /** Model profile preset to activate for the spawned session (--mpreset). */
262
- modelPreset?: string;
263
- }
264
-
265
- /** Close (hard-kill, history preserved) a session. */
266
- export interface SessionCloseFrame {
267
- type: "session_close";
268
- requestId: string;
269
- updateId: number;
270
- chatId: string;
271
- token: string;
272
- target: SessionCloseTarget;
273
- /** Required force-only close flag; false/omitted is rejected by daemon policy. */
274
- force?: boolean;
275
- }
276
-
277
- /** Resume a session (reattach if alive, else cold-restart from history). */
278
- export interface SessionResumeFrame {
279
- type: "session_resume";
280
- requestId: string;
281
- updateId: number;
282
- chatId: string;
283
- token: string;
284
- target: SessionResumeTarget;
285
- /** Reserved for a future capability transport; any supplied value is rejected before lifecycle acceptance. */
286
- startupPromptRef?: string;
287
- }
288
-
289
- /** Any client -> ingress lifecycle request frame. */
290
- export type SessionLifecycleRequest = SessionCreateFrame | SessionCloseFrame | SessionResumeFrame;
291
-
292
- /** Terminal status of a lifecycle request. */
293
303
  export type LifecycleStatus = "ok" | "error";
294
304
 
295
- /** A connected session's per-session endpoint, returned to the control client. */
296
- export interface LifecycleEndpoint {
297
- url: string;
298
- token: string;
299
- }
300
-
301
- /** The Telegram topic/thread a session is surfaced in. */
302
- export interface LifecycleTopic {
303
- chatId: string;
304
- threadId: string;
305
- }
306
-
307
- /** How a create request was correlated to its spawned session. */
308
- export type MatchedBy = "spawn_marker" | "session_ready";
309
-
310
- /** Response to a successful `session_create`. */
311
305
  export interface SessionCreateResponseFrame {
312
306
  type: "session_create_response";
313
307
  requestId: string;
314
308
  status: LifecycleStatus;
315
- lifecycleRequestId: string;
316
309
  sessionId: string;
317
- matchedBy: MatchedBy;
318
- endpoint: LifecycleEndpoint;
319
- topic: LifecycleTopic;
320
310
  target: SessionCreateTarget;
321
311
  }
322
312
 
323
- /** Response to a successful `session_close`. */
324
313
  export interface SessionCloseResponseFrame {
325
314
  type: "session_close_response";
326
315
  requestId: string;
327
316
  status: LifecycleStatus;
328
317
  sessionId: string;
329
- processGone: boolean;
330
- historyPreserved: boolean;
331
- endpointStale: boolean;
332
318
  }
333
319
 
334
- /** Whether a resume reattached to a live session or cold-restarted a dead one. */
335
320
  export type ResumeMode = "reattached" | "cold_restarted";
336
321
 
337
- /** Response to a successful `session_resume`. */
338
322
  export interface SessionResumeResponseFrame {
339
323
  type: "session_resume_response";
340
324
  requestId: string;
341
325
  status: LifecycleStatus;
342
326
  sessionId: string;
343
327
  mode: ResumeMode;
344
- endpoint: LifecycleEndpoint;
345
- topic: LifecycleTopic;
346
328
  }
347
329
 
348
- /** Machine-readable reason a lifecycle request failed. */
349
330
  export type LifecycleErrorReason =
350
331
  | "unauthorized"
351
332
  | "rate_limited"
@@ -360,15 +341,12 @@ export type LifecycleErrorReason =
360
341
  | "terminal_uncertain"
361
342
  | "unsupported_platform";
362
343
 
363
- /** A candidate returned with an `ambiguous_target` resume error. */
364
344
  export interface ResumeCandidate {
365
345
  sessionId: string;
366
346
  path?: string;
367
- /** Last-activity epoch-millis (session history file mtime), if known. */
368
347
  mtimeMs?: number;
369
348
  }
370
349
 
371
- /** A structured lifecycle error frame. */
372
350
  export interface SessionLifecycleErrorFrame {
373
351
  type: "session_lifecycle_error";
374
352
  requestId: string;
@@ -378,7 +356,6 @@ export interface SessionLifecycleErrorFrame {
378
356
  candidates?: ResumeCandidate[];
379
357
  }
380
358
 
381
- /** Any ingress -> client lifecycle response frame. */
382
359
  export type SessionLifecycleResponse =
383
360
  | SessionCreateResponseFrame
384
361
  | SessionCloseResponseFrame
@@ -454,19 +431,21 @@ export function readGitRepoName(cwd: string): string | undefined {
454
431
  /** Build the one-time identity header fields for a session thread. */
455
432
  function buildIdentity(
456
433
  cwd: string,
457
- sessionName?: string,
434
+ sessionName: string | undefined,
435
+ telegramTopicsEnabled: boolean,
458
436
  ): {
459
437
  repo: string;
460
438
  branch: string;
461
439
  machine: string;
462
440
  title?: string;
441
+ telegramTopicsEnabled: boolean;
463
442
  } {
464
443
  const repo = readGitRepoName(cwd) ?? (path.basename(cwd) || cwd);
465
444
  const branch = readGitBranch(cwd) ?? "(detached)";
466
445
  // Send repo/branch and the raw session title separately; the consumer
467
446
  // composes the topic name ("{repo}/{branch}" before the session title is
468
447
  // auto-generated, then "{repo}/{branch} - {session title}" once it exists).
469
- return { repo, branch, machine: os.hostname(), title: sessionName };
448
+ return { repo, branch, machine: os.hostname(), title: sessionName, telegramTopicsEnabled };
470
449
  }
471
450
 
472
451
  /** Compact cwd label for remote session identity; never emits the full host path by default. */
@@ -1162,6 +1141,9 @@ export class PresentationArbiter {
1162
1141
  interface SessionRuntime {
1163
1142
  server: NotificationServer;
1164
1143
  host: SessionSdkHost;
1144
+ /** Delivers one ring-positioned event envelope to every attached subscriber
1145
+ * connection, applying the same capability gate as event replay. */
1146
+ broadcastEventFrame: (event: SdkFrame) => void;
1165
1147
  /** Owns stateRoot-backed revisions and removes their spills on terminal shutdown. */
1166
1148
  revisions: RevisionStore;
1167
1149
  /** Releases all snapshot pins before the revision store is closed. */
@@ -1171,6 +1153,8 @@ interface SessionRuntime {
1171
1153
  /** Discovery scope is fixed before publication; a live default endpoint is never rotated in place. */
1172
1154
  endpointScope: "default" | "chat";
1173
1155
  idleSeq: number;
1156
+ /** Stops delayed session-name observation when this runtime loses authority. */
1157
+ stopSessionNameObserver: () => void;
1174
1158
  /** Interactive asks awaiting a remote answer, by action id. */
1175
1159
  pendingInteractive: Map<string, PendingInteractiveAsk>;
1176
1160
  /** Deregisters this session's ask answer source. */
@@ -1183,6 +1167,12 @@ interface SessionRuntime {
1183
1167
  notificationsActive: boolean;
1184
1168
  /** Provider ownership state is independent from the already-published core SDK runtime. */
1185
1169
  notificationOwnerState: "ready" | "retry" | "blocked";
1170
+ /**
1171
+ * Ownership-relevant configuration identity this runtime's owner state was
1172
+ * proved under. A settled outcome may only be applied while it still matches,
1173
+ * so a credential/destination/enablement change forces a re-proof.
1174
+ */
1175
+ notificationOwnerKey?: string;
1186
1176
  /** Rejects new SDK frames while a leased terminal response drains. */
1187
1177
  inboundFenced: boolean;
1188
1178
  /** Set as soon as terminal teardown is requested, before startup settles. */
@@ -1216,14 +1206,14 @@ interface SessionRuntime {
1216
1206
  /** This runtime's own host-liveness publication; only its teardown may retract it. */
1217
1207
  evidencePublication?: SessionHostRuntimePublication;
1218
1208
  brokerRegistrationReleased: boolean;
1219
- /** Managed Telegram root registration released during terminal teardown. */
1220
- notificationRootRegistration?: { settings: Settings; cwd: string; registrationToken: string };
1221
1209
  verbosity: "lean" | "verbose";
1222
1210
  sessionTag: string;
1223
1211
  /** Whether the agent loop is currently running (drives the typing indicator). */
1224
1212
  busy: boolean;
1225
1213
  /** Prompt command/turn identities awaiting their corresponding agent_start. */
1226
1214
  pendingPromptCorrelations: Array<{ commandId: string; turnId: string }>;
1215
+ /** SDK run tokens bind an accepted queued follow-up to only its matching agent_start. */
1216
+ pendingPromptCorrelationsBySdkRunToken: Map<string, { commandId: string; turnId: string }>;
1227
1217
  /** Identity bound to the agent lifecycle currently in flight. */
1228
1218
  activePromptCorrelation?: { commandId: string; turnId: string };
1229
1219
  /** Binds the executing Agent run to a correlated prompt so cleanup targets only it. */
@@ -1243,7 +1233,7 @@ interface SessionRuntime {
1243
1233
  notePromptReconciliation: (
1244
1234
  correlation: { commandId: string; turnId: string } | undefined,
1245
1235
  frame: { type: "agent_start" | "agent_end" } | { type: "agent_failed"; error: unknown },
1246
- ) => void;
1236
+ ) => void | Promise<void>;
1247
1237
  /** Settles and emits one sanitized correlated prompt failure. */
1248
1238
  emitPromptFailure: (correlation: { commandId: string; turnId: string }, error: unknown) => void;
1249
1239
  /** Records correlated lifecycle frames for replay and delivers them only to the accepted requester after acknowledgement. */
@@ -1286,13 +1276,27 @@ interface SessionRuntime {
1286
1276
  * frames so a stale live edit can never be emitted after the finalized turn. */
1287
1277
  turnClosed?: boolean;
1288
1278
  /** Finalized while provisional policy was held; flush exactly once on stable activation. */
1289
- pendingFinal?: { text?: string; messageRef?: string };
1279
+ pendingFinal?: {
1280
+ window: number;
1281
+ receipts: Array<{ text: string; messageRef?: string; origin: "user" | "autonomous" | "continuation" }>;
1282
+ };
1283
+ /** Monotonic user-request boundary for deferred lean delivery. */
1284
+ settlementWindow: number;
1285
+ /** Provenance of the currently executing assistant turn. */
1286
+ currentTurnSettlementOrigin?: "user" | "autonomous" | "continuation";
1287
+ /** Immutable settlement boundary captured when the current turn begins. */
1288
+ currentTurnSettlementWindow?: number;
1290
1289
  /**
1291
- * Lean-mode deferred final answer: latest assistant text observed at `turn_end`,
1292
- * emitted once at `agent_end` so intermediate tool-turn narration does not flood
1293
- * remote clients. Cleared after flush or when replaced by a newer turn.
1290
+ * Lean-mode deferred receipts for the current user-request settlement window.
1291
+ * Ordinary tool-loop turns retain latest-turn-wins behaviour. An autonomous
1292
+ * continuation has no new user request, so it appends instead of erasing the
1293
+ * prior receipt. The small fixed receipt bound prevents an unbounded idle wait
1294
+ * from retaining the full transcript.
1294
1295
  */
1295
- pendingSettled?: { text: string; messageRef?: string };
1296
+ pendingSettled?: {
1297
+ window: number;
1298
+ receipts: Array<{ text: string; messageRef?: string; origin: "user" | "autonomous" | "continuation" }>;
1299
+ };
1296
1300
  /** SDK control frames received during provisional ownership; replayed only after stable activation. */
1297
1301
  deferredInboundControls: Array<() => void>;
1298
1302
  /** Started tool calls awaiting a terminal activity frame, keyed by tool call id. */
@@ -1355,11 +1359,22 @@ type SessionStartResult = {
1355
1359
  suppressExtensionError?: boolean;
1356
1360
  };
1357
1361
 
1362
+ /** Ring append plus positioned live broadcast. An event retained for replay
1363
+ * must also reach already-attached subscribers live as the same positioned
1364
+ * envelope, or they can only observe it by issuing another replay. */
1365
+ function emitSessionEvent(
1366
+ runtime: Pick<SessionRuntime, "host" | "broadcastEventFrame">,
1367
+ frame: { type: string; [key: string]: unknown },
1368
+ payload: Record<string, unknown> = frame,
1369
+ ): void {
1370
+ runtime.broadcastEventFrame(runtime.host.emitEvent({ kind: frame.type, payload }));
1371
+ }
1372
+
1358
1373
  function pushSessionFrame(
1359
- runtime: Pick<SessionRuntime, "server" | "host">,
1374
+ runtime: Pick<SessionRuntime, "server" | "host" | "broadcastEventFrame">,
1360
1375
  frame: { type: string; [key: string]: unknown },
1361
1376
  ): void {
1362
- runtime.host.emitEvent({ kind: frame.type, payload: frame });
1377
+ emitSessionEvent(runtime, frame);
1363
1378
  if (frame.type === "turn_stream") {
1364
1379
  runtime.server.pushTurnStreamUnchecked(
1365
1380
  String(frame.sessionId),
@@ -1374,30 +1389,30 @@ function pushSessionFrame(
1374
1389
  }
1375
1390
 
1376
1391
  async function pushTerminalSessionFrame(
1377
- runtime: Pick<SessionRuntime, "server" | "host">,
1392
+ runtime: Pick<SessionRuntime, "server" | "host" | "broadcastEventFrame">,
1378
1393
  frame: { type: "session_closed"; sessionId: string },
1379
1394
  ): Promise<boolean> {
1380
- runtime.host.emitEvent({ kind: frame.type, payload: frame });
1395
+ emitSessionEvent(runtime, frame);
1381
1396
  return await runtime.server.pushFrameAndWait(JSON.stringify(frame), 1_000);
1382
1397
  }
1383
1398
 
1384
1399
  function pushFileAttachment(
1385
- runtime: Pick<SessionRuntime, "server" | "host">,
1400
+ runtime: Pick<SessionRuntime, "server" | "host" | "broadcastEventFrame">,
1386
1401
  frame: { type: "file_attachment"; sessionId: string; name: string; mime?: string; caption?: string },
1387
1402
  data: Buffer,
1388
1403
  ): void {
1389
- runtime.host.emitEvent({ kind: frame.type, payload: { ...frame, data: data.toString("base64") } });
1404
+ emitSessionEvent(runtime, frame, { ...frame, data: data.toString("base64") });
1390
1405
  runtime.server.pushFileAttachmentUnchecked(frame.sessionId, frame.name, frame.mime, data, frame.caption);
1391
1406
  }
1392
1407
 
1393
1408
  /** Agent lifecycle is SDK session truth, independent of optional chat delivery. */
1394
1409
  function emitAgentLifecycle(
1395
- runtime: Pick<SessionRuntime, "server" | "host">,
1410
+ runtime: Pick<SessionRuntime, "server" | "host" | "broadcastEventFrame">,
1396
1411
  frame: { type: "agent_start" | "agent_end"; sessionId: string; commandId?: string; turnId?: string },
1397
1412
  ): void {
1398
1413
  try {
1399
1414
  const json = JSON.stringify(frame);
1400
- runtime.host.emitEvent({ kind: frame.type, payload: frame });
1415
+ emitSessionEvent(runtime, frame);
1401
1416
  runtime.server.pushFrame(json);
1402
1417
  } catch (error) {
1403
1418
  logger.warn(`sdk: lifecycle delivery failed: ${String(error)}`);
@@ -2299,6 +2314,9 @@ function sdkQuerySurface(
2299
2314
  turnId?: string;
2300
2315
  clientRef?: string;
2301
2316
  }) => unknown = () => ({ status: "unknown" }),
2317
+ steerStatusLookup: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown = () => ({
2318
+ status: "unknown",
2319
+ }),
2302
2320
  ): SessionSurface {
2303
2321
  return createSdkSurfaceFactory({
2304
2322
  ctx,
@@ -2309,6 +2327,7 @@ function sdkQuerySurface(
2309
2327
  configOverrides,
2310
2328
  settings,
2311
2329
  turnResultLookup,
2330
+ steerStatusLookup,
2312
2331
  hostTools: () => getInstalledDefinitions("host_tools") !== undefined,
2313
2332
  }).query;
2314
2333
  }
@@ -2379,8 +2398,14 @@ function sdkControlSurface(
2379
2398
  requesterConnectionId?: string,
2380
2399
  clientRef?: string,
2381
2400
  trackReconciliation?: boolean,
2401
+ preflightAbort?: () => void | Promise<void>,
2402
+ reconciliationKind?: ReconciliationKind,
2403
+ sdkRunToken?: string,
2404
+ ) => void | Promise<void> = () => {},
2405
+ onPromptFailed: (
2406
+ correlation: { commandId: string; turnId: string },
2407
+ error: unknown,
2382
2408
  ) => void | Promise<void> = () => {},
2383
- onPromptFailed: (correlation: { commandId: string; turnId: string }, error: unknown) => void = () => {},
2384
2409
  onPromptAcceptFailed: (correlation: { commandId: string; turnId: string }) => void = () => {},
2385
2410
  acceptGateResolution: () => boolean,
2386
2411
  trackGateResolution: <T>(resolution: Promise<T>) => Promise<T>,
@@ -2390,13 +2415,41 @@ function sdkControlSurface(
2390
2415
  settings?: Settings,
2391
2416
  configOverrides: Map<string, unknown> = new Map(),
2392
2417
  configRevision: { current: number } = { current: 0 },
2393
- elevationAuthorityToken?: string,
2394
2418
  abortOwnedPrompt: (
2395
2419
  connectionId: string | undefined,
2396
2420
  ) => Promise<{ aborted: true; disposition: "cancelled" | "already_terminal" | "idle" }> = async () => ({
2397
2421
  aborted: true,
2398
2422
  disposition: "idle",
2399
2423
  }),
2424
+ abortTerminalPrompt: (
2425
+ connectionId: string | undefined,
2426
+ scope: AbortScope,
2427
+ idempotencyKey?: string,
2428
+ preflightCancel?: {
2429
+ hasPending: () => boolean;
2430
+ cancel: () => void;
2431
+ },
2432
+ // True only when NO OTHER connection has a pending preflight admission;
2433
+ // the session-wide preflight seam must not be invoked while another
2434
+ // connection's active preflight could be cancelled by it (review P1).
2435
+ noOtherConnectionPreflights?: () => boolean,
2436
+ ) => Promise<
2437
+ | {
2438
+ ok: true;
2439
+ outcome:
2440
+ | "stopped"
2441
+ | "stopped_owned"
2442
+ | "no_active_turn"
2443
+ | "already_terminal"
2444
+ | "no_store"
2445
+ | "no_effect"
2446
+ | "no_effect_replay"
2447
+ | "pending_replay"
2448
+ | "uncertain_replay";
2449
+ stored?: { responseState: string; responsePayloadHash: string; terminalPublished: boolean };
2450
+ }
2451
+ | { ok: false; reason: "worker_unsettled" | "owned_unsettled" | "conflict" | "reservation_failed" }
2452
+ > = async () => ({ ok: true, outcome: "no_active_turn" }),
2400
2453
  skillRecon?: {
2401
2454
  admit: (clientRef?: string) => void;
2402
2455
  release: (clientRef?: string) => void;
@@ -2405,15 +2458,27 @@ function sdkControlSurface(
2405
2458
  clientRef?: string,
2406
2459
  extra?: { skillName?: string },
2407
2460
  ) => Promise<void>;
2461
+ cancel: (correlation: { commandId: string; turnId: string }) => Promise<void>;
2408
2462
  noteTransition: (
2409
2463
  correlation: { commandId: string; turnId: string } | undefined,
2410
- frame: { type: "agent_start" | "agent_end" } | { type: "agent_failed"; error: unknown },
2464
+ frame:
2465
+ | { type: "agent_start" | "agent_end"; content?: TurnResultContent }
2466
+ | { type: "agent_failed"; error: unknown; content?: TurnResultContent },
2411
2467
  ) => Promise<void>;
2412
2468
  lookup: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown;
2469
+ reserveSteer?: KindAwareReconciliation["reserveSteer"];
2470
+ settleSteer?: KindAwareReconciliation["settleSteer"];
2471
+ },
2472
+ terminalAbortSeams?: {
2473
+ getTerminalTurnEpoch: () => number | undefined;
2474
+ cancelPendingPreflightForTerminalAbort: () => void;
2475
+ captureTerminalAbortSteeringSnapshot?: () => void;
2476
+ discardTerminalAbortSteeringSnapshot?: (token: number) => void;
2477
+ rebindTerminalAbortSteeringSnapshot?: (token: number) => void;
2413
2478
  },
2414
2479
  ): ControlSurface & {
2415
- cancelPendingPreflights(): void;
2416
- cancelPendingPreflightsForConnection(connectionId: string): void;
2480
+ cancelPendingPreflights(): Promise<void>;
2481
+ cancelPendingPreflightsForConnection(connectionId: string): Promise<void>;
2417
2482
  } {
2418
2483
  const unavailable = (operation: string, reason: string) => () => {
2419
2484
  throw Object.assign(new Error(`${operation} is unavailable: ${reason}`), { code: "unavailable" });
@@ -2454,11 +2519,29 @@ function sdkControlSurface(
2454
2519
  }
2455
2520
  return "unknown";
2456
2521
  };
2457
- const sendSteer = async (text: string) => {
2458
- // Await admission so a rejection (e.g. handoff in progress) surfaces as a
2459
- // control error instead of a false `accepted: true`.
2460
- await api.sendUserMessage(text, { deliverAs: "steer" });
2461
- return { commandId: crypto.randomUUID(), accepted: true };
2522
+ const sendSteer = async (text: string, clientRef?: string) => {
2523
+ if (clientRef === undefined) {
2524
+ const correlation = { commandId: crypto.randomUUID(), turnId: crypto.randomUUID() };
2525
+ await api.sendUserMessage(text, { deliverAs: "steer" });
2526
+ return { ...correlation, accepted: true };
2527
+ }
2528
+ const normalizedClientRef = clientRef.trim();
2529
+ if (!skillRecon?.reserveSteer || !skillRecon.settleSteer)
2530
+ throw Object.assign(new Error("Steer reconciliation is unavailable."), { code: "unavailable" });
2531
+ const reservation = await skillRecon.reserveSteer(normalizedClientRef, text);
2532
+ if (reservation.replay) return { sessionId: ctx.sessionManager.getSessionId(), ...reservation.result };
2533
+ try {
2534
+ await api.sendUserMessage(text, { deliverAs: "steer" });
2535
+ return {
2536
+ sessionId: ctx.sessionManager.getSessionId(),
2537
+ ...(await skillRecon.settleSteer(normalizedClientRef, "accepted")),
2538
+ };
2539
+ } catch (error) {
2540
+ return {
2541
+ sessionId: ctx.sessionManager.getSessionId(),
2542
+ ...(await skillRecon.settleSteer(normalizedClientRef, "rejected", error)),
2543
+ };
2544
+ }
2462
2545
  };
2463
2546
  const resolveModel = (id: string) => {
2464
2547
  const [provider, ...modelId] = id.split("/");
@@ -2525,19 +2608,22 @@ function sdkControlSurface(
2525
2608
  return unavailable(operation, "no typed session seam is installed")();
2526
2609
  return ctx.sdkControl(operation, input);
2527
2610
  };
2528
- const pendingPreflightCancellations = new Map<string, { connectionId?: string; cancel: () => void }>();
2611
+ const pendingPreflightCancellations = new Map<string, { connectionId?: string; cancel: () => Promise<void> }>();
2529
2612
  const preflightKey = (connectionId: string | undefined, correlation: { commandId: string; turnId: string }) =>
2530
2613
  `${connectionId ?? ""}\0${correlation.commandId}\0${correlation.turnId}`;
2531
- const cancelPendingPreflights = () => {
2532
- for (const { cancel } of pendingPreflightCancellations.values()) cancel();
2614
+ const cancelPendingPreflights = async () => {
2615
+ await Promise.all([...pendingPreflightCancellations.values()].map(async entry => await entry.cancel()));
2533
2616
  };
2534
- const cancelPendingPreflightsForConnection = (connectionId: string) => {
2535
- for (const entry of pendingPreflightCancellations.values())
2536
- if (entry.connectionId === connectionId) entry.cancel();
2617
+ const cancelPendingPreflightsForConnection = async (connectionId: string) => {
2618
+ await Promise.all(
2619
+ [...pendingPreflightCancellations.values()]
2620
+ .filter(entry => entry.connectionId === connectionId)
2621
+ .map(async entry => await entry.cancel()),
2622
+ );
2537
2623
  };
2538
2624
  const isSessionBusy = () => isBusy() || ctx.isIdle?.() === false;
2539
2625
  const awaitAbortReady = async () => {
2540
- cancelPendingPreflights();
2626
+ await cancelPendingPreflights();
2541
2627
  await (ctx.abort as () => unknown)();
2542
2628
  while (isSessionBusy()) {
2543
2629
  await Bun.sleep(10);
@@ -2602,39 +2688,71 @@ function sdkControlSurface(
2602
2688
  : text;
2603
2689
  const commandId = crypto.randomUUID();
2604
2690
  const turnId = crypto.randomUUID();
2691
+ const sdkRunToken = deliverAs === "followUp" ? crypto.randomUUID() : undefined;
2605
2692
  type PreflightTerminalResult = { status: "accepted" } | { status: "rejected"; error: unknown };
2606
2693
  const preflight = Promise.withResolvers<PreflightTerminalResult>();
2694
+ const preflightController = new AbortController();
2695
+ const cancellationError = Object.assign(new Error("Prompt preflight was cancelled before execution."), {
2696
+ code: "busy",
2697
+ });
2607
2698
  let preflightSettled = false;
2699
+ let accepting = false;
2608
2700
  let accepted = false;
2701
+ let submission: Promise<void> | undefined;
2702
+ const submissionSettled = Promise.withResolvers<void>();
2703
+ let cancellation: Promise<void> | undefined;
2609
2704
  const correlation = { commandId, turnId };
2705
+ const key = preflightKey(requesterConnectionId, correlation);
2610
2706
  const settlePreflight = (result: PreflightTerminalResult) => {
2611
2707
  if (preflightSettled) return;
2612
2708
  preflightSettled = true;
2613
2709
  preflight.resolve(result);
2614
2710
  };
2615
- const cancelPreflight = () =>
2616
- settlePreflight({
2617
- status: "rejected",
2618
- error: Object.assign(new Error("Prompt preflight was cancelled before execution."), { code: "busy" }),
2619
- });
2620
- pendingPreflightCancellations.set(preflightKey(requesterConnectionId, correlation), {
2711
+ // This is retained by the accepted durable record. It settles only this
2712
+ // submission; terminal authority remains with the pending cancellation owner.
2713
+ const settleSubmission = async () => {
2714
+ preflightController.abort();
2715
+ await submissionSettled.promise;
2716
+ };
2717
+ const cancelPreflight = () => {
2718
+ cancellation ??= (async () => {
2719
+ preflightController.abort();
2720
+ if (!accepting) settlePreflight({ status: "rejected", error: cancellationError });
2721
+ const ownedCancellation = await abortOwnedPrompt(requesterConnectionId);
2722
+ if (ownedCancellation.disposition === "idle") await settleSubmission();
2723
+ })();
2724
+ return cancellation;
2725
+ };
2726
+ pendingPreflightCancellations.set(key, {
2621
2727
  connectionId: requesterConnectionId,
2622
2728
  cancel: cancelPreflight,
2623
2729
  });
2624
2730
  const settleAccepted = async () => {
2625
2731
  if (preflightSettled) return;
2626
- accepted = true;
2732
+ accepting = true;
2627
2733
  try {
2628
- await onPromptAccepted(correlation, requesterConnectionId, trimmedClientRef, trackReconciliation);
2734
+ await onPromptAccepted(
2735
+ correlation,
2736
+ requesterConnectionId,
2737
+ trimmedClientRef,
2738
+ trackReconciliation,
2739
+ settleSubmission,
2740
+ "prompt",
2741
+ sdkRunToken,
2742
+ );
2629
2743
  } catch (error) {
2744
+ accepting = false;
2630
2745
  // Durable acceptance failed, so the prompt was never accepted: reject the
2631
2746
  // control preflight and rethrow so the awaiting session does not execute it.
2632
- accepted = false;
2633
2747
  onPromptAcceptFailed(correlation);
2634
2748
  settlePreflight({ status: "rejected", error });
2635
2749
  throw error;
2636
2750
  }
2751
+ accepting = false;
2752
+ accepted = true;
2753
+ pendingPreflightCancellations.delete(key);
2637
2754
  settlePreflight({ status: "accepted" });
2755
+ if (preflightController.signal.aborted) throw cancellationError;
2638
2756
  };
2639
2757
  // Durable fence preferred; keep legacy onPreflightAccepted for hosts/tests that only fire the sync hook.
2640
2758
  const onPreflightAcceptCommit = settleAccepted;
@@ -2645,17 +2763,19 @@ function sdkControlSurface(
2645
2763
  };
2646
2764
  // Do not acknowledge the prompt until AgentSession's async preflight
2647
2765
  // succeeds. The terminal result records correlation before agent_start can fire.
2648
- let submission: Promise<void> | undefined;
2649
2766
  try {
2650
2767
  submission = Promise.resolve(
2651
2768
  api.sendUserMessage(content, {
2652
2769
  ...(deliverAs ? { deliverAs } : !forceFresh && isBusy() ? { deliverAs: "steer" as const } : {}),
2653
2770
  onPreflightAcceptCommit,
2654
2771
  onPreflightAccepted,
2772
+ preflightSignal: preflightController.signal,
2773
+ ...(sdkRunToken ? { sdkRunToken } : {}),
2655
2774
  }),
2656
2775
  );
2657
2776
  } catch (error) {
2658
- if (accepted) onPromptFailed(correlation, error);
2777
+ submissionSettled.resolve();
2778
+ if (accepted && !preflightController.signal.aborted) void onPromptFailed(correlation, error);
2659
2779
  else settlePreflight({ status: "rejected", error });
2660
2780
  }
2661
2781
  if (submission) {
@@ -2668,10 +2788,12 @@ function sdkControlSurface(
2668
2788
  code: "busy",
2669
2789
  }),
2670
2790
  });
2791
+ submissionSettled.resolve();
2671
2792
  },
2672
2793
  error => {
2673
- if (accepted) onPromptFailed(correlation, error);
2794
+ if (accepted && !preflightController.signal.aborted) void onPromptFailed(correlation, error);
2674
2795
  else settlePreflight({ status: "rejected", error });
2796
+ submissionSettled.resolve();
2675
2797
  },
2676
2798
  );
2677
2799
  }
@@ -2683,19 +2805,16 @@ function sdkControlSurface(
2683
2805
  if (trackReconciliation) releasePromptAdmission(trimmedClientRef);
2684
2806
  throw error;
2685
2807
  } finally {
2686
- pendingPreflightCancellations.delete(preflightKey(requesterConnectionId, correlation));
2808
+ pendingPreflightCancellations.delete(key);
2687
2809
  }
2688
2810
  };
2689
2811
  const surface: ControlSurface & {
2690
- cancelPendingPreflights(): void;
2691
- cancelPendingPreflightsForConnection(connectionId: string): void;
2812
+ cancelPendingPreflights(): Promise<void>;
2813
+ cancelPendingPreflightsForConnection(connectionId: string): Promise<void>;
2692
2814
  } = {
2693
- authorizeElevationClaim: (sdkId, input, capability) =>
2694
- elevationAuthorityToken !== undefined &&
2695
- verifyElevationCapability(elevationAuthorityToken, capability, sdkId, input),
2696
2815
  prompt: (text, images, clientRef) =>
2697
2816
  submitPrompt(text, images, false, undefined, true, controlRequesterContext.getStore(), clientRef, true),
2698
- steer: text => sendSteer(text),
2817
+ steer: (text, clientRef) => sendSteer(text, clientRef),
2699
2818
  followUp: text => submitPrompt(text, undefined, false, "followUp", false, controlRequesterContext.getStore()),
2700
2819
  abort: async () => {
2701
2820
  const requesterConnectionId = controlRequesterContext.getStore();
@@ -2703,12 +2822,193 @@ function sdkControlSurface(
2703
2822
  entry => entry.connectionId === requesterConnectionId,
2704
2823
  );
2705
2824
  if (pendingPreflight) {
2706
- if (requesterConnectionId) cancelPendingPreflightsForConnection(requesterConnectionId);
2707
- else cancelPendingPreflights();
2825
+ // Fire-and-forget the cancel: the preflight signal is aborted
2826
+ // immediately (synchronously inside cancelPreflight), but the
2827
+ // full cleanup (settleRun awaiting executionSettled) may be
2828
+ // gated by a durable acceptance commit that this same abort
2829
+ // path fences. Awaiting it would deadlock. The response is
2830
+ // published once the pending writes settle.
2831
+ if (requesterConnectionId) void cancelPendingPreflightsForConnection(requesterConnectionId);
2832
+ else void cancelPendingPreflights();
2708
2833
  return { aborted: true, disposition: "preflight_cancelled" };
2709
2834
  }
2710
2835
  return await abortOwnedPrompt(requesterConnectionId);
2711
2836
  },
2837
+ abortTerminal: async (input, idempotencyKey) => {
2838
+ // Terminal abort (C04 mode:"terminal", approved plan): stop the root
2839
+ // worker's current turn and block only its own continuation routes.
2840
+ // Left-running owned work (background Bash/task jobs, detached
2841
+ // subagents) keeps running and its completions are delivered normally
2842
+ // through the existing followUp/prompt path as a fresh turn — owned
2843
+ // delivery is intentionally NOT suppressed.
2844
+ const requesterConnectionId = controlRequesterContext.getStore();
2845
+ // Capture the requester's preflight entries AT ADMISSION: a successor
2846
+ // prompt pipelined by the same connection while the abort awaits must
2847
+ // never be cancelled as part of this abort (review thread P1).
2848
+ const capturedRequesterPreflights = requesterConnectionId
2849
+ ? [...pendingPreflightCancellations.values()].filter(entry => entry.connectionId === requesterConnectionId)
2850
+ : [...pendingPreflightCancellations.values()].filter(entry => entry.connectionId === undefined);
2851
+ const cancelCapturedPreflights = () => {
2852
+ for (const entry of capturedRequesterPreflights) entry.cancel();
2853
+ };
2854
+ // Preflight cancellation happens INSIDE abortTerminalPrompt, AFTER the
2855
+ // durable admission/replay decision: a no-store request or a same-key
2856
+ // replay/conflict must NOT cancel a pending prompt — only a newly
2857
+ // admitted abort may (review thread P2).
2858
+ const scope: AbortScope = input.scope === "owned" ? "owned" : "turn";
2859
+ const outcome = await abortTerminalPrompt(
2860
+ requesterConnectionId,
2861
+ scope,
2862
+ idempotencyKey,
2863
+ {
2864
+ hasPending: () => capturedRequesterPreflights.length > 0,
2865
+ cancel: cancelCapturedPreflights,
2866
+ },
2867
+ // The seam cancels the SESSION-WIDE preflight controller; the
2868
+ // internal per-connection map lets the queued requester's abort
2869
+ // verify no OTHER connection has a pending admission before
2870
+ // invoking it (review thread P1).
2871
+ () =>
2872
+ ![...pendingPreflightCancellations.values()].some(entry => entry.connectionId !== requesterConnectionId),
2873
+ );
2874
+ // Preflight cancellation happens ONLY for a NEWLY ADMITTED abort,
2875
+ // after the durable admission/replay decision inside
2876
+ // abortTerminalPrompt: a no-store request or a same-key
2877
+ // replay/conflict must never cancel a pending prompt (review
2878
+ // thread P2). A turn.prompt still in PREFLIGHT has no
2879
+ // promptSubmissions entry, so the new no-active abort cancels the
2880
+ // connection's pending preflights and invalidates the underlying
2881
+ // session preflight — otherwise the prompt could start after this
2882
+ // abort.
2883
+ // Treat ANY outcome carrying the durable replay marker as a
2884
+ // non-admission: after the in-memory dispatch entry expires or a
2885
+ // restart, a SUCCESSFUL replay returns stopped/stopped_owned with
2886
+ // `stored`, and cancelling the requester's unrelated in-preflight
2887
+ // prompt there would give an idempotency replay real effects (review
2888
+ // thread P1). Only a newly admitted abort may cancel preflights.
2889
+ const outcomeIsNewAdmission =
2890
+ outcome.ok &&
2891
+ outcome.stored === undefined &&
2892
+ outcome.outcome !== "no_store" &&
2893
+ outcome.outcome !== "no_effect";
2894
+ if (outcomeIsNewAdmission && capturedRequesterPreflights.length > 0) {
2895
+ // Cancel ONLY the preflight entries captured when the abort was
2896
+ // admitted: a successor prompt pipelined by the same connection
2897
+ // while the abort was completing must never be cancelled as part of
2898
+ // this abort (review thread P1). The session seam stays
2899
+ // requester-gated so an unrelated connection's prompt is not
2900
+ // failed: it cancels the SESSION-WIDE preflight controller, so it
2901
+ // is invoked only when no OTHER connection has a pending admission
2902
+ // (review thread P1).
2903
+ cancelCapturedPreflights();
2904
+ const otherConnectionPreflights = [...pendingPreflightCancellations.values()].some(
2905
+ entry => entry.connectionId !== requesterConnectionId,
2906
+ );
2907
+ if (!otherConnectionPreflights) terminalAbortSeams?.cancelPendingPreflightForTerminalAbort?.();
2908
+ }
2909
+ if (!outcome.ok && outcome.reason === "conflict") {
2910
+ // Throw a typed control error instead of returning a nested result
2911
+ // so dispatchControl produces a TOP-LEVEL ok:false response with
2912
+ // code idempotency_conflict (the generic cache does the same for
2913
+ // in-cache conflicts; this path covers the evicted/restart case).
2914
+ throw new TypedControlError("idempotency_conflict", "Idempotency key was reused with different input.");
2915
+ }
2916
+ if (!outcome.ok) {
2917
+ return {
2918
+ ok: true,
2919
+ selection: scope,
2920
+ turn: "uncertain",
2921
+ ownedWork: scope === "turn" ? "left_running" : "uncertain",
2922
+ automaticDelivery: scope === "turn" ? "enabled" : "none",
2923
+ resumeOnOwnedCompletion: scope === "turn",
2924
+ reason: outcome.reason,
2925
+ };
2926
+ }
2927
+ if (outcome.outcome === "no_active_turn" || outcome.outcome === "already_terminal") {
2928
+ // No active root turn to stop: process-local no-effect, no fence.
2929
+ return {
2930
+ ok: true,
2931
+ selection: scope,
2932
+ turn: "no_active_turn",
2933
+ terminal: "terminal_no_effect",
2934
+ ...(outcome.stored ? { replay: outcome.stored } : {}),
2935
+ };
2936
+ }
2937
+ if (outcome.outcome === "no_store") {
2938
+ // No file-backed reconciliation owner: terminal admission is gated
2939
+ // off before any fence/stop/cleanup (plan AC 5).
2940
+ return {
2941
+ ok: true,
2942
+ selection: scope,
2943
+ turn: "no_store",
2944
+ terminal: "terminal_no_effect",
2945
+ };
2946
+ }
2947
+ if (outcome.outcome === "no_effect") {
2948
+ // Initial marker could not be persisted before any destructive work
2949
+ // (AC 10): process-local no-effect, no fence, no stop. A
2950
+ // marker-failure reservation replays through this same outcome, so
2951
+ // one idempotency key always returns the same result after
2952
+ // eviction/restart.
2953
+ return {
2954
+ ok: true,
2955
+ selection: scope,
2956
+ turn: "no_effect",
2957
+ terminal: "terminal_no_effect",
2958
+ ...(outcome.stored ? { replay: outcome.stored } : {}),
2959
+ };
2960
+ }
2961
+ if (outcome.outcome === "no_effect_replay") {
2962
+ // Durable idle/already-terminal reservation replayed: exact
2963
+ // no_active_turn, so a same-key retry after eviction/restart never
2964
+ // aborts an unrelated later turn.
2965
+ return {
2966
+ ok: true,
2967
+ selection: scope,
2968
+ turn: "no_active_turn",
2969
+ terminal: "terminal_no_effect",
2970
+ ...(outcome.stored ? { replay: outcome.stored } : {}),
2971
+ };
2972
+ }
2973
+ if (outcome.outcome === "pending_replay" || outcome.outcome === "uncertain_replay") {
2974
+ // A crashed or restart-settled attempt left a non-stopped durable
2975
+ // marker (AC 4/41): replay safe uncertainty without re-running the
2976
+ // stop/cleanup/event, carrying the stored immutable row.
2977
+ return {
2978
+ ok: true,
2979
+ selection: scope,
2980
+ turn: "uncertain",
2981
+ ownedWork: scope === "turn" ? "left_running" : "uncertain",
2982
+ automaticDelivery: scope === "turn" ? "enabled" : "none",
2983
+ resumeOnOwnedCompletion: scope === "turn",
2984
+ reason: outcome.outcome === "pending_replay" ? "replay_pending" : "replay_uncertain",
2985
+ ...(outcome.stored ? { replay: outcome.stored } : {}),
2986
+ };
2987
+ }
2988
+ if (outcome.outcome === "stopped_owned") {
2989
+ // scope:"owned" stopped the exact captured owned work and proved
2990
+ // quiescence (every captured generation/entry terminal); stopped
2991
+ // work can never resume the agent.
2992
+ return {
2993
+ ok: true,
2994
+ selection: "owned",
2995
+ turn: "stopped",
2996
+ ownedWork: "stopped",
2997
+ automaticDelivery: "none",
2998
+ resumeOnOwnedCompletion: false,
2999
+ ...(outcome.stored ? { replay: outcome.stored } : {}),
3000
+ };
3001
+ }
3002
+ return {
3003
+ ok: true,
3004
+ selection: "turn",
3005
+ turn: "stopped",
3006
+ ownedWork: "left_running",
3007
+ automaticDelivery: "enabled",
3008
+ resumeOnOwnedCompletion: true,
3009
+ ...(outcome.stored ? { replay: outcome.stored } : {}),
3010
+ };
3011
+ },
2712
3012
  abortAndPrompt: async text => {
2713
3013
  await awaitAbortReady();
2714
3014
  return await submitPrompt(text, undefined, true, undefined, false, controlRequesterContext.getStore());
@@ -2727,7 +3027,7 @@ function sdkControlSurface(
2727
3027
  pending.completeDirect();
2728
3028
  return { resolved: true };
2729
3029
  },
2730
- answerGate: async (id, response, expectedSessionId, idempotencyKey, _elevationRequestId) => {
3030
+ answerGate: async (id, response, expectedSessionId, idempotencyKey) => {
2731
3031
  if (!acceptGateResolution())
2732
3032
  throw Object.assign(new Error("Workflow gate is no longer answerable."), { code: "resource_gone" });
2733
3033
  if (expectedSessionId === undefined) auditMissingExpectedSessionId("workflow.gate_answer");
@@ -2802,7 +3102,7 @@ function sdkControlSurface(
2802
3102
  code: "terminal_uncertain",
2803
3103
  });
2804
3104
  },
2805
- approvePlan: async (id, choice, expectedSessionId, _elevationRequestId) => {
3105
+ approvePlan: async (id, choice, expectedSessionId) => {
2806
3106
  if (!acceptGateResolution())
2807
3107
  throw Object.assign(new Error("Workflow plan is no longer answerable."), { code: "resource_gone" });
2808
3108
  if (expectedSessionId === undefined) auditMissingExpectedSessionId("workflow.plan_approve");
@@ -2888,6 +3188,7 @@ function sdkControlSurface(
2888
3188
  const commandId = crypto.randomUUID();
2889
3189
  const turnId = crypto.randomUUID();
2890
3190
  const correlation = { commandId, turnId };
3191
+ const requesterConnectionId = controlRequesterContext.getStore();
2891
3192
  if (skillRecon) {
2892
3193
  try {
2893
3194
  await awaitReconciliationReady();
@@ -2898,44 +3199,116 @@ function sdkControlSurface(
2898
3199
  }
2899
3200
  skillRecon.admit(trimmedClientRef);
2900
3201
  }
3202
+ const cancellationError = Object.assign(new Error("Skill preflight was cancelled before execution."), {
3203
+ code: "busy",
3204
+ });
3205
+ const preflightController = new AbortController();
2901
3206
  const { promise: acceptedP, resolve, reject } = Promise.withResolvers<Record<string, unknown>>();
2902
- let phase: "pending" | "accepted" | "rejected" = "pending";
3207
+ let phase: "pending" | "accepting" | "accepted" | "rejected" = "pending";
3208
+ let promptOwned = false;
3209
+ let durableSkillAccepted = false;
3210
+ let admissionReleased = false;
3211
+ const releaseAdmission = () => {
3212
+ if (admissionReleased || durableSkillAccepted) return;
3213
+ admissionReleased = true;
3214
+ skillRecon?.release(trimmedClientRef);
3215
+ };
2903
3216
  const settleAccept = (value: Record<string, unknown>) => {
2904
- if (phase !== "pending") return;
3217
+ if (phase !== "pending" && phase !== "accepting") return;
2905
3218
  phase = "accepted";
2906
3219
  resolve(value);
2907
3220
  };
2908
3221
  const settleReject = (error: unknown) => {
2909
- if (phase !== "pending") return;
3222
+ if (phase === "accepted" || phase === "rejected") return;
2910
3223
  phase = "rejected";
2911
3224
  reject(error);
2912
3225
  };
3226
+ const executionSettled = Promise.withResolvers<void>();
3227
+ let cancellation: Promise<void> | undefined;
3228
+ const key = preflightKey(requesterConnectionId, correlation);
3229
+ // This is retained by the accepted durable record. It settles only this
3230
+ // invocation; terminal authority remains with the pending cancellation owner.
3231
+ const settleRun = async () => {
3232
+ preflightController.abort();
3233
+ await executionSettled.promise;
3234
+ };
3235
+ const cancelPreflight = () => {
3236
+ cancellation ??= (async () => {
3237
+ preflightController.abort();
3238
+ if (phase === "pending") {
3239
+ releaseAdmission();
3240
+ settleReject(cancellationError);
3241
+ }
3242
+ const ownedCancellation = await abortOwnedPrompt(requesterConnectionId);
3243
+ if (ownedCancellation.disposition === "idle") {
3244
+ await settleRun();
3245
+ if (durableSkillAccepted && skillRecon) await skillRecon.cancel(correlation);
3246
+ }
3247
+ })();
3248
+ return cancellation;
3249
+ };
3250
+ pendingPreflightCancellations.set(key, {
3251
+ connectionId: requesterConnectionId,
3252
+ cancel: cancelPreflight,
3253
+ });
2913
3254
  let prepared: { name: string; path: string; lineCount?: number; cleanedArgs?: string } | undefined;
2914
- const run = Promise.resolve(
2915
- ctx.invokeSkill(name, args as string | undefined, {
2916
- onSkillPrepared: meta => {
2917
- prepared = meta;
2918
- },
2919
- onPreflightAcceptCommit: async () => {
2920
- const meta = prepared ?? { name: String(name), path: "" };
2921
- if (skillRecon)
2922
- await skillRecon.noteAccepted(correlation, trimmedClientRef, { skillName: meta.name });
2923
- settleAccept({
2924
- accepted: true,
2925
- commandId,
2926
- turnId,
2927
- name: meta.name,
2928
- path: meta.path,
2929
- ...(meta.lineCount !== undefined ? { lineCount: meta.lineCount } : {}),
2930
- ...(meta.cleanedArgs !== undefined ? { args: meta.cleanedArgs } : {}),
2931
- ...(trimmedClientRef ? { clientRef: trimmedClientRef } : {}),
2932
- });
2933
- },
2934
- }),
2935
- ).then(
3255
+ let run: Promise<unknown>;
3256
+ try {
3257
+ run = Promise.resolve(
3258
+ ctx.invokeSkill(name, args as string | undefined, {
3259
+ preflightSignal: preflightController.signal,
3260
+ onSkillPrepared: meta => {
3261
+ prepared = meta;
3262
+ },
3263
+ onPreflightAcceptCommit: async () => {
3264
+ if (preflightController.signal.aborted) throw cancellationError;
3265
+ phase = "accepting";
3266
+ const meta = prepared ?? { name: String(name), path: "" };
3267
+ const acceptedValue = {
3268
+ accepted: true,
3269
+ commandId,
3270
+ turnId,
3271
+ name: meta.name,
3272
+ path: meta.path,
3273
+ ...(meta.lineCount !== undefined ? { lineCount: meta.lineCount } : {}),
3274
+ ...(meta.cleanedArgs !== undefined ? { args: meta.cleanedArgs } : {}),
3275
+ ...(trimmedClientRef ? { clientRef: trimmedClientRef } : {}),
3276
+ };
3277
+ try {
3278
+ await onPromptAccepted(
3279
+ correlation,
3280
+ requesterConnectionId,
3281
+ undefined,
3282
+ false,
3283
+ settleRun,
3284
+ "skill",
3285
+ );
3286
+ promptOwned = requesterConnectionId !== undefined;
3287
+ if (skillRecon) {
3288
+ await skillRecon.noteAccepted(correlation, trimmedClientRef, { skillName: meta.name });
3289
+ durableSkillAccepted = true;
3290
+ }
3291
+ } catch (error) {
3292
+ onPromptAcceptFailed(correlation);
3293
+ releaseAdmission();
3294
+ settleReject(error);
3295
+ throw error;
3296
+ }
3297
+ pendingPreflightCancellations.delete(key);
3298
+ settleAccept(acceptedValue);
3299
+ if (preflightController.signal.aborted) throw cancellationError;
3300
+ },
3301
+ }),
3302
+ );
3303
+ } catch (error) {
3304
+ releaseAdmission();
3305
+ settleReject(error);
3306
+ run = Promise.reject(error);
3307
+ }
3308
+ void run.then(
2936
3309
  result => {
2937
3310
  if (phase === "pending") {
2938
- // Completed without fence (e.g. queue path) — still surface result.
3311
+ pendingPreflightCancellations.delete(key);
2939
3312
  settleAccept({
2940
3313
  accepted: true,
2941
3314
  commandId,
@@ -2943,7 +3316,7 @@ function sdkControlSurface(
2943
3316
  ...(typeof result === "object" && result ? (result as object) : {}),
2944
3317
  ...(trimmedClientRef ? { clientRef: trimmedClientRef } : {}),
2945
3318
  });
2946
- } else if (skillRecon) {
3319
+ } else if (durableSkillAccepted && skillRecon) {
2947
3320
  void skillRecon.noteTransition(correlation, {
2948
3321
  type: "agent_end",
2949
3322
  ...(typeof result === "string"
@@ -2951,20 +3324,25 @@ function sdkControlSurface(
2951
3324
  : {}),
2952
3325
  });
2953
3326
  }
2954
- return result;
3327
+ executionSettled.resolve();
2955
3328
  },
2956
3329
  error => {
2957
- if (phase === "pending") {
2958
- if (skillRecon) skillRecon.release(trimmedClientRef);
3330
+ if (phase === "pending" || phase === "accepting") {
3331
+ releaseAdmission();
2959
3332
  settleReject(error);
2960
- } else if (skillRecon) {
2961
- void skillRecon.noteTransition(correlation, { type: "agent_failed", error });
3333
+ } else if (phase === "accepted" && promptOwned && !preflightController.signal.aborted) {
3334
+ void onPromptFailed(correlation, error);
2962
3335
  }
2963
- throw error;
3336
+ if (durableSkillAccepted && skillRecon && !preflightController.signal.aborted)
3337
+ void skillRecon.noteTransition(correlation, { type: "agent_failed", error });
3338
+ executionSettled.resolve();
2964
3339
  },
2965
3340
  );
2966
- void run.catch(() => {});
2967
- return await acceptedP;
3341
+ try {
3342
+ return await acceptedP;
3343
+ } finally {
3344
+ pendingPreflightCancellations.delete(key);
3345
+ }
2968
3346
  },
2969
3347
  setPlanMode: async on => {
2970
3348
  if (!bindings.has("setPlanMode") || !ctx.setPlanMode)
@@ -3074,7 +3452,11 @@ function sdkControlSurface(
3074
3452
  newSession: () => typed("session.new"),
3075
3453
  forkSession: () => typed("session.fork"),
3076
3454
  resumeSession: id => typed("session.resume", { id }),
3077
- closeSession: () => typed("session.close"),
3455
+ closeSession: capability =>
3456
+ typed(
3457
+ "session.close",
3458
+ capability === undefined ? {} : { [BROKER_RUNTIME_CLOSE_CAPABILITY_FIELD]: capability },
3459
+ ),
3078
3460
  switchSession: id => typed("session.switch", { id }),
3079
3461
  branchSession: entryId => typed("session.branch", { entryId }),
3080
3462
  renameSession: name => typed("session.rename", { name }),
@@ -3528,9 +3910,13 @@ function sdkInboundFrame(commandJson: string | undefined): Record<string, unknow
3528
3910
  }
3529
3911
  }
3530
3912
  /**
3531
- * Ensures every configured chat-provider daemon is ready before the SDK
3532
- * publishes session identity. A rejected ensure is startup-fatal: presenting
3533
- * an identity for a transport that never became available is false success.
3913
+ * Ensures every configured chat-provider daemon is ready.
3914
+ *
3915
+ * This runs strictly AFTER the SDK publishes session identity and its core
3916
+ * endpoint, through the detached ownership coordinator: chat providers are
3917
+ * optional notification adapters, never session authority. A rejected ensure
3918
+ * therefore degrades notification delivery only — the coordinator records it
3919
+ * as `failed`, adapters stay withheld, and a later reconcile re-attempts.
3534
3920
  */
3535
3921
  export async function ensureConfiguredProviderDaemons(
3536
3922
  settings: Settings,
@@ -3560,12 +3946,7 @@ export function createNotificationsExtension(
3560
3946
  api: ExtensionAPI,
3561
3947
  options: {
3562
3948
  settings?: Settings;
3563
- ensureTelegramDaemon?: (input: {
3564
- settings: Settings;
3565
- cwd: string;
3566
- sessionId: string;
3567
- onRegistered?: (registration: RegisterNotificationRootResult) => void;
3568
- }) => Promise<EnsureDaemonResult>;
3949
+ ensureTelegramDaemon?: (input: { settings: Settings }) => Promise<EnsureDaemonResult>;
3569
3950
  ensureProviderDaemon?: (provider: "discord" | "slack", settings: Settings) => Promise<unknown>;
3570
3951
  /** Suppress auto-delivery for a GJC-spawned child under `sessionScope=primary`. */
3571
3952
  spawnedByGjc?: boolean;
@@ -3579,9 +3960,34 @@ export function createNotificationsExtension(
3579
3960
  onBranchStartupSettled?: (receipt: { sessionId: string; status: SessionStartResult["status"] }) => void;
3580
3961
  readNotificationFile?: (path: string) => Promise<Buffer>;
3581
3962
  readNotificationDiffStat?: (cwd: string) => Promise<string | undefined>;
3963
+ /**
3964
+ * INTERNAL terminal-abort session seams, threaded directly from the owning
3965
+ * session — deliberately NOT on the public ExtensionContext so third-party
3966
+ * extensions cannot observe attempt epochs or cancel the session-global
3967
+ * preflight outside the durable admission path (review thread P2).
3968
+ */
3969
+ terminalAbortSeams?: {
3970
+ getTerminalTurnEpoch: () => number | undefined;
3971
+ cancelPendingPreflightForTerminalAbort: () => void;
3972
+ captureTerminalAbortSteeringSnapshot?: () => void;
3973
+ discardTerminalAbortSteeringSnapshot?: (token: number) => void;
3974
+ rebindTerminalAbortSteeringSnapshot?: (token: number) => void;
3975
+ abortPromptAndWaitWithTerminal: (
3976
+ handle: string,
3977
+ options: { graceMs: number; terminal?: { scope: "turn" | "owned"; expectedEpoch?: number } },
3978
+ ) => Promise<RunSettlementProof>;
3979
+ };
3582
3980
  } = {},
3583
3981
  ): void {
3982
+ const terminalAbortSeams = options.terminalAbortSeams;
3584
3983
  const lifecycleStartupCapability = lifecycleStartupCapabilityForApi(api);
3984
+ // Telegram session eligibility follows the user's configuration, not how the
3985
+ // session happened to be launched. Any session may own a forum topic and
3986
+ // receive notifications once Telegram is configured and effectively active;
3987
+ // gating this on coordinator/lifecycle provenance silently denied every
3988
+ // ordinary interactive session (the daemon refuses an identity header that
3989
+ // declares itself ineligible, so nothing was ever delivered).
3990
+ const telegramTopicsEnabled = (): boolean => isTelegramSessionEligible(resolveSettings(options.settings).cfg);
3585
3991
  const runtimes = new Map<string, SessionRuntime>();
3586
3992
  const controller =
3587
3993
  options.controller ??
@@ -3609,28 +4015,12 @@ export function createNotificationsExtension(
3609
4015
  | undefined;
3610
4016
  let extensionShuttingDown = false;
3611
4017
 
3612
- async function ensureTelegramOwner(
3613
- settings: Settings,
3614
- cwd: string,
3615
- id: string,
3616
- onRegistered?: (registrationToken: string) => void,
3617
- ): Promise<"ready" | "blocked_identity"> {
4018
+ async function ensureTelegramOwner(settings: Settings): Promise<"ready" | "blocked_identity"> {
4019
+ if (!telegramTopicsEnabled()) return "blocked_identity";
3618
4020
  if (options.ensureTelegramDaemon) {
3619
- return (await options.ensureTelegramDaemon({
3620
- settings,
3621
- cwd,
3622
- sessionId: id,
3623
- onRegistered: registration => onRegistered?.(registration.token),
3624
- })) === "blocked"
3625
- ? "blocked_identity"
3626
- : "ready";
4021
+ return (await options.ensureTelegramDaemon({ settings })) === "blocked" ? "blocked_identity" : "ready";
3627
4022
  }
3628
- return (await ensureTelegramDaemonRunningDetailed({
3629
- settings,
3630
- cwd,
3631
- sessionId: id,
3632
- onRegistered: registration => onRegistered?.(registration.token),
3633
- })) === "blocked_identity"
4023
+ return (await ensureTelegramDaemonRunningDetailed({ settings })) === "blocked_identity"
3634
4024
  ? "blocked_identity"
3635
4025
  : "ready";
3636
4026
  }
@@ -3638,11 +4028,8 @@ export function createNotificationsExtension(
3638
4028
  async function ensureConfiguredDaemonOwners(
3639
4029
  settings: Settings,
3640
4030
  cfg: NotificationConfig,
3641
- cwd: string,
3642
- id: string,
3643
- onRegistered?: (registrationToken: string) => void,
3644
4031
  ): Promise<ConfiguredDaemonOwnerResult> {
3645
- if (isProviderEffectivelyEnabled(cfg, "telegram")) {
4032
+ if (telegramTopicsEnabled() && isProviderEffectivelyEnabled(cfg, "telegram")) {
3646
4033
  const telegramMarker = getCurrentTelegramActivationMarker(cfg);
3647
4034
  if (telegramMarker) {
3648
4035
  if (!isProviderEffectivelyEnabled(cfg, "discord") && !isProviderEffectivelyEnabled(cfg, "slack")) {
@@ -3651,7 +4038,7 @@ export function createNotificationsExtension(
3651
4038
  await ensureConfiguredProviderDaemons(settings, cfg, options.ensureProviderDaemon);
3652
4039
  return "blocked_identity_with_sibling";
3653
4040
  }
3654
- const telegram = await ensureTelegramOwner(settings, cwd, id, onRegistered);
4041
+ const telegram = await ensureTelegramOwner(settings);
3655
4042
  if (telegram === "blocked_identity") {
3656
4043
  if (!isProviderEffectivelyEnabled(cfg, "discord") && !isProviderEffectivelyEnabled(cfg, "slack")) {
3657
4044
  return "blocked_identity";
@@ -3664,23 +4051,149 @@ export function createNotificationsExtension(
3664
4051
  return "ready";
3665
4052
  }
3666
4053
 
3667
- function retainNotificationRootRegistration(
3668
- runtime: SessionRuntime,
4054
+ /**
4055
+ * Single-flight chat-daemon ownership acquisition.
4056
+ *
4057
+ * Chat daemons are optional notification adapters, never session authority,
4058
+ * so NO session-lifecycle path (startSession, reconcile, authority rotation)
4059
+ * may await ownership — a wedged ensure would hold `session/new` open. This
4060
+ * coordinator keeps at most one ensure in flight and exposes it as a
4061
+ * settle-or-pending read: {@link peekDaemonOwnership} answers `undefined`
4062
+ * while an ensure is still running, and callers proceed without it. Exactly
4063
+ * one ensure runs at a time, so two callers can never race two outcomes onto
4064
+ * the same runtime.
4065
+ */
4066
+ type DaemonOwnershipOutcome = ConfiguredDaemonOwnerResult | "failed";
4067
+ let daemonOwnershipInFlight: { key: string; task: Promise<DaemonOwnershipOutcome> } | undefined;
4068
+ let daemonOwnershipSettled: DaemonOwnershipOutcome | undefined;
4069
+ let daemonOwnershipSettledKey: string | undefined;
4070
+
4071
+ /**
4072
+ * Identity of the ownership-relevant configuration. A settled outcome is
4073
+ * only reusable while this is unchanged: toggling enablement, credentials,
4074
+ * destination, or the activation marker must force a fresh ensure, while a
4075
+ * delivery-only change (redaction, verbosity) must not.
4076
+ */
4077
+ function daemonOwnershipKey(cfg: NotificationConfig): string {
4078
+ // Secrets are keyed by non-reversible fingerprint, never by raw value or
4079
+ // length: a same-length credential rotation MUST invalidate the outcome.
4080
+ const fingerprint = (secret: string | undefined): string | null =>
4081
+ secret === undefined || secret.length === 0 ? null : tokenFingerprint(secret);
4082
+ return JSON.stringify([
4083
+ isProviderEffectivelyEnabled(cfg, "telegram"),
4084
+ isProviderEffectivelyEnabled(cfg, "discord"),
4085
+ isProviderEffectivelyEnabled(cfg, "slack"),
4086
+ fingerprint(cfg.botToken),
4087
+ cfg.chatId ?? null,
4088
+ fingerprint(cfg.discord.botToken),
4089
+ cfg.discord.applicationId ?? null,
4090
+ cfg.discord.guildId ?? null,
4091
+ cfg.discord.parentChannelId ?? null,
4092
+ fingerprint(cfg.slack.botToken),
4093
+ fingerprint(cfg.slack.appToken),
4094
+ cfg.slack.workspaceId ?? null,
4095
+ cfg.slack.channelId ?? null,
4096
+ // Slack's inbound actor authorization is part of the daemon's durable
4097
+ // identity: rotating the paired user must re-prove ownership, or the
4098
+ // previous user keeps command authority through a stale outcome.
4099
+ cfg.slack.authorizedUserId ?? null,
4100
+ getCurrentTelegramActivationMarker(cfg) ?? null,
4101
+ // Redaction and verbosity are ordinarily delivery policy, applied
4102
+ // in-process. For Discord/Slack they are also part of the DAEMON's
4103
+ // durable identity (chatDaemonIdentity) and are snapshotted into the
4104
+ // provider's presentation engine at construction, so only a daemon
4105
+ // re-proof can retire a presenter still rendering under the old policy.
4106
+ // Key them exactly when such a provider is effective, so a Telegram-only
4107
+ // session is not churned by an in-process policy change.
4108
+ isProviderEffectivelyEnabled(cfg, "discord") || isProviderEffectivelyEnabled(cfg, "slack")
4109
+ ? `${String(cfg.redact)}:${cfg.verbosity}`
4110
+ : null,
4111
+ ]);
4112
+ }
4113
+
4114
+ function peekDaemonOwnership(cfg: NotificationConfig): DaemonOwnershipOutcome | undefined {
4115
+ if (daemonOwnershipInFlight) return undefined;
4116
+ return daemonOwnershipSettledKey === daemonOwnershipKey(cfg) ? daemonOwnershipSettled : undefined;
4117
+ }
4118
+
4119
+ function kickDaemonOwnership(
3669
4120
  settings: Settings,
3670
- cwd: string,
3671
- id: string,
3672
- registrationToken: string,
4121
+ cfg: NotificationConfig,
4122
+ onSettled?: (outcome: DaemonOwnershipOutcome, key: string) => void,
3673
4123
  ): void {
3674
- if (runtimes.get(id) === runtime && !runtime.stopping) {
3675
- runtime.notificationRootRegistration = { settings, cwd, registrationToken };
4124
+ const key = daemonOwnershipKey(cfg);
4125
+ const existing = daemonOwnershipInFlight;
4126
+ // Only an ensure for the SAME configuration can serve this caller. An
4127
+ // in-flight ensure for a different key is superseded: its outcome proves
4128
+ // nothing about this configuration, and its callbacks are key-checked.
4129
+ if (existing && existing.key === key) {
4130
+ if (onSettled)
4131
+ void existing.task.then(
4132
+ outcome => onSettled(outcome, key),
4133
+ () => {},
4134
+ );
3676
4135
  return;
3677
4136
  }
3678
- runtime.notificationRootRegistration = { settings, cwd, registrationToken };
3679
- cleanupRetries.set(id, runtime);
3680
- void stopSession(id, "session", runtime).catch(error =>
3681
- logger.warn(`notifications: late Telegram root unregister failed: ${String(error)}`),
3682
- );
4137
+ daemonOwnershipSettled = undefined;
4138
+ daemonOwnershipSettledKey = undefined;
4139
+ const task = (async (): Promise<DaemonOwnershipOutcome> => {
4140
+ try {
4141
+ return await ensureConfiguredDaemonOwners(settings, cfg);
4142
+ } catch (error) {
4143
+ logger.warn(
4144
+ `notifications: provider daemon ownership unavailable; core SDK remains available: ${String(error)}`,
4145
+ );
4146
+ return "failed";
4147
+ }
4148
+ })();
4149
+ daemonOwnershipInFlight = { key, task };
4150
+ void task.then(outcome => {
4151
+ if (daemonOwnershipInFlight?.task === task) {
4152
+ daemonOwnershipInFlight = undefined;
4153
+ daemonOwnershipSettled = outcome;
4154
+ daemonOwnershipSettledKey = key;
4155
+ }
4156
+ onSettled?.(outcome, key);
4157
+ });
3683
4158
  }
4159
+
4160
+ /**
4161
+ * Apply one settled ownership outcome to exactly one runtime.
4162
+ *
4163
+ * Four guards keep a late or superseded outcome harmless: the runtime must
4164
+ * still be the registered one for its id and not be stopping (a replacement
4165
+ * session never inherits a predecessor's outcome), the state must still be
4166
+ * `retry` (an outcome that lost a race must never downgrade a runtime whose
4167
+ * adapters are already active), and the outcome must have been proved under
4168
+ * the exact configuration this runtime is waiting on — an outcome proved for
4169
+ * a superseded configuration can never authorize adapters. A `failed` ensure
4170
+ * leaves the state retryable.
4171
+ */
4172
+ function applyDaemonOwnership(
4173
+ id: string,
4174
+ runtime: SessionRuntime,
4175
+ outcome: DaemonOwnershipOutcome,
4176
+ isolateChatEndpoint: boolean,
4177
+ key: string,
4178
+ ): void {
4179
+ if (runtimes.get(id) !== runtime || runtime.stopping) return;
4180
+ if (runtime.notificationOwnerState !== "retry") return;
4181
+ if (runtime.notificationOwnerKey !== key) return;
4182
+ if (outcome === "failed") return;
4183
+ runtime.notificationOwnerState =
4184
+ outcome === "ready" || (outcome === "blocked_identity_with_sibling" && isolateChatEndpoint)
4185
+ ? "ready"
4186
+ : "blocked";
4187
+ if (outcome === "blocked_identity") {
4188
+ logger.warn("notifications: Telegram daemon ownership is blocked; core SDK remains available.");
4189
+ }
4190
+ if (outcome === "blocked_identity_with_sibling" && !isolateChatEndpoint) {
4191
+ logger.warn(
4192
+ "notifications: Telegram ownership changed after core publication; preserving the canonical endpoint and withholding adapters.",
4193
+ );
4194
+ }
4195
+ }
4196
+
3684
4197
  const identityControlOperations = new Set([
3685
4198
  "session.new",
3686
4199
  "session.fork",
@@ -3704,6 +4217,7 @@ export function createNotificationsExtension(
3704
4217
  requestedRuntime.stopping = true;
3705
4218
  requestedRuntime.abortEphemeralTurns();
3706
4219
  }
4220
+ if (reason === "session" && requestedRuntime) requestedRuntime.stopSessionNameObserver();
3707
4221
  if (reason === "session" && requestedRuntime) {
3708
4222
  // Fence the exact runtime before awaiting its startup promise: a late start
3709
4223
  // must observe removal and clean itself up rather than becoming reachable.
@@ -3834,20 +4348,6 @@ export function createNotificationsExtension(
3834
4348
  hostStopped: rt.hostStopped && rt.serverStopped,
3835
4349
  brokerRegistrationReleased: rt.brokerRegistrationReleased,
3836
4350
  });
3837
- if (rt.notificationRootRegistration) {
3838
- try {
3839
- await unregisterNotificationRoot({
3840
- settings: rt.notificationRootRegistration.settings,
3841
- cwd: rt.notificationRootRegistration.cwd,
3842
- sessionId: id,
3843
- registrationToken: rt.notificationRootRegistration.registrationToken,
3844
- });
3845
- rt.notificationRootRegistration = undefined;
3846
- } catch (e) {
3847
- ownerReleaseFailures.push(e);
3848
- logger.warn(`notifications: Telegram root unregister failed: ${String(e)}`);
3849
- }
3850
- }
3851
4351
  if (ownerReleaseFailures.length > 0) {
3852
4352
  cleanupRetries.set(id, rt);
3853
4353
  throw new AggregateError(ownerReleaseFailures, `SDK notification runtime ${id} owner release failed.`);
@@ -3942,6 +4442,7 @@ export function createNotificationsExtension(
3942
4442
 
3943
4443
  const pendingInteractive = new Map<string, PendingInteractiveAsk>();
3944
4444
  const pendingPromptCorrelations: Array<{ commandId: string; turnId: string }> = [];
4445
+ const pendingPromptCorrelationsBySdkRunToken = new Map<string, { commandId: string; turnId: string }>();
3945
4446
  const tag = sessionTag(id);
3946
4447
  let runtime: SessionRuntime | undefined;
3947
4448
 
@@ -3950,11 +4451,6 @@ export function createNotificationsExtension(
3950
4451
  // build information and required capability while lifecycle startup can settle
3951
4452
  // a structured failure instead of leaving the lifecycle caller pending.
3952
4453
  const token = resolveToken();
3953
- const elevationAuthorityToken = crypto.randomBytes(32).toString("base64url");
3954
- const elevationAuthorityFile = elevationAuthorityPath(endpointStateRoot, id);
3955
- await fsPromises.mkdir(path.dirname(elevationAuthorityFile), { recursive: true, mode: 0o700 });
3956
- await Bun.write(elevationAuthorityFile, `${JSON.stringify({ version: 1, token: elevationAuthorityToken })}\n`);
3957
- await fsPromises.chmod(elevationAuthorityFile, 0o600);
3958
4454
  let server: NotificationServer;
3959
4455
  try {
3960
4456
  const { NotificationServer, nativeBuildInfo } = sdkBusNatives();
@@ -3995,6 +4491,7 @@ export function createNotificationsExtension(
3995
4491
  createSdkPermissionAskAnswerSource(
3996
4492
  async (params, signal) => await host!.reverse.request("permission", "request", params, signal),
3997
4493
  ),
4494
+ "protocol",
3998
4495
  );
3999
4496
  };
4000
4497
  const installProviderDefinitions = (capability: string, definitions: unknown) => {
@@ -4038,6 +4535,7 @@ export function createNotificationsExtension(
4038
4535
  createSdkUiAskAnswerSource(
4039
4536
  async (params, signal) => await host!.reverse.request("ui", "ui.elicit", params, signal),
4040
4537
  ),
4538
+ "protocol",
4041
4539
  );
4042
4540
  disposePermissionAnswerSource?.();
4043
4541
  disposePermissionAnswerSource = undefined;
@@ -4103,6 +4601,8 @@ export function createNotificationsExtension(
4103
4601
  const PROMPT_TERMINAL_TOMBSTONE_TTL_MS = 15 * 60_000;
4104
4602
  // SDK-owned terminalization grace; injectable in tests, never a user setting.
4105
4603
  const PROMPT_TERMINALIZATION_GRACE_MS = 10_000;
4604
+ // Fixed grace for exact owned-job stop before the second quiescence proof.
4605
+ const OWNED_SETTLEMENT_GRACE_MS = 500;
4106
4606
  const promptSubmissionKey = (correlation: { commandId: string; turnId: string }) =>
4107
4607
  `${correlation.commandId}:${correlation.turnId}`;
4108
4608
  type PromptLifecycleFrame =
@@ -4138,21 +4638,50 @@ export function createNotificationsExtension(
4138
4638
  outcome?: SdkPromptTerminalOutcome;
4139
4639
  /** Agent-owned resource run captured at acceptance; cleanup targets only this handle. */
4140
4640
  executionHandle?: string;
4641
+ /** Cancels accepted skill preparation before an execution handle exists. */
4642
+ preflightAbort?: () => void | Promise<void>;
4643
+ reconciliationKind: ReconciliationKind;
4141
4644
  bufferedFrames: Array<PromptLifecycleFrame | Record<string, unknown>>;
4142
4645
  };
4143
4646
  const promptSubmissions = new Map<string, PromptSubmission>();
4144
4647
  /** Connections fenced by a fatal prompt closure; their later frames are refused. */
4145
4648
  const fencedConnections = new Set<string>();
4146
- let cancelPreflightsForConnection: ((connectionId: string) => void) | undefined;
4649
+ /**
4650
+ * Live positioned-event delivery to attached subscribers. The native
4651
+ * broadcast channel round-trips a closed frame enum and cannot carry the
4652
+ * positioned event envelope, so each envelope rides the validated directed
4653
+ * leg instead — to every connection that completed capability negotiation
4654
+ * or an event replay, which is exactly the attached-subscriber set. Fenced
4655
+ * connections are excluded like their inbound frames, and capability-gated
4656
+ * kinds follow the same gate replay applies, so live and replay delivery
4657
+ * stay one truth per connection.
4658
+ */
4659
+ const broadcastEventFrame = (event: SdkFrame): void => {
4660
+ const gated = CAP_GATED_FRAME_KINDS.has(String(event.kind));
4661
+ const json = JSON.stringify(event);
4662
+ for (const [connectionId, capabilities] of hostCapCache) {
4663
+ if (fencedConnections.has(connectionId)) continue;
4664
+ if (gated && !capabilities.has(TOOL_ACTIVITY_CAPABILITY)) continue;
4665
+ try {
4666
+ server.sendTo(connectionId, json);
4667
+ } catch {
4668
+ // Broadcasts are best effort; directed responses surface send failures.
4669
+ }
4670
+ }
4671
+ };
4672
+ let cancelPreflightsForConnection: ((connectionId: string) => Promise<void>) | undefined;
4147
4673
  const promptTerminalTombstones = new Map<string, { connectionId: string; expiresAt: number }>();
4148
- // Authoritative bounded reconciliation state for Q26 turn.prompt_status
4674
+ // Authoritative bounded reconciliation state for canonical Q26 turn.result
4149
4675
  // (contract documented in ./prompt-reconciliation and ../prompt-status).
4150
4676
  // Active records never age into terminal; documented TTL/capacity
4151
4677
  // eviction is the only removal, after which lookups report `unknown`.
4152
- const sessionFile =
4678
+ const persistedSessionFile =
4153
4679
  typeof ctx.sessionManager?.getSessionFile === "function" ? ctx.sessionManager.getSessionFile() : null;
4154
4680
  const reconciliationSessionId =
4155
4681
  typeof ctx.sessionManager?.getSessionId === "function" ? ctx.sessionManager.getSessionId() : "";
4682
+ const sessionFile = reconciliationSessionId
4683
+ ? resolveReconciliationSessionFile(persistedSessionFile, stateRoot, String(reconciliationSessionId))
4684
+ : null;
4156
4685
  const durableStore =
4157
4686
  sessionFile && reconciliationSessionId
4158
4687
  ? createReconciliationStore({ sessionFile, sessionId: String(reconciliationSessionId) })
@@ -4160,12 +4689,22 @@ export function createNotificationsExtension(
4160
4689
  const kindReconciliation = createKindAwareReconciliation({ store: durableStore });
4161
4690
  // Restart recovery must commit before any prompt is admitted; otherwise a new
4162
4691
  // admission can race the hydrated full-state replacement.
4163
- const reconciliationReady: Promise<void> = durableStore
4164
- ? kindReconciliation.hydrateFromStore()
4165
- : Promise.resolve();
4692
+ let reconciliationReady: Promise<void> = durableStore ? kindReconciliation.hydrateFromStore() : Promise.resolve();
4166
4693
  // Never let a hydration rejection become an unhandled rejection; tracked prompts
4167
4694
  // re-await the same promise and fail closed below.
4168
4695
  void reconciliationReady.catch(() => {});
4696
+ const awaitReconciliationReady = async () => {
4697
+ const observed = reconciliationReady;
4698
+ try {
4699
+ await observed;
4700
+ } catch {
4701
+ if (reconciliationReady === observed) {
4702
+ reconciliationReady = durableStore ? kindReconciliation.hydrateFromStore() : Promise.resolve();
4703
+ void reconciliationReady.catch(() => {});
4704
+ }
4705
+ await reconciliationReady;
4706
+ }
4707
+ };
4169
4708
  // Backward-compatible process-local prompt reconciler kept for unit-test isolation;
4170
4709
  // production path uses kindReconciliation for prompt+skill.
4171
4710
  const reconciliation = createPromptReconciliation();
@@ -4182,6 +4721,10 @@ export function createNotificationsExtension(
4182
4721
  candidate => candidate.commandId === correlation.commandId && candidate.turnId === correlation.turnId,
4183
4722
  );
4184
4723
  if (pendingIndex !== -1) pendingPromptCorrelations.splice(pendingIndex, 1);
4724
+ for (const [sdkRunToken, candidate] of pendingPromptCorrelationsBySdkRunToken) {
4725
+ if (candidate.commandId === correlation.commandId && candidate.turnId === correlation.turnId)
4726
+ pendingPromptCorrelationsBySdkRunToken.delete(sdkRunToken);
4727
+ }
4185
4728
  };
4186
4729
  const addTerminalTombstone = (key: string, connectionId: string, now = Date.now()) => {
4187
4730
  promptTerminalTombstones.delete(key);
@@ -4234,7 +4777,7 @@ export function createNotificationsExtension(
4234
4777
  const key = promptSubmissionKey(correlation);
4235
4778
  const submission = promptSubmissions.get(key);
4236
4779
  if (!submission) return;
4237
- runtime.host.emitEvent({ kind: frame.type, payload: frame });
4780
+ emitSessionEvent(runtime, frame);
4238
4781
  if (submission.abandoned) {
4239
4782
  if (submission.terminal) finalizePrompt(key, correlation);
4240
4783
  return;
@@ -4298,6 +4841,9 @@ export function createNotificationsExtension(
4298
4841
  requesterConnectionId?: string,
4299
4842
  clientRef?: string,
4300
4843
  trackReconciliation = false,
4844
+ preflightAbort?: () => void | Promise<void>,
4845
+ reconciliationKind: ReconciliationKind = "prompt",
4846
+ sdkRunToken?: string,
4301
4847
  ) => {
4302
4848
  if (!requesterConnectionId) {
4303
4849
  // No delivery owner: tracked prompts cannot be reconciled. Release
@@ -4317,7 +4863,8 @@ export function createNotificationsExtension(
4317
4863
  );
4318
4864
  expirePromptDelivery(oldestTerminal[0], oldestTerminal[1]);
4319
4865
  }
4320
- pendingPromptCorrelations.push(correlation);
4866
+ if (sdkRunToken) pendingPromptCorrelationsBySdkRunToken.set(sdkRunToken, correlation);
4867
+ else pendingPromptCorrelations.push(correlation);
4321
4868
  const submission: PromptSubmission = {
4322
4869
  acknowledged: false,
4323
4870
  connectionId: requesterConnectionId,
@@ -4330,6 +4877,8 @@ export function createNotificationsExtension(
4330
4877
  phase: "active",
4331
4878
  // Bound to the Agent run at `agent_start`; acceptance precedes execution.
4332
4879
  executionHandle: undefined,
4880
+ ...(preflightAbort ? { preflightAbort } : {}),
4881
+ reconciliationKind,
4333
4882
  bufferedFrames: [],
4334
4883
  };
4335
4884
  promptSubmissions.set(promptSubmissionKey(correlation), submission);
@@ -4350,6 +4899,8 @@ export function createNotificationsExtension(
4350
4899
  };
4351
4900
  /** Roll back process-local registration when durable acceptance failed. */
4352
4901
  const discardPromptAcceptance = (correlation: { commandId: string; turnId: string }) => {
4902
+ const submission = promptSubmissions.get(promptSubmissionKey(correlation));
4903
+ if (submission?.deadlineTimer) clearTimeout(submission.deadlineTimer);
4353
4904
  promptSubmissions.delete(promptSubmissionKey(correlation));
4354
4905
  removePendingPromptCorrelation(correlation);
4355
4906
  };
@@ -4402,7 +4953,10 @@ export function createNotificationsExtension(
4402
4953
  // connection is marked failed (every later inbound frame from it is refused),
4403
4954
  // its reverse leases are released, and its deliveries are abandoned. The durable
4404
4955
  // record stays active for Q26 and restart recovery.
4405
- cancelPreflightsForConnection?.(submission.connectionId);
4956
+ if (cancelPreflightsForConnection)
4957
+ void cancelPreflightsForConnection(submission.connectionId).catch(error =>
4958
+ logger.warn(`sdk: failed to cancel fenced preflight: ${String(error)}`),
4959
+ );
4406
4960
  fencedConnections.add(submission.connectionId);
4407
4961
  host?.handleDisconnect(submission.connectionId);
4408
4962
  for (const [key, owned] of promptSubmissions)
@@ -4422,7 +4976,10 @@ export function createNotificationsExtension(
4422
4976
  handle: string | undefined,
4423
4977
  ) => {
4424
4978
  const submission = promptSubmissions.get(promptSubmissionKey(correlation));
4425
- if (submission) submission.executionHandle = handle;
4979
+ if (submission) {
4980
+ submission.executionHandle = handle;
4981
+ submission.preflightAbort = undefined;
4982
+ }
4426
4983
  };
4427
4984
  const terminalizePrompt = async (
4428
4985
  correlation: { commandId: string; turnId: string },
@@ -4430,15 +4987,35 @@ export function createNotificationsExtension(
4430
4987
  // Cleanup-initiated claims (cancel, deadline, owner disconnect) must abort the
4431
4988
  // run and prove settlement. A natural `agent_end`/`agent_failed` already unwound,
4432
4989
  // so aborting there would cancel the next turn instead of fencing this one.
4433
- options: { fence?: boolean } = {},
4990
+ options: { fence?: boolean; terminal?: { scope: AbortScope; steeringSnapshotToken?: number } } = {},
4434
4991
  extra?: PromptTerminalExtra,
4992
+ capture?: {
4993
+ proof?: RunSettlementProof & {
4994
+ terminalScope?: { scopeId: string; abortedAttemptEpoch: number; lineageIdHash: string };
4995
+ };
4996
+ /** Whether the correlated agent_end event was published (AC 19). */
4997
+ published?: boolean;
4998
+ /** Whether terminalization reached the durable terminal (fail-closed paths leave this unset). */
4999
+ terminalized?: boolean;
5000
+ },
4435
5001
  ) => {
5002
+ const receiptState = extra?.finalText?.trim() ? "present" : "missing";
4436
5003
  const submission = promptSubmissions.get(promptSubmissionKey(correlation));
4437
5004
  if (!submission || submission.terminal || submission.phase !== "active") return;
4438
5005
  submission.phase = "outcome_claimed";
5006
+ // Capture the attempt epoch BEFORE the durable claim: a successor
5007
+ // prompt admitted while the claim is awaited advances the epoch, so
5008
+ // the seam must refuse to fence the successor's lineage (review
5009
+ // thread P1).
5010
+ const terminalExpectedEpoch = options.terminal ? terminalAbortSeams?.getTerminalTurnEpoch?.() : undefined;
4439
5011
  let winner: SdkPromptTerminalOutcome;
4440
5012
  try {
4441
- winner = await kindReconciliation.claimPendingOutcome(correlation, requestedOutcome);
5013
+ winner = await kindReconciliation.claimPendingOutcome(
5014
+ submission.reconciliationKind,
5015
+ correlation,
5016
+ requestedOutcome,
5017
+ receiptState,
5018
+ );
4442
5019
  } catch (error) {
4443
5020
  // The claim is the durability boundary: without it nothing may be published
4444
5021
  // and the endpoint must fail closed so the client rejects exactly once. The
@@ -4450,12 +5027,17 @@ export function createNotificationsExtension(
4450
5027
  submission.outcome = winner;
4451
5028
  submission.phase = "terminalizing";
4452
5029
  if (options.fence) {
4453
- const seam = ctx as typeof ctx & {
4454
- abortPromptAndWait?: (handle: string, options: { graceMs: number }) => Promise<RunSettlementProof>;
4455
- };
5030
+ // Terminal fencing goes through a BUS-PRIVATE session capability
5031
+ // (never the extension context, whose abortPromptAndWait has no
5032
+ // terminal option): a JavaScript extension cannot register a
5033
+ // closed terminal scope without the SDK's durable admission
5034
+ // (review thread P2).
4456
5035
  // Only the handle captured for this correlation may be fenced; a later run
4457
5036
  // must never be aborted by an older prompt's cleanup.
4458
- if (typeof seam.abortPromptAndWait !== "function" || !submission.executionHandle) {
5037
+ if (
5038
+ typeof terminalAbortSeams?.abortPromptAndWaitWithTerminal !== "function" ||
5039
+ !submission.executionHandle
5040
+ ) {
4459
5041
  failPromptClosed(
4460
5042
  correlation,
4461
5043
  submission,
@@ -4464,10 +5046,27 @@ export function createNotificationsExtension(
4464
5046
  );
4465
5047
  return;
4466
5048
  }
5049
+ // Rebind the steering snapshot to the current turn before the
5050
+ // settlement: the token was captured under the abort's admission
5051
+ // turn, which may differ from the now-active turn that the
5052
+ // settlement will fence. Without the rebind the session rejects
5053
+ // the still-old token as unknown_run (review thread P1).
5054
+ if (options.terminal?.steeringSnapshotToken !== undefined) {
5055
+ terminalAbortSeams?.rebindTerminalAbortSteeringSnapshot?.(options.terminal.steeringSnapshotToken);
5056
+ }
4467
5057
  let proof: RunSettlementProof;
4468
5058
  try {
4469
- proof = await seam.abortPromptAndWait(submission.executionHandle, {
5059
+ proof = await terminalAbortSeams.abortPromptAndWaitWithTerminal(submission.executionHandle, {
4470
5060
  graceMs: PROMPT_TERMINALIZATION_GRACE_MS,
5061
+ // Terminal abort registers the continuation fence for the
5062
+ // aborted turn before the run is interrupted (see
5063
+ // AgentSession.abortPromptAndWait). Ordinary cancels pass no
5064
+ // terminal option and register nothing. The request-time epoch
5065
+ // travels with the scope so the seam refuses to fence a
5066
+ // successor turn admitted while the durable claim was awaited.
5067
+ ...(options.terminal
5068
+ ? { terminal: { ...options.terminal, expectedEpoch: terminalExpectedEpoch } }
5069
+ : {}),
4471
5070
  });
4472
5071
  } catch (error) {
4473
5072
  logger.warn(`sdk: prompt resource fencing failed: ${String(error)}`);
@@ -4491,9 +5090,16 @@ export function createNotificationsExtension(
4491
5090
  );
4492
5091
  return;
4493
5092
  }
5093
+ if (capture) capture.proof = proof;
4494
5094
  }
4495
5095
  try {
4496
- await kindReconciliation.finalizePromptOutcome(correlation, winner, extra?.error, extra?.finalText);
5096
+ await kindReconciliation.finalizeOutcome(
5097
+ submission.reconciliationKind,
5098
+ correlation,
5099
+ winner,
5100
+ extra?.error,
5101
+ extra?.finalText,
5102
+ );
4497
5103
  } catch (error) {
4498
5104
  // The durable pending claim survives; publishing an unpersisted terminal
4499
5105
  // would contradict it, so fail the endpoint closed instead.
@@ -4521,25 +5127,57 @@ export function createNotificationsExtension(
4521
5127
  reason: formatPromptTerminalFailureReason(diagnostic?.reason),
4522
5128
  });
4523
5129
  }
4524
- if (winner.kind === "failed") {
4525
- emitPromptLifecycle(correlation, {
4526
- type: "agent_failed",
4527
- sessionId: runtime.id,
4528
- ...correlation,
4529
- error: extra?.error ?? { code: winner.code, message: winner.message },
4530
- outcome: winner,
4531
- });
4532
- } else {
4533
- emitPromptLifecycle(correlation, {
4534
- type: "agent_end",
4535
- sessionId: runtime.id,
4536
- ...correlation,
4537
- ...(extra?.finalText ? { finalText: extra.finalText } : {}),
4538
- outcome: winner,
4539
- });
5130
+ try {
5131
+ if (winner.kind === "failed") {
5132
+ emitPromptLifecycle(correlation, {
5133
+ type: "agent_failed",
5134
+ sessionId: runtime.id,
5135
+ ...correlation,
5136
+ error: extra?.error ?? { code: winner.code, message: winner.message },
5137
+ outcome: winner,
5138
+ });
5139
+ } else {
5140
+ emitPromptLifecycle(correlation, {
5141
+ type: "agent_end",
5142
+ sessionId: runtime.id,
5143
+ ...correlation,
5144
+ ...(extra?.finalText ? { finalText: extra.finalText } : {}),
5145
+ outcome: winner,
5146
+ // Terminal abort: one correlated existing agent_end carries bounded
5147
+ // scope/turn/ownedWork/automatic metadata before the first terminal
5148
+ // success. ownedWork is pre-proof here (owned cleanup settles it in
5149
+ // the terminal response); later owned-completion feedback uses the
5150
+ // ordinary fresh-turn event path, never a second terminal event.
5151
+ ...(options.terminal
5152
+ ? {
5153
+ terminal: {
5154
+ scope: options.terminal.scope,
5155
+ turn: "stopped",
5156
+ ownedWork: options.terminal.scope === "turn" ? "left_running" : "uncertain",
5157
+ automaticDelivery: options.terminal.scope === "turn" ? "enabled" : "none",
5158
+ resumeOnOwnedCompletion: options.terminal.scope === "turn",
5159
+ },
5160
+ }
5161
+ : {}),
5162
+ });
5163
+ }
5164
+ // The correlated event was published (AC 19): record the outcome so
5165
+ // the durable terminal-scope record carries terminalPublished:true.
5166
+ // terminalized marks a genuinely landed durable terminal — the
5167
+ // submission may already have been finalized/deleted for an
5168
+ // acknowledged prompt, so the callback must not re-derive success
5169
+ // from promptSubmissions after this point (P1).
5170
+ if (capture) {
5171
+ capture.published = true;
5172
+ capture.terminalized = true;
5173
+ }
5174
+ } catch (error) {
5175
+ // Event publication failed: the semantic terminal stands but the
5176
+ // event bit stays false (no second event is ever emitted on replay).
5177
+ logger.warn(`sdk: prompt terminal event publication failed: ${String(error)}`);
4540
5178
  }
4541
5179
  };
4542
- const emitPromptFailure = (correlation: { commandId: string; turnId: string }, error: unknown) => {
5180
+ const emitPromptFailure = async (correlation: { commandId: string; turnId: string }, error: unknown) => {
4543
5181
  logger.error("SDK prompt submission failed", {
4544
5182
  commandId: correlation.commandId,
4545
5183
  turnId: correlation.turnId,
@@ -4562,7 +5200,7 @@ export function createNotificationsExtension(
4562
5200
  provenance: outcome.provenance,
4563
5201
  reason: formatPromptTerminalFailureReason(error),
4564
5202
  });
4565
- void terminalizePrompt(
5203
+ await terminalizePrompt(
4566
5204
  correlation,
4567
5205
  outcome,
4568
5206
  {},
@@ -4571,7 +5209,7 @@ export function createNotificationsExtension(
4571
5209
  { error: sanitized, diagnostic: { reason: error }, diagnosticAlreadyLogged: true },
4572
5210
  );
4573
5211
  };
4574
- const recordPromptFailure = (correlation: { commandId: string; turnId: string }, error: unknown) => {
5212
+ const recordPromptFailure = async (correlation: { commandId: string; turnId: string }, error: unknown) => {
4575
5213
  const submission = promptSubmissions.get(promptSubmissionKey(correlation));
4576
5214
  if (!submission) return;
4577
5215
  submission.failed = true;
@@ -4581,7 +5219,7 @@ export function createNotificationsExtension(
4581
5219
  runtime.activePromptCorrelation.turnId === correlation.turnId
4582
5220
  )
4583
5221
  runtime.activePromptCorrelation = undefined;
4584
- emitPromptFailure(correlation, error);
5222
+ await emitPromptFailure(correlation, error);
4585
5223
  };
4586
5224
  const acknowledgePrompt = (connectionId: string, correlation: { commandId: string; turnId: string }) => {
4587
5225
  const key = promptSubmissionKey(correlation);
@@ -4612,6 +5250,7 @@ export function createNotificationsExtension(
4612
5250
  configOverrides,
4613
5251
  settings,
4614
5252
  selector => kindReconciliation.lookupResult(selector.kind, selector),
5253
+ selector => kindReconciliation.lookupSteer(selector),
4615
5254
  ),
4616
5255
  id,
4617
5256
  revisions,
@@ -4622,7 +5261,10 @@ export function createNotificationsExtension(
4622
5261
  pendingInteractive,
4623
5262
  gatePresentations,
4624
5263
  api,
4625
- () => runtime?.busy === true || pendingPromptCorrelations.length > 0,
5264
+ () =>
5265
+ runtime?.busy === true ||
5266
+ pendingPromptCorrelations.length > 0 ||
5267
+ pendingPromptCorrelationsBySdkRunToken.size > 0,
4626
5268
  recordPromptAccepted,
4627
5269
  recordPromptFailure,
4628
5270
  discardPromptAcceptance,
@@ -4630,33 +5272,1017 @@ export function createNotificationsExtension(
4630
5272
  trackGateResolution,
4631
5273
  admitPromptSubmission,
4632
5274
  releasePromptAdmission,
4633
- () => reconciliationReady,
5275
+ awaitReconciliationReady,
4634
5276
  settings,
4635
5277
  configOverrides,
4636
5278
  configRevision,
4637
- elevationAuthorityToken,
4638
5279
  async connectionId => {
4639
5280
  const active = [...promptSubmissions.entries()].find(
4640
5281
  ([, submission]) => submission.connectionId === connectionId && !submission.terminal,
4641
5282
  );
4642
5283
  if (!active) return { aborted: true, disposition: "idle" as const };
5284
+ const [key, submission] = active;
5285
+ const [commandId, turnId] = key.split(":", 2);
5286
+ if (commandId && turnId) {
5287
+ const hasExecutionHandle = Boolean(submission.executionHandle);
5288
+ const cancellationOutcome: SdkPromptTerminalOutcome = {
5289
+ kind: "stopped",
5290
+ reason: "cancelled",
5291
+ provenance: "client_cancel",
5292
+ };
5293
+ if (!hasExecutionHandle && submission.preflightAbort) {
5294
+ try {
5295
+ await kindReconciliation.claimPendingOutcome(
5296
+ submission.reconciliationKind,
5297
+ { commandId, turnId },
5298
+ cancellationOutcome,
5299
+ );
5300
+ } catch (error) {
5301
+ logger.warn(`sdk: accepted preflight cancellation claim failed: ${String(error)}`);
5302
+ failPromptClosed(
5303
+ { commandId, turnId },
5304
+ submission,
5305
+ "terminal_uncertain",
5306
+ "Accepted prompt cancellation could not be persisted.",
5307
+ );
5308
+ return { aborted: true, disposition: "cancelled" as const };
5309
+ }
5310
+ try {
5311
+ await submission.preflightAbort();
5312
+ } catch (error) {
5313
+ logger.warn(`sdk: accepted preflight cancellation failed: ${String(error)}`);
5314
+ failPromptClosed(
5315
+ { commandId, turnId },
5316
+ submission,
5317
+ "terminal_uncertain",
5318
+ "Accepted prompt preparation could not be settled before cancellation.",
5319
+ );
5320
+ return { aborted: true, disposition: "cancelled" as const };
5321
+ }
5322
+ }
5323
+ await terminalizePrompt({ commandId, turnId }, cancellationOutcome, {
5324
+ fence: hasExecutionHandle || !submission.preflightAbort,
5325
+ });
5326
+ }
5327
+ return { aborted: true, disposition: "cancelled" as const };
5328
+ },
5329
+ async (connectionId, scope, idempotencyKey, preflightCancel, noOtherConnectionPreflights) => {
5330
+ // Capture the steering snapshot at ADMISSION (before the durable
5331
+ // marker transaction): client steering admitted while the abort
5332
+ // is in flight classifies as post-snapshot and is preserved at
5333
+ // abortPromptAndWait (review thread P1).
5334
+ const steeringSnapshotToken = terminalAbortSeams?.captureTerminalAbortSteeringSnapshot?.();
5335
+ // Hash the EXACT response payload this abort will return: the durable
5336
+ // row stores it at finalization so the response-state advance requires
5337
+ // equality instead of trusting a non-pending placeholder (review
5338
+ // thread P2).
5339
+ const hashResult = (value: unknown): string =>
5340
+ crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex");
5341
+ // The public uncertain disposition the client receives for an
5342
+ // {ok:false, reason} outcome (single source for finalization hashes).
5343
+ const hashPublicUncertain = (reason: "worker_unsettled" | "owned_unsettled"): string =>
5344
+ hashResult({
5345
+ ok: true,
5346
+ selection: scope,
5347
+ turn: "uncertain",
5348
+ ownedWork: scope === "turn" ? "left_running" : "uncertain",
5349
+ automaticDelivery: scope === "turn" ? "enabled" : "none",
5350
+ resumeOnOwnedCompletion: scope === "turn",
5351
+ reason,
5352
+ });
5353
+ // Terminal abort stops the root turn through the same durable
5354
+ // terminalization as ordinary client cancel, then verifies the
5355
+ // terminal actually landed before claiming "stopped". The fence
5356
+ // for the aborted turn is registered by the session (via the
5357
+ // terminal option on abortPromptAndWait) so a later left-running
5358
+ // owned completion classifies by exact source. A fatal
5359
+ // fail-closed path (no exact run handle or unsettled resources)
5360
+ // reports safe uncertainty, never a fabricated stop.
5361
+ if (!durableStore?.path) {
5362
+ // No FILE-BACKED reconciliation owner: terminal admission is
5363
+ // gated off (plan AC 5) before any fence, stop, or cleanup. A
5364
+ // memory-only store (path null, e.g. an unsafe session header
5365
+ // id) must not report durable success — restart would lose the
5366
+ // idempotency row and a same-key retry could affect a later
5367
+ // turn (review thread P2).
5368
+ return { ok: true as const, outcome: "no_store" as const };
5369
+ }
5370
+ // Await the startup reconciliation hydration before ANY snapshot or
5371
+ // terminal-scope transaction, so a same-key retry immediately after
5372
+ // a restarted endpoint becomes reachable replays the durable row
5373
+ // instead of racing the still-pending store load (P2).
5374
+ await reconciliationReady;
5375
+ // Same-key replay/conflict: a durable terminal-scope record already
5376
+ // exists for this bounded idempotency key. Same key + same
5377
+ // normalized input -> return the stored dispositions exactly, never
5378
+ // re-run cleanup, never a second event. Same key + different input
5379
+ // (scope change) -> deterministic conflict (AC 3).
5380
+ const keyHash = idempotencyKey
5381
+ ? crypto.createHash("sha256").update(idempotencyKey).digest("hex")
5382
+ : undefined;
5383
+ const inputHash = crypto
5384
+ .createHash("sha256")
5385
+ .update(JSON.stringify({ mode: "terminal", scope }))
5386
+ .digest("hex");
5387
+ if (keyHash) {
5388
+ const existing = durableStore.snapshotTerminalScopes().find(s => s.idempotencyKeyHash === keyHash);
5389
+ if (!existing) {
5390
+ // The completed row may have been evicted by the retention
5391
+ // cap; its compact key tombstone survives, so a same-key
5392
+ // retry replays as already-handled instead of aborting an
5393
+ // unrelated later prompt. Look the tombstone up by keyHash
5394
+ // FIRST: reusing the key with the OTHER scope must return
5395
+ // idempotency_conflict, not bypass the conflict path
5396
+ // (review thread P1).
5397
+ const tombstone = durableStore.snapshotTerminalKeys().find(k => k.keyHash === keyHash);
5398
+ if (tombstone) {
5399
+ if (tombstone.inputHash !== inputHash) {
5400
+ return { ok: false as const, reason: "conflict" as const };
5401
+ }
5402
+ // The evicted row's compact tombstone carries its
5403
+ // disposition, so the replay reconstructs the ORIGINAL
5404
+ // result (stopped/stopped_owned/uncertain/no_effect)
5405
+ // with the stored response/publication state instead of
5406
+ // collapsing everything to no_effect (review thread P2).
5407
+ const storedRow = {
5408
+ responseState: tombstone.responseState ?? "",
5409
+ responsePayloadHash: tombstone.responsePayloadHash ?? "",
5410
+ terminalPublished: tombstone.terminalPublished === true,
5411
+ };
5412
+ if (tombstone.turnDisposition === "stopped") {
5413
+ return {
5414
+ ok: true as const,
5415
+ outcome: (tombstone.ownedWorkDisposition === "stopped" ? "stopped_owned" : "stopped") as
5416
+ | "stopped"
5417
+ | "stopped_owned",
5418
+ stored: storedRow,
5419
+ };
5420
+ }
5421
+ if (tombstone.turnDisposition === "uncertain") {
5422
+ return { ok: true as const, outcome: "uncertain_replay" as const, stored: storedRow };
5423
+ }
5424
+ if (tombstone.turnDisposition === "no_effect_marker_failure") {
5425
+ // Marker failure before any destructive work: replay the SAME
5426
+ // no_effect result, so eviction/restart can never turn it into
5427
+ // a fabricated no_active_turn (review thread P2).
5428
+ return { ok: true as const, outcome: "no_effect" as const, stored: storedRow };
5429
+ }
5430
+ if (tombstone.turnDisposition === "no_effect_reserved") {
5431
+ // A transitional reservation evicted mid-flight: the result was
5432
+ // never finalized, so replay safe uncertainty — never a
5433
+ // fabricated no_active_turn (review thread P2).
5434
+ return { ok: true as const, outcome: "uncertain_replay" as const, stored: storedRow };
5435
+ }
5436
+ // Idle/already-terminal reservation: replay as no_active_turn so a
5437
+ // same-key retry after eviction/restart never aborts an unrelated
5438
+ // later turn.
5439
+ return { ok: true as const, outcome: "no_effect_replay" as const, stored: storedRow };
5440
+ }
5441
+ }
5442
+ if (existing) {
5443
+ if (existing.selection !== scope || existing.idempotencyInputHash !== inputHash) {
5444
+ return { ok: false as const, reason: "conflict" as const };
5445
+ }
5446
+ // Replay every persisted durable row (AC 18/19/41) WITHOUT
5447
+ // re-running the stop, cleanup, or event, carrying the stored
5448
+ // response state, payload hash, and publication bit so the
5449
+ // client sees the exact immutable row.
5450
+ const storedRow = {
5451
+ responseState: existing.responseState,
5452
+ responsePayloadHash: existing.responsePayloadHash,
5453
+ terminalPublished: existing.terminalPublished === true,
5454
+ };
5455
+ if (existing.turnDisposition === "stopped") {
5456
+ return {
5457
+ ok: true as const,
5458
+ outcome: (existing.ownedWorkDisposition === "stopped" ? "stopped_owned" : "stopped") as
5459
+ | "stopped"
5460
+ | "stopped_owned",
5461
+ stored: storedRow,
5462
+ };
5463
+ }
5464
+ if (existing.turnDisposition === "pending") {
5465
+ // A crashed attempt left an incomplete marker: replay the
5466
+ // plan's pending row (AC 4/41) — safe uncertainty, NO
5467
+ // re-run of the stop, cleanup, or event.
5468
+ return { ok: true as const, outcome: "pending_replay" as const, stored: storedRow };
5469
+ }
5470
+ if (existing.turnDisposition === "no_effect_marker_failure") {
5471
+ // Marker failure before any destructive work: replay the SAME
5472
+ // no_effect result the request returned, never a fabricated
5473
+ // no_active_turn (review thread P2).
5474
+ return { ok: true as const, outcome: "no_effect" as const, stored: storedRow };
5475
+ }
5476
+ if (existing.turnDisposition === "no_effect_reserved") {
5477
+ // A transitional reservation: the abort may still transition to
5478
+ // active, so a duplicate must never claim no_active_turn over a
5479
+ // provisional row (review thread P2).
5480
+ return { ok: true as const, outcome: "uncertain_replay" as const, stored: storedRow };
5481
+ }
5482
+ if (existing.turnDisposition === "no_effect") {
5483
+ // A durable idle/already-terminal reservation: replay the exact
5484
+ // no_active_turn result so a same-key retry after
5485
+ // eviction/restart never aborts an unrelated later turn and
5486
+ // never turns the reservation into a no_effect marker failure.
5487
+ return { ok: true as const, outcome: "no_effect_replay" as const, stored: storedRow };
5488
+ }
5489
+ // uncertain (restart-settled) or any other durable state: safe
5490
+ // uncertainty replay, never a re-run (AC 41 restart row).
5491
+ return { ok: true as const, outcome: "uncertain_replay" as const, stored: storedRow };
5492
+ }
5493
+ }
5494
+ // Durable terminal-state writes below go through the shared
5495
+ // `boundTerminalRetentionState` bound (see
5496
+ // `session/terminal-abort.ts`), which the SDK-only host runtime uses
5497
+ // too: idle aborts with unique keys cannot grow the reconciliation
5498
+ // document, only the oldest COMPLETED rows are evicted, and evicted
5499
+ // keys are retained as compact tombstones (review thread P2).
5500
+ // Set when the no-effect reservation finds an existing SAME-input row
5501
+ // or tombstone: the caller replays it instead of returning a no-active
5502
+ // result over the original row's replay authority (review thread P2).
5503
+ let existingReplay: DurableTerminalScopeRecord | EvictedTerminalKeyEntry | undefined;
5504
+ // Finalize THIS abort's transitional no_effect_reserved reservation to
5505
+ // plain no_effect once the recheck confirms there is no active turn to
5506
+ // stop: a later same-key retry then replays the deterministic
5507
+ // no_active_turn result instead of reservation uncertainty. Only OUR
5508
+ // row (exact key+input, still reserved) is touched (review thread P2).
5509
+ const finalizeNoEffectReservation = async (payloadHash: string): Promise<void> => {
5510
+ if (!keyHash) return;
5511
+ // The same-key retry delivers the replay envelope; store its hash
5512
+ // too so a written replay can advance the finalized row (review
5513
+ // thread P2).
5514
+ const replayPayloadHash = hashResult({
5515
+ ok: true,
5516
+ selection: scope,
5517
+ turn: "no_active_turn",
5518
+ terminal: "terminal_no_effect",
5519
+ replay: {
5520
+ responseState: "pending",
5521
+ responsePayloadHash: payloadHash,
5522
+ terminalPublished: false,
5523
+ },
5524
+ });
5525
+ try {
5526
+ await durableStore.transactTerminalState(state => {
5527
+ const scopes: DurableTerminalScopeRecord[] = state.scopes.map(record =>
5528
+ record.idempotencyKeyHash === keyHash &&
5529
+ record.idempotencyInputHash === inputHash &&
5530
+ record.turnDisposition === "no_effect_reserved"
5531
+ ? {
5532
+ ...record,
5533
+ turnDisposition: "no_effect",
5534
+ responsePayloadHash: payloadHash,
5535
+ replayPayloadHash,
5536
+ }
5537
+ : record,
5538
+ );
5539
+ // Finalized reservations become evictable completed rows: apply
5540
+ // the SAME bounded retention as reserveTerminalNoEffect so a
5541
+ // burst of idle aborts cannot grow the document (review
5542
+ // thread P2).
5543
+ return boundTerminalRetentionState(state.keys, scopes);
5544
+ });
5545
+ } catch {
5546
+ // Best-effort: the row stays reserved (replays as uncertainty)
5547
+ // rather than failing the abort (review thread P2).
5548
+ }
5549
+ };
5550
+ // Finalize pending markers through the SAME bounded retention as the
5551
+ // admission writes: mapping pending rows to completed dispositions
5552
+ // (uncertain/stopped) must evict the oldest completed rows and
5553
+ // retain tombstones, or a burst of concurrent distinct-key aborts of
5554
+ // one slow turn leaves an arbitrarily large reconciliation document
5555
+ // (review thread P2).
5556
+ const transactBoundedTerminalScopes = async (
5557
+ mutate: (scopes: DurableTerminalScopeRecord[]) => DurableTerminalScopeRecord[],
5558
+ ): Promise<void> => {
5559
+ await durableStore.transactTerminalState(state => {
5560
+ return boundTerminalRetentionState(state.keys, mutate(state.scopes));
5561
+ });
5562
+ };
5563
+ const replayExisting = (row: DurableTerminalScopeRecord | EvictedTerminalKeyEntry) => {
5564
+ const storedRow = {
5565
+ responseState: row.responseState ?? "",
5566
+ responsePayloadHash: row.responsePayloadHash ?? "",
5567
+ terminalPublished: row.terminalPublished === true,
5568
+ };
5569
+ if (row.turnDisposition === "stopped") {
5570
+ return {
5571
+ ok: true as const,
5572
+ outcome: (row.ownedWorkDisposition === "stopped" ? "stopped_owned" : "stopped") as
5573
+ | "stopped"
5574
+ | "stopped_owned",
5575
+ stored: storedRow,
5576
+ };
5577
+ }
5578
+ if (row.turnDisposition === "pending") {
5579
+ return { ok: true as const, outcome: "pending_replay" as const, stored: storedRow };
5580
+ }
5581
+ if (row.turnDisposition === "uncertain") {
5582
+ return { ok: true as const, outcome: "uncertain_replay" as const, stored: storedRow };
5583
+ }
5584
+ if (row.turnDisposition === "no_effect_marker_failure") {
5585
+ return { ok: true as const, outcome: "no_effect" as const, stored: storedRow };
5586
+ }
5587
+ if (row.turnDisposition === "no_effect_reserved") {
5588
+ // A transitional reservation never finalized: replay safe
5589
+ // uncertainty, never a fabricated no_active_turn (review
5590
+ // thread P2).
5591
+ return { ok: true as const, outcome: "uncertain_replay" as const, stored: storedRow };
5592
+ }
5593
+ return { ok: true as const, outcome: "no_effect_replay" as const, stored: storedRow };
5594
+ };
5595
+ const reserveTerminalNoEffect = async (
5596
+ reason: "idle" | "marker_failure" = "idle",
5597
+ ): Promise<"ok" | "failed" | "conflict"> => {
5598
+ if (!keyHash) return "ok";
5599
+ try {
5600
+ await durableStore.transactTerminalState(state => {
5601
+ // Atomic recheck: a concurrent request may have committed a
5602
+ // DIFFERENT input under this key after the earlier snapshot
5603
+ // check (the 256-entry dispatch cache evicted the in-flight
5604
+ // entry). Appending a second same-key row would make later
5605
+ // replay's .find() by key hash ambiguous and could report a
5606
+ // conflict for a request that already succeeded; reject the
5607
+ // conflicting input inside the transaction instead (review
5608
+ // thread P2).
5609
+ const conflicting = state.scopes.find(s => s.idempotencyKeyHash === keyHash);
5610
+ if (conflicting && conflicting.idempotencyInputHash !== inputHash)
5611
+ throw new TerminalIdempotencyConflictError();
5612
+ // A SAME-input live row is durable replay authority (the
5613
+ // original in-flight abort's marker): never replace it with a
5614
+ // no-effect reservation, or the successful abort would replay
5615
+ // later as no_active_turn. Leave the store unchanged and let
5616
+ // the caller replay the existing row (review thread P2).
5617
+ if (conflicting) {
5618
+ existingReplay = conflicting;
5619
+ return { scopes: state.scopes, keys: state.keys };
5620
+ }
5621
+ // A concurrent admission may ALSO have evicted a same-key row
5622
+ // into the tombstone collection after this request's snapshot;
5623
+ // recheck keys so a different input can never install a fresh
5624
+ // marker over existing durable replay authority (review
5625
+ // thread P2). A same-input tombstone already carries the
5626
+ // reservation: leave the store unchanged and replay it.
5627
+ const tombstone = state.keys.find(s => s.keyHash === keyHash);
5628
+ if (tombstone) {
5629
+ if (tombstone.inputHash !== inputHash) throw new TerminalIdempotencyConflictError();
5630
+ existingReplay = tombstone;
5631
+ return { scopes: state.scopes, keys: state.keys };
5632
+ }
5633
+ const retained = state.scopes.filter(
5634
+ s => !(s.idempotencyKeyHash === keyHash && s.idempotencyInputHash === inputHash),
5635
+ );
5636
+ const preBound: DurableTerminalScopeRecord[] = [
5637
+ ...retained,
5638
+ {
5639
+ selection: scope,
5640
+ idempotencyKeyHash: keyHash,
5641
+ idempotencyInputHash: inputHash,
5642
+ // Idle reservations write a TRANSITIONAL reserved disposition: the
5643
+ // prompt may become active while the reservation is awaited, and a
5644
+ // duplicate must never claim no_active_turn over a provisional row
5645
+ // — the row is finalized to plain no_effect only when the recheck
5646
+ // confirms no active turn. Marker-failure reservations use a
5647
+ // distinct final disposition so replay returns the SAME no_effect
5648
+ // result instead of a fabricated no_active_turn (review thread
5649
+ // P2).
5650
+ turnDisposition:
5651
+ reason === "marker_failure" ? "no_effect_marker_failure" : "no_effect_reserved",
5652
+ ownedWorkDisposition: "not_requested",
5653
+ automaticDeliveryDisposition: scope === "turn" ? "enabled" : "none",
5654
+ resumeOnOwnedCompletion: scope === "turn",
5655
+ turnContinuationFence: {
5656
+ state: "retained",
5657
+ abortedAttemptEpoch: 0,
5658
+ blockedContinuationIds: [],
5659
+ predecessorTombstones: [],
5660
+ ownedCompletionPolicy: scope === "turn" ? "enabled" : "disabled",
5661
+ },
5662
+ responseState: "pending",
5663
+ // marker_failure rows are FINAL as written (the abort
5664
+ // returns the public no_effect result immediately, with no
5665
+ // later finalization), so store the public payload hash;
5666
+ // idle reservations are finalized by
5667
+ // finalizeNoEffectReservation (review thread P2).
5668
+ responsePayloadHash:
5669
+ reason === "marker_failure"
5670
+ ? hashResult({
5671
+ ok: true,
5672
+ selection: scope,
5673
+ turn: "no_effect",
5674
+ terminal: "terminal_no_effect",
5675
+ })
5676
+ : inputHash,
5677
+ acceptedAt: Date.now(),
5678
+ } satisfies DurableTerminalScopeRecord,
5679
+ ];
5680
+ // Compact key tombstones for rows evicted by the cap are
5681
+ // retained ATOMICALLY with the scope write (review thread P2).
5682
+ return boundTerminalRetentionState(state.keys, preBound);
5683
+ });
5684
+ return "ok";
5685
+ } catch (error) {
5686
+ if (error instanceof TerminalIdempotencyConflictError) return "conflict";
5687
+ logger.warn(`sdk: terminal no-effect reservation failed: ${String(error)}`);
5688
+ return "failed";
5689
+ }
5690
+ };
5691
+ let active = [...promptSubmissions.entries()].find(
5692
+ ([, submission]) => submission.connectionId === connectionId && !submission.terminal,
5693
+ );
5694
+ if (!active) {
5695
+ // DURABLY reserve the key even for a no-active-turn abort: the
5696
+ // generic idempotency cache is in-memory only, so after restart
5697
+ // or eviction a same-key retry while a later prompt is active
5698
+ // must replay this no-effect row instead of aborting an
5699
+ // unrelated turn (review thread P2). No active turn means no
5700
+ // fence epoch; the marker uses sentinel 0. The reservation is
5701
+ // bounded (see reserveTerminalNoEffect).
5702
+ const reservation = await reserveTerminalNoEffect();
5703
+ if (reservation === "conflict") return { ok: false as const, reason: "conflict" as const };
5704
+ if (reservation === "failed") {
5705
+ // Without the durable reservation a same-key retry after
5706
+ // eviction/restart could abort an unrelated later turn, so a
5707
+ // failed reservation must NOT report success.
5708
+ return { ok: false as const, reason: "reservation_failed" as const };
5709
+ }
5710
+ // A SAME-input row/tombstone existed while the reservation awaited
5711
+ // the store: it is durable replay authority — replay its stored
5712
+ // result instead of returning no_active_turn over it (review
5713
+ // thread P2).
5714
+ if (existingReplay) return replayExisting(existingReplay);
5715
+ // RE-SCAN after the async reservation: the requester's prompt may
5716
+ // have moved from preflight to accepted during the filesystem
5717
+ // write. Returning no_active_turn here would leave the accepted
5718
+ // prompt to start (its pending-preflight entry may already be
5719
+ // removed, so the surface cleanup skips both cancellation seams)
5720
+ // and the durable no-effect row would block a same-key retry from
5721
+ // stopping it — fall through to the pre-run / active fencing path
5722
+ // when a submission now exists (review thread P1).
5723
+ active = [...promptSubmissions.entries()].find(
5724
+ ([, submission]) => submission.connectionId === connectionId && !submission.terminal,
5725
+ );
5726
+ if (!active) {
5727
+ // Close the remaining acceptance race: cancel the requester's
5728
+ // preflights HERE, in the same synchronous region as the
5729
+ // rescan (no await boundary between the scan and the cancel,
5730
+ // as the surface-level post-check had), so a prompt accepted
5731
+ // in that window cannot start with its preflight entry
5732
+ // already removed (review thread P1).
5733
+ if (preflightCancel?.hasPending()) {
5734
+ preflightCancel.cancel();
5735
+ // The seam cancels the SESSION-WIDE preflight controller:
5736
+ // invoke it only when no OTHER connection has a pending
5737
+ // admission, or this queued requester's abort would fail an
5738
+ // unrelated connection's active preflight (review thread P1).
5739
+ if (noOtherConnectionPreflights?.() !== false)
5740
+ terminalAbortSeams?.cancelPendingPreflightForTerminalAbort?.();
5741
+ }
5742
+ // No prompt won the race: finalize the reserved row so a later
5743
+ // same-key retry (including after restart) replays this
5744
+ // deterministic no_active_turn result instead of reservation
5745
+ // uncertainty (review thread P2).
5746
+ const no_active_turnResult = { ok: true as const, outcome: "no_active_turn" as const };
5747
+ // Hash the PUBLIC disposition the client receives (abortTerminal
5748
+ // transforms the outcome into {ok,selection,turn,terminal}); the
5749
+ // response-state observer hashes response.result, so hashing the
5750
+ // internal outcome shape could never match (review thread P2).
5751
+ await finalizeNoEffectReservation(
5752
+ hashResult({ ok: true, selection: scope, turn: "no_active_turn", terminal: "terminal_no_effect" }),
5753
+ );
5754
+ return no_active_turnResult;
5755
+ }
5756
+ }
4643
5757
  const [commandId, turnId] = active[0].split(":", 2);
4644
- if (commandId && turnId)
4645
- await terminalizePrompt(
4646
- { commandId, turnId },
4647
- { kind: "stopped", reason: "cancelled", provenance: "client_cancel" },
4648
- { fence: true },
5758
+ if (!commandId || !turnId) {
5759
+ const reservation = await reserveTerminalNoEffect();
5760
+ if (reservation === "conflict") return { ok: false as const, reason: "conflict" as const };
5761
+ if (reservation === "failed") {
5762
+ // Same durable-reservation guarantee as the no-active path.
5763
+ return { ok: false as const, reason: "reservation_failed" as const };
5764
+ }
5765
+ if (existingReplay) return replayExisting(existingReplay);
5766
+ const already_terminalResult = { ok: true as const, outcome: "already_terminal" as const };
5767
+ // Hash the PUBLIC disposition the client receives (abortTerminal
5768
+ // transforms the outcome into {ok,selection,turn,terminal}); the
5769
+ // response-state observer hashes response.result, so hashing the
5770
+ // internal outcome shape could never match (review thread P2).
5771
+ await finalizeNoEffectReservation(
5772
+ hashResult({ ok: true, selection: scope, turn: "no_active_turn", terminal: "terminal_no_effect" }),
4649
5773
  );
4650
- return { aborted: true, disposition: "cancelled" as const };
5774
+ return already_terminalResult;
5775
+ }
5776
+ // Accepted-but-not-started window: the submission exists but
5777
+ // agent_start has not bound executionHandle yet, and the preflight
5778
+ // cancellation entry was already removed after accept. Cancel the
5779
+ // in-flight session preflight so the pending #promptWithMessage
5780
+ // cannot continue into the agent, and FINALIZE the accepted prompt
5781
+ // as a pre-run client cancellation WITHOUT terminalizing — there is
5782
+ // no run handle to fence, and terminalizePrompt's missing-handle
5783
+ // fail-closed path would wrongly fence the SDK connection and leave
5784
+ // reconciliation unfinalized for a prompt that will never run
5785
+ // (review thread P2).
5786
+ if (!active[1].executionHandle) {
5787
+ // Persist the durable no-effect reservation BEFORE cancelling the
5788
+ // session preflight: a failed reservation must NOT leave the
5789
+ // prompt cancelled with no durable row (a later same-key retry
5790
+ // after eviction/restart could then abort an unrelated turn —
5791
+ // review thread P2).
5792
+ const reservation = await reserveTerminalNoEffect();
5793
+ if (reservation === "conflict") return { ok: false as const, reason: "conflict" as const };
5794
+ if (reservation === "failed") {
5795
+ return { ok: false as const, reason: "reservation_failed" as const };
5796
+ }
5797
+ if (existingReplay) return replayExisting(existingReplay);
5798
+ // RECHCK after the async write: agent_start may have bound the
5799
+ // execution handle during the reservation, so this is no longer
5800
+ // a pre-run cancellation — fall through to the ACTIVE-turn
5801
+ // fencing path below (terminalizePrompt with fence) instead of
5802
+ // finalizing without abortPromptAndWait (review thread P2).
5803
+ if (!active[1].executionHandle) {
5804
+ // REVALIDATE the captured submission: another abort may have
5805
+ // terminalized it during the reservation while a NEW prompt
5806
+ // entered preflight — cancelling the session-global preflight
5807
+ // for the stale capture would abort that unrelated prompt.
5808
+ // Only proceed when this exact nonterminal submission is still
5809
+ // authoritative (review thread P1).
5810
+ const currentSubmission = promptSubmissions.get(promptSubmissionKey({ commandId, turnId }));
5811
+ if (currentSubmission !== active[1] || currentSubmission.terminal) {
5812
+ // The captured submission is no longer authoritative: finalize
5813
+ // our provisional reservation so the durable row reflects the
5814
+ // settled outcome rather than lingering mid-transition (review
5815
+ // thread P2).
5816
+ const already_terminalResult = { ok: true as const, outcome: "already_terminal" as const };
5817
+ // Hash the PUBLIC disposition the client receives (abortTerminal
5818
+ // transforms the outcome into {ok,selection,turn,terminal}); the
5819
+ // response-state observer hashes response.result, so hashing the
5820
+ // internal outcome shape could never match (review thread P2).
5821
+ await finalizeNoEffectReservation(
5822
+ hashResult({
5823
+ ok: true,
5824
+ selection: scope,
5825
+ turn: "no_active_turn",
5826
+ terminal: "terminal_no_effect",
5827
+ }),
5828
+ );
5829
+ return already_terminalResult;
5830
+ }
5831
+ if (preflightCancel?.hasPending()) {
5832
+ preflightCancel.cancel();
5833
+ // The seam cancels the SESSION-WIDE preflight controller:
5834
+ // invoke it only when no OTHER connection has a pending
5835
+ // admission (review thread P1).
5836
+ if (noOtherConnectionPreflights?.() !== false)
5837
+ terminalAbortSeams?.cancelPendingPreflightForTerminalAbort?.();
5838
+ }
5839
+ await terminalizePrompt(
5840
+ { commandId, turnId },
5841
+ { kind: "stopped", reason: "cancelled", provenance: "client_cancel" },
5842
+ {},
5843
+ );
5844
+ // No prompt won the race: finalize the reserved row so a later
5845
+ // same-key retry replays this deterministic no_active_turn result
5846
+ // instead of reservation uncertainty (review thread P2).
5847
+ const no_active_turnResult = { ok: true as const, outcome: "no_active_turn" as const };
5848
+ // Hash the PUBLIC disposition the client receives (abortTerminal
5849
+ // transforms the outcome into {ok,selection,turn,terminal}); the
5850
+ // response-state observer hashes response.result, so hashing the
5851
+ // internal outcome shape could never match (review thread P2).
5852
+ await finalizeNoEffectReservation(
5853
+ hashResult({ ok: true, selection: scope, turn: "no_active_turn", terminal: "terminal_no_effect" }),
5854
+ );
5855
+ return no_active_turnResult;
5856
+ }
5857
+ }
5858
+ // Plan ordered step 4: write the bounded INITIAL MARKER (key/input
5859
+ // hashes, pending dispositions, publication false, response pending)
5860
+ // BEFORE any fence/stop/event effect, so a crash between the stop
5861
+ // and the semantic CAS still leaves a same-key retry that replays
5862
+ // deterministically instead of re-running effects. Marker failure is
5863
+ // process-local no-effect (AC 10) — nothing destructive has run yet.
5864
+ const markerEpoch = terminalAbortSeams?.getTerminalTurnEpoch?.();
5865
+ if (markerEpoch === undefined) return { ok: true as const, outcome: "no_effect" as const };
5866
+ let pendingReplay: DurableTerminalScopeRecord | undefined;
5867
+ let tombstoneReplay: EvictedTerminalKeyEntry | undefined;
5868
+ try {
5869
+ // Write the marker and any evicted-row key tombstones in ONE
5870
+ // atomic document transaction so a crash cannot leave the
5871
+ // durable store with neither the row nor its tombstone
5872
+ // (review thread P2). The marker itself stays pending (never
5873
+ // evicted); only completed rows are bounded.
5874
+ await durableStore.transactTerminalState(state => {
5875
+ // Atomic recheck: a concurrent request may have committed a
5876
+ // DIFFERENT input under this key after the earlier snapshot
5877
+ // check (dispatch-cache eviction), and the filter below must
5878
+ // never wipe that row — replacing it would let a later replay
5879
+ // of the SUCCEEDED request report a conflict (review thread
5880
+ // P2). A same-input PENDING row is an in-flight duplicate:
5881
+ // replay it instead of installing a second marker, so the
5882
+ // duplicate cannot race terminalization and flip the row to
5883
+ // uncertain while the original returns stopped (review
5884
+ // thread P2).
5885
+ const conflicting = state.scopes.find(s => keyHash && s.idempotencyKeyHash === keyHash);
5886
+ if (conflicting) {
5887
+ if (conflicting.idempotencyInputHash !== inputHash) throw new TerminalIdempotencyConflictError();
5888
+ if (conflicting.turnDisposition === "pending") {
5889
+ pendingReplay = conflicting;
5890
+ return { scopes: state.scopes, keys: state.keys };
5891
+ }
5892
+ }
5893
+ // A concurrent admission may ALSO have evicted a same-key row
5894
+ // into the tombstone collection after this request's snapshot;
5895
+ // recheck keys so a different input can never install a fresh
5896
+ // marker over existing durable replay authority (review
5897
+ // thread P2). A same-input tombstone already carries replay
5898
+ // authority: never install a second marker here.
5899
+ const tombstone = state.keys.find(s => keyHash && s.keyHash === keyHash);
5900
+ if (tombstone) {
5901
+ if (tombstone.inputHash !== inputHash) throw new TerminalIdempotencyConflictError();
5902
+ tombstoneReplay = tombstone;
5903
+ return { scopes: state.scopes, keys: state.keys };
5904
+ }
5905
+ const retained = state.scopes.filter(s => !(keyHash && s.idempotencyKeyHash === keyHash));
5906
+ const preBound: DurableTerminalScopeRecord[] = [
5907
+ ...retained,
5908
+ {
5909
+ selection: scope,
5910
+ ...(keyHash ? { idempotencyKeyHash: keyHash, idempotencyInputHash: inputHash } : {}),
5911
+ turnDisposition: "pending",
5912
+ terminalPublished: false,
5913
+ ownedWorkDisposition: "not_requested",
5914
+ automaticDeliveryDisposition: scope === "turn" ? "enabled" : "none",
5915
+ resumeOnOwnedCompletion: scope === "turn",
5916
+ turnContinuationFence: {
5917
+ state: "retained",
5918
+ abortedAttemptEpoch: markerEpoch,
5919
+ blockedContinuationIds: [],
5920
+ predecessorTombstones: [],
5921
+ ownedCompletionPolicy: scope === "turn" ? "enabled" : "disabled",
5922
+ },
5923
+ responseState: "pending",
5924
+ responsePayloadHash: inputHash,
5925
+ acceptedAt: Date.now(),
5926
+ } satisfies DurableTerminalScopeRecord,
5927
+ ];
5928
+ return boundTerminalRetentionState(state.keys, preBound);
5929
+ });
5930
+ } catch (error) {
5931
+ if (error instanceof TerminalIdempotencyConflictError) {
5932
+ // A different input won the durable key race inside the
5933
+ // transaction; reject rather than replacing its row.
5934
+ return { ok: false as const, reason: "conflict" as const };
5935
+ }
5936
+ logger.warn(`sdk: terminal initial marker persistence failed: ${String(error)}`);
5937
+ // No durable marker exists: do NOT acknowledge success without a
5938
+ // durable row — a same-key retry after restart/expiry would miss
5939
+ // replay and could abort an unrelated later prompt. Persist a
5940
+ // bounded marker-failure reservation first (review thread P2).
5941
+ const reservation = await reserveTerminalNoEffect("marker_failure");
5942
+ if (reservation === "conflict") return { ok: false as const, reason: "conflict" as const };
5943
+ if (reservation === "failed") {
5944
+ return { ok: false as const, reason: "reservation_failed" as const };
5945
+ }
5946
+ if (existingReplay) return replayExisting(existingReplay);
5947
+ return { ok: true as const, outcome: "no_effect" as const };
5948
+ }
5949
+ if (pendingReplay) {
5950
+ // An in-flight duplicate of this exact key+input was already
5951
+ // admitted; replay its pending row WITHOUT re-running the stop,
5952
+ // cleanup, or event.
5953
+ return {
5954
+ ok: true as const,
5955
+ outcome: "pending_replay" as const,
5956
+ stored: {
5957
+ responseState: pendingReplay.responseState,
5958
+ responsePayloadHash: pendingReplay.responsePayloadHash,
5959
+ terminalPublished: pendingReplay.terminalPublished === true,
5960
+ },
5961
+ };
5962
+ }
5963
+ if (tombstoneReplay) {
5964
+ // The key gained durable replay authority via an eviction
5965
+ // tombstone while this request was in flight; never install a
5966
+ // second marker or run the stop. Reconstruct the ORIGINAL stored
5967
+ // result exactly as the same-key replay path would (review
5968
+ // thread P2).
5969
+ const storedRow = {
5970
+ responseState: tombstoneReplay.responseState ?? "",
5971
+ responsePayloadHash: tombstoneReplay.responsePayloadHash ?? "",
5972
+ terminalPublished: tombstoneReplay.terminalPublished === true,
5973
+ };
5974
+ if (tombstoneReplay.turnDisposition === "stopped") {
5975
+ return {
5976
+ ok: true as const,
5977
+ outcome: (tombstoneReplay.ownedWorkDisposition === "stopped" ? "stopped_owned" : "stopped") as
5978
+ | "stopped"
5979
+ | "stopped_owned",
5980
+ stored: storedRow,
5981
+ };
5982
+ }
5983
+ if (tombstoneReplay.turnDisposition === "uncertain") {
5984
+ return { ok: true as const, outcome: "uncertain_replay" as const, stored: storedRow };
5985
+ }
5986
+ if (tombstoneReplay.turnDisposition === "no_effect_marker_failure") {
5987
+ return { ok: true as const, outcome: "no_effect" as const, stored: storedRow };
5988
+ }
5989
+ return { ok: true as const, outcome: "no_effect_replay" as const, stored: storedRow };
5990
+ }
5991
+ const captured: {
5992
+ proof?: RunSettlementProof & {
5993
+ terminalScope?: { scopeId: string; abortedAttemptEpoch: number; lineageIdHash: string };
5994
+ };
5995
+ published?: boolean;
5996
+ terminalized?: boolean;
5997
+ } = {};
5998
+ await terminalizePrompt(
5999
+ { commandId, turnId },
6000
+ { kind: "stopped", reason: "cancelled", provenance: "client_cancel" },
6001
+ {
6002
+ fence: true,
6003
+ terminal: { scope, ...(steeringSnapshotToken !== undefined ? { steeringSnapshotToken } : {}) },
6004
+ },
6005
+ undefined,
6006
+ captured,
6007
+ );
6008
+ // Success is decided by the terminalizePrompt outcome, NOT by the
6009
+ // submission record: an already-acknowledged prompt is finalized
6010
+ // (deleted) during emission, so a lookup here can return undefined
6011
+ // even for a landed terminal (P1). fail-closed paths leave
6012
+ // terminalized unset.
6013
+ if (captured.terminalized !== true) {
6014
+ // A CONCURRENT abort won the shared submission: settle THIS
6015
+ // request's pending marker to a completed uncertainty state so
6016
+ // the retention bound can evict it — otherwise concurrent
6017
+ // distinct-key aborts leave pending rows that grow the
6018
+ // reconciliation document unboundedly until restart (review
6019
+ // thread P2).
6020
+ try {
6021
+ await transactBoundedTerminalScopes(scopes =>
6022
+ scopes.map(scopeRecord => {
6023
+ const isMarker =
6024
+ (keyHash !== undefined && scopeRecord.idempotencyKeyHash === keyHash) ||
6025
+ (keyHash === undefined &&
6026
+ scopeRecord.selection === scope &&
6027
+ scopeRecord.turnDisposition === "pending");
6028
+ if (!isMarker) return scopeRecord;
6029
+ const responsePayloadHash = hashPublicUncertain("worker_unsettled");
6030
+ return {
6031
+ ...scopeRecord,
6032
+ turnDisposition: "uncertain" as const,
6033
+ ownedWorkDisposition: "uncertain" as const,
6034
+ // Hash the public worker_unsettled disposition the client
6035
+ // receives, or the response can never advance the row
6036
+ // (review thread P2).
6037
+ responsePayloadHash,
6038
+ // A same-key retry delivers the replay envelope (with the
6039
+ // replay reason); store its hash so the written replay can
6040
+ // advance the row (review thread P2).
6041
+ replayPayloadHash: hashResult({
6042
+ ok: true,
6043
+ selection: scope,
6044
+ turn: "uncertain",
6045
+ ownedWork: scope === "turn" ? "left_running" : "uncertain",
6046
+ automaticDelivery: scope === "turn" ? "enabled" : "none",
6047
+ resumeOnOwnedCompletion: scope === "turn",
6048
+ reason: "replay_uncertain",
6049
+ replay: {
6050
+ responseState: "pending",
6051
+ responsePayloadHash,
6052
+ terminalPublished: captured.published === true,
6053
+ },
6054
+ }),
6055
+ // Preserve the captured publication bit: if agent_end
6056
+ // was already published before settlement failed, the
6057
+ // implementation will NOT publish a second event, so the
6058
+ // durable row must not claim publication never occurred
6059
+ // (review thread P2).
6060
+ terminalPublished: captured.published === true,
6061
+ };
6062
+ }),
6063
+ );
6064
+ } catch (error) {
6065
+ logger.warn(`sdk: terminal losing-marker settle failed: ${String(error)}`);
6066
+ }
6067
+ return { ok: false as const, reason: "worker_unsettled" as const };
6068
+ }
6069
+ // For scope:"owned", stop the exact captured owned work and prove
6070
+ // quiescence before claiming stopped. Exactness comes from the
6071
+ // registered five-tuples of this turn's lineage+epoch; foreign or
6072
+ // unclassified work is never swept and yields uncertainty.
6073
+ const terminalScope = captured.proof?.terminalScope;
6074
+ let ownedStopped = true;
6075
+ // Owned settlement failed (incomplete authority, unavailable
6076
+ // manager, or a job that did not unwind within the grace):
6077
+ // transition this request's PENDING marker to completed
6078
+ // uncertainty BEFORE returning — otherwise distinct-key retries
6079
+ // accumulate non-evictable pending rows and same-key retries
6080
+ // misleadingly replay pending (review thread P2).
6081
+ const settleMarkerToUncertain = async (
6082
+ reason: "worker_unsettled" | "owned_unsettled",
6083
+ ): Promise<{ ok: false; reason: "worker_unsettled" | "owned_unsettled" }> => {
6084
+ // The EXACT response payload is stored at finalization so the
6085
+ // response-state advance requires equality (review thread P2).
6086
+ // The EXACT PUBLIC payload is stored at finalization: abortTerminal
6087
+ // maps {ok:false, reason} to the uncertain disposition the client
6088
+ // receives, and the response-state observer hashes response.result,
6089
+ // so the internal outcome shape can never match (review thread P2).
6090
+ const result = { ok: false as const, reason };
6091
+ const responsePayloadHash = hashPublicUncertain(reason);
6092
+ // A same-key retry delivers the replay envelope (with the replay
6093
+ // reason); store its hash so the written replay can advance the
6094
+ // row (review thread P2).
6095
+ const replayPayloadHash = hashResult({
6096
+ ok: true,
6097
+ selection: scope,
6098
+ turn: "uncertain",
6099
+ ownedWork: scope === "turn" ? "left_running" : "uncertain",
6100
+ automaticDelivery: scope === "turn" ? "enabled" : "none",
6101
+ resumeOnOwnedCompletion: scope === "turn",
6102
+ reason: "replay_uncertain",
6103
+ replay: {
6104
+ responseState: "pending",
6105
+ responsePayloadHash,
6106
+ terminalPublished: captured.published === true,
6107
+ },
6108
+ });
6109
+ try {
6110
+ await transactBoundedTerminalScopes(scopes =>
6111
+ scopes.map(scopeRecord => {
6112
+ const isMarker =
6113
+ (keyHash !== undefined && scopeRecord.idempotencyKeyHash === keyHash) ||
6114
+ (keyHash === undefined &&
6115
+ scopeRecord.selection === scope &&
6116
+ scopeRecord.turnDisposition === "pending");
6117
+ if (!isMarker) return scopeRecord;
6118
+ return {
6119
+ ...scopeRecord,
6120
+ turnDisposition: "uncertain" as const,
6121
+ ownedWorkDisposition: "uncertain" as const,
6122
+ responsePayloadHash,
6123
+ replayPayloadHash,
6124
+ // Preserve the captured publication bit: if agent_end
6125
+ // was already published before settlement failed, the
6126
+ // implementation will NOT publish a second event, so the
6127
+ // durable row must not claim publication never occurred
6128
+ // (review thread P2).
6129
+ terminalPublished: captured.published === true,
6130
+ };
6131
+ }),
6132
+ );
6133
+ } catch (error) {
6134
+ logger.warn(`sdk: terminal owned-uncertain marker settle failed: ${String(error)}`);
6135
+ }
6136
+ return result;
6137
+ };
6138
+ const settleOwnedUncertain = async () => await settleMarkerToUncertain("owned_unsettled");
6139
+ if (!terminalScope) {
6140
+ // The terminal scope was NOT registered (process-wide
6141
+ // saturation refused admission): no continuation fence bounds
6142
+ // the aborted turn. FAIL CLOSED to uncertainty — never report
6143
+ // stopped/stopped_owned without a registered fence (review
6144
+ // thread P2).
6145
+ return await settleMarkerToUncertain("worker_unsettled");
6146
+ }
6147
+ if (scope === "owned") {
6148
+ // The attempt's registration set may be KNOWN incomplete
6149
+ // (registry saturation skipped a tuple under the 8192 cap):
6150
+ // fail closed to uncertainty instead of settling an
6151
+ // incomplete set and claiming stopped_owned while a live job
6152
+ // keeps running (review thread P2).
6153
+ if (
6154
+ terminalScope &&
6155
+ isOwnedAttemptRegistrationIncomplete(terminalScope.lineageIdHash, terminalScope.abortedAttemptEpoch)
6156
+ ) {
6157
+ return await settleOwnedUncertain();
6158
+ }
6159
+ const exactJobs = terminalScope
6160
+ ? findOwnedRegistrationsForTurn(terminalScope.lineageIdHash, terminalScope.abortedAttemptEpoch)
6161
+ : [];
6162
+ if (exactJobs.length > 0) {
6163
+ // Resolve the manager from the ABORTING ENDPOINT (session)
6164
+ // captured on the registrations — the process-global
6165
+ // instance is the last-created session, so using it could
6166
+ // cancel another session's same-id foreign job and report
6167
+ // stopped_owned while the aborting session's job keeps
6168
+ // running (review thread P1). Fall back to the global for
6169
+ // legacy endpoint-less registrations.
6170
+ const endpointId = exactJobs[0]?.endpointId;
6171
+ const manager = AsyncJobManager.forEndpoint(endpointId) ?? AsyncJobManager.instance();
6172
+ if (!manager) {
6173
+ return await settleOwnedUncertain();
6174
+ }
6175
+ ownedStopped = (await settleOwnedWork(manager, exactJobs, OWNED_SETTLEMENT_GRACE_MS)) === "stopped";
6176
+ if (!ownedStopped) return await settleOwnedUncertain();
6177
+ }
6178
+ }
6179
+ // Semantic CAS: advance the INITIAL MARKER (matched by key hash, or
6180
+ // by selection+epoch when keyless) to the final dispositions through
6181
+ // the same full-document owner (plan step 15). The prompt terminal
6182
+ // is already durable; a failed write fails closed to safe
6183
+ // uncertainty — never a stopped disposition the record cannot prove.
6184
+ try {
6185
+ await transactBoundedTerminalScopes(scopes =>
6186
+ scopes.map(scopeRecord => {
6187
+ const isMarker =
6188
+ (keyHash !== undefined && scopeRecord.idempotencyKeyHash === keyHash) ||
6189
+ (keyHash === undefined &&
6190
+ scopeRecord.selection === scope &&
6191
+ scopeRecord.turnDisposition === "pending");
6192
+ if (!isMarker) return scopeRecord;
6193
+ const ownedWorkDisposition =
6194
+ scope === "turn" ? "left_running" : ownedStopped ? "stopped" : "uncertain";
6195
+ // Hash the EXACT public result the client receives: the surface
6196
+ // returns {ok: true, selection, turn, ...} and the delivery
6197
+ // observer hashes response.result, so the stored hash must
6198
+ // include ok or payloadMatches would never match a fresh
6199
+ // stopped/stopped_owned response and the durable responseState
6200
+ // would stay pending after a successful write (review thread
6201
+ // P2).
6202
+ const payloadHash = crypto
6203
+ .createHash("sha256")
6204
+ .update(
6205
+ JSON.stringify({
6206
+ ok: true,
6207
+ selection: scope,
6208
+ turn: "stopped",
6209
+ ownedWork: ownedWorkDisposition,
6210
+ automaticDelivery: scope === "turn" ? "enabled" : "none",
6211
+ resumeOnOwnedCompletion: scope === "turn",
6212
+ }),
6213
+ )
6214
+ .digest("hex");
6215
+ // A same-key retry delivers the replay envelope; store its
6216
+ // hash too so a written replay can advance the pending row
6217
+ // (review thread P2).
6218
+ const replayPayloadHash = crypto
6219
+ .createHash("sha256")
6220
+ .update(
6221
+ JSON.stringify({
6222
+ ok: true,
6223
+ selection: scope,
6224
+ turn: "stopped",
6225
+ ownedWork: ownedWorkDisposition,
6226
+ automaticDelivery: scope === "turn" ? "enabled" : "none",
6227
+ resumeOnOwnedCompletion: scope === "turn",
6228
+ replay: {
6229
+ responseState: "pending",
6230
+ responsePayloadHash: payloadHash,
6231
+ terminalPublished: captured.published === true,
6232
+ },
6233
+ }),
6234
+ )
6235
+ .digest("hex");
6236
+ return {
6237
+ ...scopeRecord,
6238
+ turnDisposition: "stopped" as const,
6239
+ terminalPublished: captured.published === true,
6240
+ ownedWorkDisposition,
6241
+ turnContinuationFence: {
6242
+ ...scopeRecord.turnContinuationFence,
6243
+ abortedAttemptEpoch:
6244
+ terminalScope?.abortedAttemptEpoch ??
6245
+ scopeRecord.turnContinuationFence.abortedAttemptEpoch,
6246
+ },
6247
+ responsePayloadHash: payloadHash,
6248
+ replayPayloadHash,
6249
+ terminalAt: Date.now(),
6250
+ };
6251
+ }),
6252
+ );
6253
+ } catch (error) {
6254
+ logger.warn(`sdk: terminal scope persistence failed: ${String(error)}`);
6255
+ // The prompt terminalization succeeded but the final marker
6256
+ // write failed: best-effort transition the pending marker to
6257
+ // completed uncertainty so same-key retries do not replay a
6258
+ // stale pending row and repeated failures do not accumulate
6259
+ // non-evictable rows (review thread P2).
6260
+ return await settleMarkerToUncertain("worker_unsettled");
6261
+ }
6262
+ if (scope === "owned") {
6263
+ return { ok: true as const, outcome: "stopped_owned" as const };
6264
+ }
6265
+ return { ok: true as const, outcome: "stopped" as const };
4651
6266
  },
4652
6267
  {
4653
6268
  admit: (clientRef?: string) => kindReconciliation.admit("skill", clientRef),
4654
6269
  release: (clientRef?: string) => kindReconciliation.releaseAdmission("skill", clientRef),
4655
6270
  noteAccepted: (correlation, clientRef, extra) =>
4656
6271
  kindReconciliation.noteAccepted("skill", correlation, clientRef, extra),
6272
+ cancel: async correlation => {
6273
+ const outcome = await kindReconciliation.claimPendingOutcome("skill", correlation, {
6274
+ kind: "stopped",
6275
+ reason: "cancelled",
6276
+ provenance: "client_cancel",
6277
+ });
6278
+ await kindReconciliation.finalizeOutcome("skill", correlation, outcome);
6279
+ },
4657
6280
  noteTransition: (correlation, frame) => kindReconciliation.noteTransition("skill", correlation, frame),
4658
6281
  lookup: selector => kindReconciliation.lookup("skill", selector),
6282
+ reserveSteer: kindReconciliation.reserveSteer,
6283
+ settleSteer: kindReconciliation.settleSteer,
4659
6284
  },
6285
+ terminalAbortSeams,
4660
6286
  );
4661
6287
  cancelPreflightsForConnection = controlSurface.cancelPendingPreflightsForConnection;
4662
6288
  const abandonPromptResponse = (connectionId: string, frame: Record<string, unknown>) => {
@@ -4677,10 +6303,11 @@ export function createNotificationsExtension(
4677
6303
  abandonPrompt(submission);
4678
6304
  };
4679
6305
 
4680
- const sendSdkFrame = (connectionId: string, frame: Record<string, unknown>) => {
6306
+ const sendSdkFrame = (connectionId: string, frame: Record<string, unknown>): "written" | "dropped" => {
4681
6307
  if (extensionShuttingDown || runtime?.stopping || runtimes.get(id) !== runtime) {
6308
+ // Deliberate drop (AC 17/20): no write, no post-write hook, no fallback.
4682
6309
  abandonPromptResponse(connectionId, frame);
4683
- return;
6310
+ return "dropped";
4684
6311
  }
4685
6312
  const json = JSON.stringify(frame);
4686
6313
  if (connectionId.startsWith("seam:")) {
@@ -4697,7 +6324,7 @@ export function createNotificationsExtension(
4697
6324
  abandonPromptResponse(connectionId, frame);
4698
6325
  throw error;
4699
6326
  }
4700
- return;
6327
+ return "written";
4701
6328
  }
4702
6329
  try {
4703
6330
  server.sendTo(connectionId, json);
@@ -4706,6 +6333,7 @@ export function createNotificationsExtension(
4706
6333
  abandonPromptResponse(connectionId, frame);
4707
6334
  throw error;
4708
6335
  }
6336
+ return "written";
4709
6337
  };
4710
6338
 
4711
6339
  /**
@@ -4780,7 +6408,7 @@ export function createNotificationsExtension(
4780
6408
  },
4781
6409
  start: async () => await server.start(),
4782
6410
  stop: async () => await server.stopAndWait(),
4783
- broadcastFrame: frame => server.pushFrame(JSON.stringify(frame)),
6411
+ broadcastFrame: frame => broadcastEventFrame(frame),
4784
6412
  },
4785
6413
  ...(preparesExistingThread ? { readiness: "deferred" as const } : {}),
4786
6414
  ...(activationGate ? { activationGate } : {}),
@@ -4790,6 +6418,105 @@ export function createNotificationsExtension(
4790
6418
  installProviderDefinitions,
4791
6419
  onProviderDefinitionsRemoved: removeProviderDefinitions,
4792
6420
  onRequest: options.onSdkRequest,
6421
+ onControlResponseDelivery: async (_connectionId, request, response, outcome) => {
6422
+ // Terminal abort: persist the monotonic response-state transition
6423
+ // (AC 18) — pending -> sent on a written response, pending -> failed
6424
+ // on a rejected/dropped write. A same-key retry then replays the
6425
+ // stored disposition with the matching response state.
6426
+ if (
6427
+ request.operation === "turn.abort" &&
6428
+ typeof request.input === "object" &&
6429
+ request.input !== null &&
6430
+ (request.input as { mode?: unknown }).mode === "terminal" &&
6431
+ typeof request.idempotencyKey === "string" &&
6432
+ durableStore
6433
+ ) {
6434
+ const keyHash = crypto.createHash("sha256").update(request.idempotencyKey).digest("hex");
6435
+ // Match the NORMALIZED terminal input hash too: a same-key
6436
+ // request with a different scope (conflict after in-memory
6437
+ // eviction) must never advance the ORIGINAL pending marker's
6438
+ // response state — only the response for the exact input that
6439
+ // produced the record may transition it (review thread P2).
6440
+ // Strictly validate first: a MALFORMED retry (e.g. scope:"bogus")
6441
+ // rejected by dispatch must not match a prior valid scope:"turn"
6442
+ // row through the "not owned => turn" fallback.
6443
+ const input = request.input as Record<string, unknown>;
6444
+ const mode = input.mode;
6445
+ const rawScope = input.scope;
6446
+ if (mode !== "terminal") return;
6447
+ if (rawScope !== undefined && rawScope !== "turn" && rawScope !== "owned") return;
6448
+ for (const key of Object.keys(input)) if (key !== "mode" && key !== "scope") return;
6449
+ const scopeInput = rawScope === "owned" ? "owned" : "turn";
6450
+ const inputHash = crypto
6451
+ .createHash("sha256")
6452
+ .update(JSON.stringify({ mode: "terminal", scope: scopeInput }))
6453
+ .digest("hex");
6454
+ try {
6455
+ // Same hydration barrier as the abort path: never race a still
6456
+ // pending store load with the response-state transition (P2).
6457
+ await reconciliationReady;
6458
+ // Update the matching LIVE scope OR its evicted compact
6459
+ // tombstone atomically: if the row was moved to
6460
+ // evictedTerminalKeys by the 256-row retention cap while this
6461
+ // write was in flight, scanning only live scope rows would
6462
+ // leave the tombstone responseState:"pending" and a same-key
6463
+ // replay after cache expiry/restart would report a false
6464
+ // durable delivery state (review thread P2).
6465
+ // Hash the ACTUAL written response payload: the durable state may only
6466
+ // advance when the written response corresponds to the row's payload.
6467
+ // When more than 256 concurrent requests evict an in-flight abort from
6468
+ // the dispatch cache, a same-key retry can return pending_replay while
6469
+ // the original is still terminalizing — matching only key+input would
6470
+ // mark the original marker sent for the retry's uncertainty response,
6471
+ // and the original's later stopped CAS would replace the payload hash
6472
+ // without resetting the state, making durable replay claim the stopped
6473
+ // payload was sent when only the pending response was written (review
6474
+ // thread P2). A final non-pending row whose stored hash is the input
6475
+ // placeholder (no_effect/uncertain) still advances: its own response is
6476
+ // the only one written for it.
6477
+ const responsePayloadHash =
6478
+ response && typeof response === "object" && "result" in response
6479
+ ? crypto
6480
+ .createHash("sha256")
6481
+ .update(JSON.stringify((response as { result: unknown }).result))
6482
+ .digest("hex")
6483
+ : undefined;
6484
+ // Require EXACT payload equality: finalization now stores the precise
6485
+ // final response hash for every disposition (including uncertainty and
6486
+ // no-effect), so a pending_replay retry whose payload differs can never
6487
+ // mark the durable row sent (review thread P2). A same-key retry
6488
+ // delivers the replay-shaped payload (replay envelope, and the replay
6489
+ // reason for uncertainty), so finalization also stores its hash and the
6490
+ // written replay response advances the row exactly like the original
6491
+ // response would (review thread P2).
6492
+ const payloadMatches = (record: { responsePayloadHash?: string; replayPayloadHash?: string }) =>
6493
+ responsePayloadHash !== undefined &&
6494
+ (record.responsePayloadHash === responsePayloadHash ||
6495
+ record.replayPayloadHash === responsePayloadHash);
6496
+ const nextResponseState: "sent" | "failed" = outcome === "written" ? "sent" : "failed";
6497
+ await durableStore.transactTerminalState(state => ({
6498
+ scopes: state.scopes.map(scope =>
6499
+ scope.idempotencyKeyHash === keyHash &&
6500
+ scope.idempotencyInputHash === inputHash &&
6501
+ scope.responseState === "pending" &&
6502
+ payloadMatches(scope)
6503
+ ? { ...scope, responseState: nextResponseState }
6504
+ : scope,
6505
+ ),
6506
+ keys: state.keys.map(key =>
6507
+ key.keyHash === keyHash &&
6508
+ key.inputHash === inputHash &&
6509
+ key.responseState === "pending" &&
6510
+ payloadMatches(key)
6511
+ ? { ...key, responseState: nextResponseState }
6512
+ : key,
6513
+ ),
6514
+ }));
6515
+ } catch (error) {
6516
+ logger.warn(`sdk: terminal response-state persistence failed: ${String(error)}`);
6517
+ }
6518
+ }
6519
+ },
4793
6520
  beforeControlResponse: async (_connectionId, request, response, sendTerminal) => {
4794
6521
  if (typeof request.operation !== "string" || !identityControlOperations.has(request.operation)) return;
4795
6522
  const pending = deferredIdentityRotation;
@@ -4863,7 +6590,8 @@ export function createNotificationsExtension(
4863
6590
  if (
4864
6591
  (request.operation === "turn.prompt" ||
4865
6592
  request.operation === "turn.follow_up" ||
4866
- request.operation === "turn.abort_and_prompt") &&
6593
+ request.operation === "turn.abort_and_prompt" ||
6594
+ request.operation === "skill.invoke") &&
4867
6595
  response.ok === true &&
4868
6596
  response.result &&
4869
6597
  typeof response.result === "object" &&
@@ -4918,7 +6646,15 @@ export function createNotificationsExtension(
4918
6646
  dispatchControl(
4919
6647
  controlSurface,
4920
6648
  OPERATIONS.find(row => row.kind === "control" && row.sdkId === operation),
4921
- controlRequestFromFrame(request as Record<string, unknown>),
6649
+ {
6650
+ id: requestId,
6651
+ operation,
6652
+ input: request.input,
6653
+ expectedRevision:
6654
+ typeof request.expectedRevision === "string" ? request.expectedRevision : undefined,
6655
+ idempotencyKey: typeof request.idempotencyKey === "string" ? request.idempotencyKey : undefined,
6656
+ confirm: request.confirm === true,
6657
+ },
4922
6658
  ),
4923
6659
  );
4924
6660
 
@@ -4930,16 +6666,6 @@ export function createNotificationsExtension(
4930
6666
  },
4931
6667
  query: async (connectionId, frame) => {
4932
6668
  const request = frame as { id?: unknown; query?: unknown; input?: unknown; cursor?: unknown };
4933
- // D1: a non-string top-level cursor is rejected, never silently
4934
- // dropped into a fresh-snapshot query (parity with the loopback
4935
- // session-runtime adapter).
4936
- if (request.cursor !== undefined && typeof request.cursor !== "string")
4937
- return {
4938
- type: "query_response",
4939
- id: typeof request.id === "string" ? request.id : undefined,
4940
- ok: false,
4941
- error: { code: "invalid_input", message: "cursor must be a non-empty string" },
4942
- };
4943
6669
  const response = await queryHandlers.dispatch({
4944
6670
  id: typeof request.id === "string" ? request.id : undefined,
4945
6671
  query: typeof request.query === "string" ? request.query : "",
@@ -4963,11 +6689,13 @@ export function createNotificationsExtension(
4963
6689
  runtime = {
4964
6690
  server,
4965
6691
  host,
6692
+ broadcastEventFrame,
4966
6693
  revisions,
4967
6694
  cursors,
4968
6695
  id,
4969
6696
  endpointScope: isolateChatEndpoint ? "chat" : "default",
4970
6697
  idleSeq: 0,
6698
+ stopSessionNameObserver: () => {},
4971
6699
  pendingInteractive,
4972
6700
  brokerRegistrationActive: false,
4973
6701
  hostStopped: false,
@@ -4995,21 +6723,33 @@ export function createNotificationsExtension(
4995
6723
  cancelPostmortemCleanup: () => {},
4996
6724
 
4997
6725
  redact: true,
4998
- committedRedact: true,
6726
+ // Provisional policy withholds delivery. This is not a committed redaction
6727
+ // decision: terminal text may be retained in the bounded settlement window
6728
+ // until the parsed policy either clears it for redaction or activates it.
6729
+ committedRedact: false,
4999
6730
  policySuspended: true,
5000
6731
  verbosity: "lean",
5001
6732
  stream: false,
6733
+ settlementWindow: 0,
5002
6734
  policyGeneration: 0,
5003
6735
  workflowGatePublicationEpoch: 0,
5004
6736
  sessionTag: tag,
5005
6737
  busy: false,
5006
6738
  pendingPromptCorrelations,
6739
+ pendingPromptCorrelationsBySdkRunToken,
5007
6740
  activePromptCorrelation: undefined,
5008
6741
  bindPromptExecutionHandle,
5009
- peekPromptPendingOutcome: correlation => kindReconciliation.peekPendingOutcome(correlation),
6742
+ peekPromptPendingOutcome: correlation => {
6743
+ const kind =
6744
+ promptSubmissions.get(promptSubmissionKey(correlation))?.reconciliationKind ?? ("prompt" as const);
6745
+ return kindReconciliation.peekPendingOutcome(kind, correlation);
6746
+ },
5010
6747
  terminalizePrompt: (correlation, outcome, extra) => terminalizePrompt(correlation, outcome, {}, extra),
5011
6748
  notePromptReconciliation: (correlation, frame) => {
5012
- void kindReconciliation.noteTransition("prompt", correlation, frame);
6749
+ const kind = correlation
6750
+ ? (promptSubmissions.get(promptSubmissionKey(correlation))?.reconciliationKind ?? ("prompt" as const))
6751
+ : "prompt";
6752
+ return kindReconciliation.noteTransition(kind, correlation, frame);
5013
6753
  },
5014
6754
  emitPromptFailure,
5015
6755
  emitPromptLifecycle,
@@ -5020,7 +6760,6 @@ export function createNotificationsExtension(
5020
6760
  { toolName: string; args?: unknown; pendingPhase?: "completed" | "failed" | "cancelled" }
5021
6761
  >(),
5022
6762
  deferredInboundControls: [],
5023
- notificationRootRegistration: undefined,
5024
6763
  };
5025
6764
  const initializedRuntime = runtime;
5026
6765
  runtimes.set(id, initializedRuntime);
@@ -5093,6 +6832,26 @@ export function createNotificationsExtension(
5093
6832
  );
5094
6833
  } catch {}
5095
6834
  };
6835
+ const sendInboundAck = (
6836
+ connectionId: string,
6837
+ inbound: { sessionId: string; updateId?: number },
6838
+ state: "accepted" | "rejected" | "dropped",
6839
+ reason?: "inbound_fenced" | "policy_suspended" | "invalid_input" | "injection_failed",
6840
+ ): void => {
6841
+ if (typeof inbound.updateId !== "number") return;
6842
+ try {
6843
+ server.sendTo(
6844
+ connectionId,
6845
+ JSON.stringify({
6846
+ type: "inbound_ack",
6847
+ sessionId: inbound.sessionId,
6848
+ updateId: inbound.updateId,
6849
+ state,
6850
+ ...(reason ? { reason } : {}),
6851
+ }),
6852
+ );
6853
+ } catch {}
6854
+ };
5096
6855
  try {
5097
6856
  server.onSdkFrame((err, inbound) => {
5098
6857
  if (err) {
@@ -5150,7 +6909,9 @@ export function createNotificationsExtension(
5150
6909
  });
5151
6910
  server.onConnectionClose((_err, connectionId) => {
5152
6911
  if (!connectionId) return;
5153
- controlSurface.cancelPendingPreflightsForConnection(connectionId);
6912
+ void controlSurface
6913
+ .cancelPendingPreflightsForConnection(connectionId)
6914
+ .catch(error => logger.warn(`sdk: failed to cancel disconnected preflight: ${String(error)}`));
5154
6915
  host.handleDisconnect(connectionId);
5155
6916
  hostCapCache.delete(connectionId);
5156
6917
  // The socket is gone, so its fence has nothing left to refuse. Dropping the
@@ -5164,7 +6925,9 @@ export function createNotificationsExtension(
5164
6925
  // therefore only abandons delivery; ACP rejects its own local waiter once and
5165
6926
  // terminal authority stays with the eventual normalized SDK outcome.
5166
6927
  for (const submission of promptSubmissions.values())
5167
- if (submission.connectionId === connectionId) abandonPrompt(submission);
6928
+ if (submission.connectionId === connectionId) {
6929
+ abandonPrompt(submission);
6930
+ }
5168
6931
  });
5169
6932
 
5170
6933
  server.onReply((err, reply) => {
@@ -5414,34 +7177,60 @@ export function createNotificationsExtension(
5414
7177
 
5415
7178
  // Inbound free-text injection / in-thread config command from a session
5416
7179
  // thread (forwarded by the daemon over the WS, fail-closed at the daemon).
5417
- server.onInbound((err, inbound) => {
7180
+ server.onInbound(async (err, inbound) => {
5418
7181
  if (err || !inbound) return;
5419
- if (initializedRuntime.inboundFenced) return;
7182
+ const notificationOrigin = hostCapCache.get(inbound.connectionId)?.has(ASK_SELECTED_ACK_CAPABILITY);
7183
+ const admission = notificationInboundAdmission({
7184
+ inboundFenced: initializedRuntime.inboundFenced,
7185
+ policySuspended: runtime?.policySuspended ?? false,
7186
+ notificationOrigin: notificationOrigin ?? false,
7187
+ controlCommand: inbound.kind === "control_command",
7188
+ });
7189
+ if (admission.outcome === "drop" && admission.reason === "inbound_fenced") {
7190
+ sendInboundAck(inbound.connectionId, inbound, "dropped", admission.reason);
7191
+ // A fenced predecessor keeps its native server alive for the terminal
7192
+ // response, so the daemon can still deliver here after it has already
7193
+ // ACKed the user's Telegram message. Dropping without a diagnostic
7194
+ // made that loss undiscoverable (2026-08-14 incident).
7195
+ logger.warn(
7196
+ `notifications: inbound ${inbound.kind} dropped: runtime is inbound-fenced (updateId=${String(
7197
+ (inbound as { updateId?: unknown }).updateId ?? "none",
7198
+ )})`,
7199
+ );
7200
+ return;
7201
+ }
5420
7202
  const authenticatedInbound = inbound as typeof inbound & {
5421
7203
  connectionId: string;
5422
7204
  messageId?: number;
5423
7205
  reason?: string;
5424
7206
  };
5425
- const notificationOrigin = hostCapCache
5426
- .get(authenticatedInbound.connectionId)
5427
- ?.has(ASK_SELECTED_ACK_CAPABILITY);
5428
- if (runtime?.policySuspended && notificationOrigin) {
5429
- if (inbound.kind === "control_command") {
5430
- const frame = sdkInboundFrame(inbound.commandJson);
5431
- if (frame) {
5432
- const suspendedRuntime = runtime;
5433
- runtime.deferredInboundControls.push(() => {
5434
- if (
5435
- runtimes.get(id) === suspendedRuntime &&
5436
- !suspendedRuntime.stopping &&
5437
- !suspendedRuntime.policySuspended
5438
- )
5439
- inboundSdkFrame?.(`seam:${inbound.requestId ?? "notification"}`, frame);
5440
- });
5441
- }
7207
+ if (admission.outcome === "defer") {
7208
+ // Provisional policy defers valid control commands to activate(); they are
7209
+ // NOT dropped, so no terminal dropped acknowledgement is emitted. The
7210
+ // control_command_result frame after execution is the authoritative reply.
7211
+ const frame = sdkInboundFrame(inbound.commandJson);
7212
+ if (frame) {
7213
+ const suspendedRuntime = runtime;
7214
+ runtime.deferredInboundControls.push(() => {
7215
+ if (
7216
+ runtimes.get(id) === suspendedRuntime &&
7217
+ !suspendedRuntime.stopping &&
7218
+ !suspendedRuntime.policySuspended
7219
+ )
7220
+ inboundSdkFrame?.(`seam:${inbound.requestId ?? "notification"}`, frame);
7221
+ });
5442
7222
  }
5443
7223
  return;
5444
7224
  }
7225
+ if (admission.outcome === "drop") {
7226
+ sendInboundAck(authenticatedInbound.connectionId, authenticatedInbound, "dropped", admission.reason);
7227
+ logger.warn(
7228
+ `notifications: inbound ${inbound.kind} dropped: notification policy is suspended (updateId=${String(
7229
+ (inbound as { updateId?: unknown }).updateId ?? "none",
7230
+ )})`,
7231
+ );
7232
+ return;
7233
+ }
5445
7234
  if (inbound.kind === "control_command") {
5446
7235
  const frame = sdkInboundFrame(inbound.commandJson);
5447
7236
  if (frame) {
@@ -5472,12 +7261,14 @@ export function createNotificationsExtension(
5472
7261
  if (inbound.kind === "user_message") {
5473
7262
  // Inject as a user turn (steers/continues the agent; the resulting
5474
7263
  // turn streams back via the turn_end handler even when not idle).
5475
- // Record the update id so it can be acked as "consumed" on the next
5476
- // turn_start, and steer (vs start a fresh turn) when already busy.
7264
+ // Session-side acceptance is explicit: the daemon must not leave its
7265
+ // optimistic queued reaction in place when this live host rejects/drop.
5477
7266
  const text = inbound.text ?? "";
5478
7267
  const images = inbound.images ?? [];
5479
- if (!text && images.length === 0) return;
5480
- if (runtime && typeof inbound.updateId === "number") runtime.pendingInbound.add(inbound.updateId);
7268
+ if (!text && images.length === 0) {
7269
+ sendInboundAck(authenticatedInbound.connectionId, authenticatedInbound, "rejected", "invalid_input");
7270
+ return;
7271
+ }
5481
7272
  const content: string | (TextContent | ImageContent)[] =
5482
7273
  images.length > 0
5483
7274
  ? [
@@ -5492,9 +7283,35 @@ export function createNotificationsExtension(
5492
7283
  ),
5493
7284
  ]
5494
7285
  : text;
7286
+ let acceptedSent = false;
7287
+ const acceptAdmission = (): void => {
7288
+ // Fired by AgentSession's preflight acceptance: after admission has
7289
+ // committed but before the turn starts. Registering the update id and
7290
+ // acking here means turn_start can no longer race ahead of the
7291
+ // pendingInbound registration it consumes.
7292
+ if (acceptedSent) return;
7293
+ acceptedSent = true;
7294
+ if (runtime && typeof inbound.updateId === "number") runtime.pendingInbound.add(inbound.updateId);
7295
+ sendInboundAck(authenticatedInbound.connectionId, authenticatedInbound, "accepted");
7296
+ };
5495
7297
  try {
5496
- api.sendUserMessage(content, runtime?.busy ? { deliverAs: "steer" } : undefined);
7298
+ // sendUserMessage is async and settles only after the full prompt, so
7299
+ // acceptance is signalled by the preflight-acceptance callback instead
7300
+ // of after the await; a rejection (preflight, admission, or later)
7301
+ // still reaches the catch and maps to a rejected ack.
7302
+ await api.sendUserMessage(content, {
7303
+ ...(runtime?.busy ? { deliverAs: "steer" as const } : {}),
7304
+ onPreflightAcceptCommit: acceptAdmission,
7305
+ onPreflightAccepted: acceptAdmission,
7306
+ });
7307
+ acceptAdmission();
5497
7308
  } catch (e) {
7309
+ sendInboundAck(
7310
+ authenticatedInbound.connectionId,
7311
+ authenticatedInbound,
7312
+ "rejected",
7313
+ "injection_failed",
7314
+ );
5498
7315
  logger.warn(`notifications: sendUserMessage failed: ${String(e)}`);
5499
7316
  }
5500
7317
  return;
@@ -5574,44 +7391,42 @@ export function createNotificationsExtension(
5574
7391
  await cleanupAbandonedStartup();
5575
7392
  return { status: "failed" };
5576
7393
  }
5577
- // Record canonical identity before transport startup so lifecycle events emitted
5578
- // by an early server-start callback cannot overtake it in replay. The direct
5579
- // socket publication remains below, after the server is listening.
7394
+
7395
+ // Fail-closed daemon isolation: the chat daemons (Telegram, Discord,
7396
+ // Slack) are optional notification adapters, never session authority.
7397
+ // No daemon ownership is acquired, awaited, or verified before core
7398
+ // publication — a slow, wedged, blocked, or crashed daemon must degrade
7399
+ // ONLY notification delivery, never an ACP/MCP session open. Native
7400
+ // frames are ephemeral, so publish identity first; late SDK consumers
7401
+ // recover it from event_replay.
5580
7402
  const identityHeader = {
5581
7403
  type: "identity_header",
5582
7404
  sessionId: id,
5583
- ...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
7405
+ ...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName(), telegramTopicsEnabled()),
5584
7406
  };
5585
- host.emitEvent({ kind: identityHeader.type, payload: identityHeader });
5586
- // Core SDK authority is published before optional notification adapters acquire
5587
- // daemon ownership. A blocked adapter must not make an interactive session
5588
- // undiscoverable or uncontrollable.
7407
+ emitSessionEvent(initializedRuntime, identityHeader);
5589
7408
  const endpoint = await sdkRuntime.startTransport();
5590
7409
  initializedRuntime.notificationOwnerState = "ready";
5591
7410
  if (notificationsEnabledForSession && settingsAvailable && settings) {
7411
+ // Daemon ownership is acquired AFTER the core SDK endpoint is
7412
+ // published, and is deliberately NOT tracked in
7413
+ // `sessionLifecycleTasks`: shutdown joins that set, so a wedged
7414
+ // ensure tracked there would hang teardown. Every write the settled
7415
+ // outcome performs is identity-guarded instead. Until it settles the
7416
+ // owner state stays "retry", so activate() withholds notification
7417
+ // adapters and a later reconcile re-attempts.
5592
7418
  initializedRuntime.notificationOwnerState = "retry";
5593
- try {
5594
- const ownership = await ensureConfiguredDaemonOwners(settings, cfg, ctx.cwd, id, token => {
5595
- retainNotificationRootRegistration(initializedRuntime, settings, ctx.cwd, id, token);
5596
- });
5597
- initializedRuntime.notificationOwnerState =
5598
- ownership === "ready" || (ownership === "blocked_identity_with_sibling" && isolateChatEndpoint)
5599
- ? "ready"
5600
- : "blocked";
5601
- if (ownership === "blocked_identity") {
5602
- logger.warn("notifications: Telegram daemon ownership is blocked; core SDK remains available.");
5603
- }
5604
- if (ownership === "blocked_identity_with_sibling" && !isolateChatEndpoint) {
5605
- logger.warn(
5606
- "notifications: Telegram ownership changed after core publication; preserving the canonical endpoint and withholding adapters.",
5607
- );
5608
- }
5609
- } catch (error) {
5610
- initializedRuntime.notificationOwnerState = "retry";
5611
- logger.warn(
5612
- `notifications: provider daemon ownership unavailable; core SDK remains available: ${String(error)}`,
5613
- );
5614
- }
7419
+ initializedRuntime.notificationOwnerKey = daemonOwnershipKey(cfg);
7420
+ const ownershipRuntime = initializedRuntime;
7421
+ kickDaemonOwnership(settings, cfg, (outcome, key) => {
7422
+ applyDaemonOwnership(id, ownershipRuntime, outcome, isolateChatEndpoint, key);
7423
+ // Adapters activate only through reconciliation, so re-run it once
7424
+ // ownership is known. Detached: no lifecycle path awaits this.
7425
+ if (runtimes.get(id) !== ownershipRuntime || ownershipRuntime.stopping || extensionShuttingDown) return;
7426
+ void controller
7427
+ .reconcileCurrentSession(ctx)
7428
+ .catch(error => logger.warn(`notifications: post-ownership reconciliation failed: ${String(error)}`));
7429
+ });
5615
7430
  }
5616
7431
 
5617
7432
  // The native server owns the only authoritative view of this host's live
@@ -5620,7 +7435,10 @@ export function createNotificationsExtension(
5620
7435
  // runtime's alone, so only this runtime's teardown can retract it.
5621
7436
  initializedRuntime.evidencePublication = publishSessionHostRuntimeEvidence({
5622
7437
  attachedClients: () => server.clientCount(),
5623
- workInFlight: () => initializedRuntime.busy || initializedRuntime.pendingPromptCorrelations.length > 0,
7438
+ workInFlight: () =>
7439
+ initializedRuntime.busy ||
7440
+ initializedRuntime.pendingPromptCorrelations.length > 0 ||
7441
+ initializedRuntime.pendingPromptCorrelationsBySdkRunToken.size > 0,
5624
7442
  });
5625
7443
  ephemeralTurns.configureAuthority({
5626
7444
  sessionId: id,
@@ -5635,6 +7453,35 @@ export function createNotificationsExtension(
5635
7453
  }
5636
7454
 
5637
7455
  server.pushFrame(JSON.stringify(identityHeader));
7456
+ let publishedSessionName = identityHeader.title;
7457
+ const sessionNameObserver = setInterval(() => {
7458
+ if (runtime?.stopping || runtimes.get(id) !== runtime) return;
7459
+ if (ctx.sessionManager.getSessionId() !== id) return;
7460
+ const sessionName = ctx.sessionManager.getSessionName();
7461
+ if (!sessionName || sessionName === publishedSessionName) return;
7462
+ publishedSessionName = sessionName;
7463
+ const identity = {
7464
+ type: "identity_header",
7465
+ sessionId: id,
7466
+ ...buildIdentity(ctx.cwd, sessionName, telegramTopicsEnabled()),
7467
+ };
7468
+ emitSessionEvent(initializedRuntime, identity);
7469
+ server.pushFrame(JSON.stringify(identity));
7470
+ }, 250);
7471
+ sessionNameObserver.unref?.();
7472
+ runtime.stopSessionNameObserver = () => clearInterval(sessionNameObserver);
7473
+ const sessionNameAfterStartup =
7474
+ ctx.sessionManager.getSessionId() === id ? ctx.sessionManager.getSessionName() : undefined;
7475
+ if (sessionNameAfterStartup && sessionNameAfterStartup !== publishedSessionName) {
7476
+ publishedSessionName = sessionNameAfterStartup;
7477
+ const identity = {
7478
+ type: "identity_header",
7479
+ sessionId: id,
7480
+ ...buildIdentity(ctx.cwd, sessionNameAfterStartup, telegramTopicsEnabled()),
7481
+ };
7482
+ emitSessionEvent(initializedRuntime, identity);
7483
+ server.pushFrame(JSON.stringify(identity));
7484
+ }
5638
7485
  const agentDir = lifecycleAgentDir ?? settings?.getAgentDir?.();
5639
7486
  if (lifecycleRequired && !agentDir) throw new Error("Lifecycle SDK host requires an agent directory.");
5640
7487
 
@@ -5854,7 +7701,7 @@ export function createNotificationsExtension(
5854
7701
  logger.warn(`notifications: failed to start server: ${String(e)}`);
5855
7702
  const result = failLifecycleStartup("failed", e);
5856
7703
  finishStartup(result);
5857
- let suppressExtensionError = result.failure?.message === "Lifecycle SDK startup was cancelled.";
7704
+ let suppressExtensionError = false;
5858
7705
  let stopped = false;
5859
7706
  try {
5860
7707
  stopped = await stopSession(id, "session", runtime);
@@ -5891,23 +7738,18 @@ export function createNotificationsExtension(
5891
7738
  if (runtime.notificationOwnerState === "retry") {
5892
7739
  const { cfg, settings, settingsAvailable } = resolveSettings(options.settings);
5893
7740
  if (!settingsAvailable || !settings) return "failed";
5894
- try {
5895
- const ownership = await ensureConfiguredDaemonOwners(
5896
- settings,
5897
- cfg,
5898
- binding.cwd,
5899
- binding.sessionId,
5900
- token => {
5901
- retainNotificationRootRegistration(runtime, settings, binding.cwd, binding.sessionId, token);
5902
- },
7741
+ // Settle-or-pending: never await ownership on this path. A pending
7742
+ // ensure leaves the state retryable and adapters withheld; its
7743
+ // settle callback applies the outcome and re-reconciles.
7744
+ const settled = peekDaemonOwnership(cfg);
7745
+ const isolated = runtime.endpointScope === "chat";
7746
+ runtime.notificationOwnerKey = daemonOwnershipKey(cfg);
7747
+ if (settled === undefined) {
7748
+ kickDaemonOwnership(settings, cfg, (outcome, key) =>
7749
+ applyDaemonOwnership(binding.sessionId, runtime, outcome, isolated, key),
5903
7750
  );
5904
- runtime.notificationOwnerState =
5905
- ownership === "ready" ||
5906
- (ownership === "blocked_identity_with_sibling" && runtime.endpointScope === "chat")
5907
- ? "ready"
5908
- : "blocked";
5909
- } catch {
5910
- return "failed";
7751
+ } else {
7752
+ applyDaemonOwnership(binding.sessionId, runtime, settled, isolated, runtime.notificationOwnerKey);
5911
7753
  }
5912
7754
  }
5913
7755
  return "started";
@@ -5958,8 +7800,11 @@ export function createNotificationsExtension(
5958
7800
  runtime.redact = policy.redact;
5959
7801
  runtime.verbosity = policy.verbosity;
5960
7802
  runtime.stream = policy.stream;
5961
- if (redactionEnabled) terminalizeInFlightTools(runtime, runtime.id, "cancelled", true);
5962
- else if (wasPolicySuspended && !policy.redact) settleProvisionalToolTerminals(runtime, runtime.id);
7803
+ if (redactionEnabled) {
7804
+ runtime.pendingFinal = undefined;
7805
+ runtime.pendingSettled = undefined;
7806
+ terminalizeInFlightTools(runtime, runtime.id, "cancelled", true);
7807
+ } else if (wasPolicySuspended && !policy.redact) settleProvisionalToolTerminals(runtime, runtime.id);
5963
7808
  },
5964
7809
  activate: binding => {
5965
7810
  const runtime = runtimes.get(binding.sessionId);
@@ -5972,34 +7817,89 @@ export function createNotificationsExtension(
5972
7817
  runtime.gatePresentations?.setPublicationSuspended(false);
5973
7818
  runtime.gatePresentations?.activateDeferred(runtime.workflowGatePublicationEpoch);
5974
7819
  flushPendingFinal(runtime, runtime.id);
7820
+ flushPendingSettled(runtime, runtime.id);
5975
7821
  for (const processControl of runtime.deferredInboundControls.splice(0)) processControl();
5976
7822
  },
7823
+ reproveOwnership: async binding => {
7824
+ // PROVIDER-NEUTRAL ownership re-proof. Reconciliation calls this for any
7825
+ // effective chat provider, not just Telegram: a Discord-only or
7826
+ // Slack-only credential/destination/actor change must withhold adapters
7827
+ // exactly like a Telegram one, or the old identity keeps delivery and
7828
+ // (for Slack) inbound command authority through a stale outcome.
7829
+ //
7830
+ // Never awaits the ensure itself — only the adapter teardown, which is
7831
+ // local. Lifecycle paths must not block on daemon ownership.
7832
+ const { settings, settingsAvailable, cfg } = resolveSettings(options.settings);
7833
+ if (!settingsAvailable || !settings) return;
7834
+ const key = daemonOwnershipKey(cfg);
7835
+ const sessionIdForOwnership = binding.sessionId;
7836
+ // The binding is released when reconciliation returns, so capture the
7837
+ // context eagerly: the settle callback runs strictly later.
7838
+ const ownershipCtx = binding.context;
7839
+ const runtime = runtimes.get(sessionIdForOwnership);
7840
+ if (!runtime || runtime.notificationOwnerKey === key) return;
7841
+ // The ownership-relevant configuration changed under a runtime whose
7842
+ // LOCAL adapters were authorized for the OLD configuration. Withhold
7843
+ // them first (`stop` with reason "notifications" tears down adapters
7844
+ // and answer sources while leaving the core SDK host untouched), then
7845
+ // re-prove.
7846
+ //
7847
+ // SCOPE: this does NOT revoke an already-attached external Discord or
7848
+ // Slack daemon's SessionRouter attachment. Such a daemon can still
7849
+ // observe host events and reach the host's inbound/control paths until
7850
+ // it notices the change itself. Closing that requires one coherent
7851
+ // authenticated chat-attachment authority boundary at the Router layer
7852
+ // (provenance per connection, revocation on re-proof and on
7853
+ // last-provider disable, covering replies and raw v3 frames uniformly).
7854
+ // That is tracked as a follow-up; per-callback heuristics here were
7855
+ // tried and reverted because they were bypassable and, on a blocked
7856
+ // identity, silently refused legitimate Telegram inbound forever.
7857
+ runtime.notificationOwnerKey = key;
7858
+ runtime.notificationOwnerState = "retry";
7859
+ if (runtime.notificationsActive) await stopSession(sessionIdForOwnership, "notifications");
7860
+ kickDaemonOwnership(settings, cfg, (outcome, outcomeKey) => {
7861
+ const current = runtimes.get(sessionIdForOwnership);
7862
+ if (!current) return;
7863
+ applyDaemonOwnership(sessionIdForOwnership, current, outcome, current.endpointScope === "chat", outcomeKey);
7864
+ if (current.stopping || extensionShuttingDown) return;
7865
+ // One detached reconciliation so adapters activate once the new
7866
+ // configuration is proved. No lifecycle path awaits this.
7867
+ void controller
7868
+ .reconcileCurrentSession(ownershipCtx)
7869
+ .catch(error => logger.warn(`notifications: post-ownership reconciliation failed: ${String(error)}`));
7870
+ });
7871
+ },
5977
7872
  ensureTelegramDaemon: async binding => {
5978
- const { settings, settingsAvailable } = resolveSettings(options.settings);
7873
+ const { settings, settingsAvailable, cfg } = resolveSettings(options.settings);
5979
7874
  if (!settingsAvailable || !settings) return "blocked_identity";
5980
- try {
5981
- let registrationToken: string | undefined;
5982
- const result = await ensureTelegramOwner(settings, binding.cwd, binding.sessionId, token => {
5983
- registrationToken = token;
7875
+ // Reconciliation runs on awaited session-lifecycle paths (session_start,
7876
+ // switch, branch, fork, resume), so this preflight must NEVER await a
7877
+ // daemon ensure. A pending ensure answers "pending": reconciliation
7878
+ // proceeds without activating adapters, and the settle callback
7879
+ // re-reconciles once ownership is known.
7880
+ const sessionIdForOwnership = binding.sessionId;
7881
+ const ownershipCtx = binding.context;
7882
+ const settled = peekDaemonOwnership(cfg);
7883
+ if (settled === undefined) {
7884
+ kickDaemonOwnership(settings, cfg, (outcome, outcomeKey) => {
7885
+ const current = runtimes.get(sessionIdForOwnership);
7886
+ if (!current) return;
7887
+ applyDaemonOwnership(
7888
+ sessionIdForOwnership,
7889
+ current,
7890
+ outcome,
7891
+ current.endpointScope === "chat",
7892
+ outcomeKey,
7893
+ );
7894
+ if (current.stopping || extensionShuttingDown) return;
7895
+ void controller
7896
+ .reconcileCurrentSession(ownershipCtx)
7897
+ .catch(error => logger.warn(`notifications: post-ownership reconciliation failed: ${String(error)}`));
5984
7898
  });
5985
- const runtime = runtimes.get(binding.sessionId);
5986
- const configured = isProviderEffectivelyEnabled(resolveSettings(options.settings).cfg, "telegram");
5987
- if (result === "ready" && runtime && !runtime.stopping && configured && registrationToken !== undefined) {
5988
- runtime.notificationRootRegistration = { settings, cwd: binding.cwd, registrationToken };
5989
- } else if (registrationToken !== undefined) {
5990
- await unregisterNotificationRoot({
5991
- settings,
5992
- cwd: binding.cwd,
5993
- sessionId: binding.sessionId,
5994
- registrationToken,
5995
- });
5996
- }
5997
- if (runtime?.notificationOwnerState === "blocked")
5998
- runtime.notificationOwnerState = result === "ready" ? "ready" : "blocked";
5999
- return result;
6000
- } catch {
6001
- return "failed";
7899
+ return "pending";
6002
7900
  }
7901
+ if (settled === "failed") return "failed";
7902
+ return settled === "ready" ? "ready" : "blocked_identity";
6003
7903
  },
6004
7904
  };
6005
7905
  controller.attachRuntime(sessionRuntime);
@@ -6150,6 +8050,7 @@ export function createNotificationsExtension(
6150
8050
  if (!predecessor || cleanupRetries.has(id))
6151
8051
  throw new Error(`notifications: predecessor runtime ${id} is not safely send-capable.`);
6152
8052
  predecessor.inboundFenced = true;
8053
+ predecessor.stopSessionNameObserver();
6153
8054
  // Release broker/host authority while leaving the native server alive for
6154
8055
  // the one accepted terminal response. stopAndWait is deferred to stopSession.
6155
8056
  const stopped = await predecessor.host.stop();
@@ -6293,27 +8194,96 @@ export function createNotificationsExtension(
6293
8194
  rt.lastLiveText = undefined;
6294
8195
  };
6295
8196
 
8197
+ /**
8198
+ * Session messages historically omitted attribution for direct user prompts;
8199
+ * that wire shape remains user-originated. Explicit agent attribution always
8200
+ * wins, including on a role:"user" internal resource notification.
8201
+ */
8202
+ const isUserSettlementBoundary = (message: { role?: unknown; attribution?: unknown }): boolean =>
8203
+ message.attribution === "user" || (message.role === "user" && message.attribution === undefined);
8204
+ const isFrameworkPromptContext = (message: { role?: unknown; customType?: unknown }): boolean =>
8205
+ message.role === "custom" && message.customType === "volatile-project-context";
8206
+
8207
+ type SettlementOrigin = "user" | "autonomous" | "continuation";
8208
+
8209
+ const deferLeanReceipt = (
8210
+ rt: SessionRuntime,
8211
+ text: string,
8212
+ messageRef?: string,
8213
+ window: number = rt.currentTurnSettlementWindow ?? rt.settlementWindow,
8214
+ origin: SettlementOrigin = rt.currentTurnSettlementOrigin ?? "continuation",
8215
+ ): void => {
8216
+ if (window !== rt.settlementWindow) return;
8217
+ const receipt = { text, ...(messageRef ? { messageRef } : {}), origin };
8218
+ const pending = rt.pendingSettled;
8219
+ if (!pending || pending.window !== window) {
8220
+ rt.pendingSettled = { window, receipts: [receipt] };
8221
+ return;
8222
+ }
8223
+ if (origin === "autonomous") {
8224
+ const prior = pending.receipts[pending.receipts.length - 1];
8225
+ if (prior?.text === text) return;
8226
+ // Keep the user-request receipt plus the newest autonomous outcome.
8227
+ pending.receipts = [pending.receipts[0]!, receipt];
8228
+ return;
8229
+ }
8230
+ if (origin === "continuation" && pending.receipts[0]?.origin === "user") {
8231
+ pending.receipts = [pending.receipts[0]!, receipt];
8232
+ return;
8233
+ }
8234
+ pending.receipts = [receipt];
8235
+ };
8236
+
8237
+ const deferProvisionalReceipt = (rt: SessionRuntime, text: string | undefined, messageRef?: string): void => {
8238
+ if (!text) return;
8239
+ const window = rt.currentTurnSettlementWindow ?? rt.settlementWindow;
8240
+ if (window !== rt.settlementWindow) return;
8241
+ const origin = rt.currentTurnSettlementOrigin ?? "continuation";
8242
+ const receipt = { text, ...(messageRef ? { messageRef } : {}), origin };
8243
+ const pending = rt.pendingFinal;
8244
+ if (!pending || pending.window !== window) {
8245
+ rt.pendingFinal = { window, receipts: [receipt] };
8246
+ return;
8247
+ }
8248
+ if (origin === "autonomous") {
8249
+ if (pending.receipts[pending.receipts.length - 1]?.text === text) return;
8250
+ pending.receipts = [pending.receipts[0]!, receipt];
8251
+ return;
8252
+ }
8253
+ if (origin === "continuation" && pending.receipts[0]?.origin === "user") {
8254
+ pending.receipts = [pending.receipts[0]!, receipt];
8255
+ return;
8256
+ }
8257
+ pending.receipts = [receipt];
8258
+ };
8259
+
8260
+ const hasUserSettlementReceipt = (rt: SessionRuntime): boolean => rt.pendingSettled?.receipts[0]?.origin === "user";
8261
+
6296
8262
  const flushPendingFinal = (rt: SessionRuntime, id: string): void => {
6297
8263
  const pending = rt.pendingFinal;
6298
8264
  if (!pending) return;
6299
8265
  rt.pendingFinal = undefined;
6300
- if (pending.text && rt.notificationsActive && !rt.redact) {
8266
+ if (pending.window !== rt.settlementWindow) return;
8267
+ if (pending.receipts.length && rt.notificationsActive && !rt.redact) {
6301
8268
  // Under lean, hold intermediate finals until agent_end when the agent is still
6302
8269
  // running so provisional-policy activation cannot reintroduce per-turn spam.
6303
- if (rt.verbosity === "lean" && rt.busy) {
6304
- rt.pendingSettled = {
6305
- text: pending.text,
6306
- ...(pending.messageRef ? { messageRef: pending.messageRef } : {}),
6307
- };
8270
+ // A receipt retained from an idle reached during provisional policy has the
8271
+ // same settlement window. Merge it before either path publishes so stable
8272
+ // activation emits one composed terminal receipt, not two messages.
8273
+ if (rt.verbosity === "lean" && (rt.busy || rt.pendingSettled?.window === pending.window)) {
8274
+ for (const receipt of pending.receipts)
8275
+ deferLeanReceipt(rt, receipt.text, receipt.messageRef, pending.window, receipt.origin);
6308
8276
  } else {
8277
+ const text = pending.receipts.map(receipt => receipt.text).join("\n\n");
8278
+ const messageRef = pending.receipts.length === 1 ? pending.receipts[0]?.messageRef : undefined;
6309
8279
  try {
6310
8280
  pushSessionFrame(rt, {
6311
8281
  type: "turn_stream",
6312
8282
  sessionId: id,
6313
8283
  phase: "finalized",
6314
8284
  finalAnswer: true,
6315
- text: pending.text,
6316
- ...(pending.messageRef ? { messageRef: pending.messageRef } : {}),
8285
+ text,
8286
+ ...(messageRef ? { messageRef } : {}),
6317
8287
  });
6318
8288
  } catch (error) {
6319
8289
  logger.warn(`notifications: pushFrame (pending turn) failed: ${String(error)}`);
@@ -6326,38 +8296,53 @@ export function createNotificationsExtension(
6326
8296
  /** Emit the deferred lean settled answer exactly once (agent_end / idle). */
6327
8297
  const flushPendingSettled = (rt: SessionRuntime, id: string): void => {
6328
8298
  const settled = rt.pendingSettled;
8299
+ if (!settled?.receipts.length || !rt.notificationsActive || rt.redact || rt.policySuspended) return;
6329
8300
  rt.pendingSettled = undefined;
6330
- if (!settled?.text || !rt.notificationsActive || rt.redact || rt.policySuspended) return;
8301
+ const text = settled.receipts.map(receipt => receipt.text).join("\n\n");
8302
+ // A composition represents multiple finalized turns. It must be a fresh
8303
+ // terminal message rather than editing either constituent turn's stream.
8304
+ const messageRef = settled.receipts.length === 1 ? settled.receipts[0]?.messageRef : undefined;
6331
8305
  const previousLiveRef = rt.liveRef;
6332
- if (settled.messageRef) rt.liveRef = settled.messageRef;
8306
+ if (messageRef) rt.liveRef = messageRef;
6333
8307
  try {
6334
- flushTurnText(rt, id, settled.text, true);
8308
+ flushTurnText(rt, id, text, true);
6335
8309
  } finally {
6336
- if (!settled.messageRef) rt.liveRef = previousLiveRef;
8310
+ if (!messageRef) rt.liveRef = previousLiveRef;
6337
8311
  }
6338
8312
  };
6339
8313
 
6340
8314
  // Drive the live typing indicator: mark busy when the agent loop starts so
6341
8315
  // the daemon shows "typing…" in the thread while the agent is thinking,
6342
8316
  // before any turn output exists. Cleared on `agent_end` below.
6343
- api.on("agent_start", (_event, ctx) => {
8317
+ api.on("agent_start", async (event, ctx) => {
6344
8318
  const id = sessionId(ctx);
6345
8319
  const rt = runtimes.get(id);
6346
8320
  if (!rt) return;
6347
8321
  // Streaming state is SDK-visible session truth (context.get isStreaming);
6348
8322
  // it is tracked regardless of whether notifications are active.
6349
8323
  rt.busy = true;
8324
+ // The first prompt message arrives after turn_start. A pre-existing deferred
8325
+ // receipt means a message-less run is autonomous until a direct user prompt
8326
+ // authoritatively overrides it; otherwise start unbound.
8327
+ if (!rt.currentTurnSettlementOrigin && rt.pendingSettled) {
8328
+ rt.currentTurnSettlementOrigin = "autonomous";
8329
+ rt.currentTurnSettlementWindow = rt.settlementWindow;
8330
+ } else if (!rt.currentTurnSettlementOrigin) rt.currentTurnSettlementWindow = undefined;
6350
8331
  // A continuation re-enters the agent loop inside the same prompt and emits
6351
- // another `agent_start`. Shifting again would pop nothing and clobber the
6352
- // live correlation with `undefined`, so the prompt's `agent_end` could no
6353
- // longer be terminalized and the caller would hang until the deadline.
6354
- // Only claim the next pending correlation when this session has no active one.
6355
- const correlation = rt.activePromptCorrelation ?? rt.pendingPromptCorrelations.shift();
8332
+ // another `agent_start`. Only a queued follow-up's exact SDK token may
8333
+ // claim its correlation; unrelated queue work must not consume it.
8334
+ const sdkRunToken = event.sdkRunToken;
8335
+ const correlation =
8336
+ rt.activePromptCorrelation ??
8337
+ (sdkRunToken
8338
+ ? rt.pendingPromptCorrelationsBySdkRunToken.get(sdkRunToken)
8339
+ : rt.pendingPromptCorrelations.shift());
8340
+ if (sdkRunToken && correlation) rt.pendingPromptCorrelationsBySdkRunToken.delete(sdkRunToken);
6356
8341
  const continuation = rt.activePromptCorrelation !== undefined;
6357
8342
  rt.activePromptCorrelation = correlation;
6358
8343
  if (correlation && !continuation) rt.bindPromptExecutionHandle(correlation, ctx.getActivePromptHandle());
6359
8344
  if (continuation) return;
6360
- rt.notePromptReconciliation(correlation, { type: "agent_start" });
8345
+ await rt.notePromptReconciliation(correlation, { type: "agent_start" });
6361
8346
  rt.emitPromptLifecycle(correlation, { type: "agent_start", sessionId: id, ...correlation });
6362
8347
  try {
6363
8348
  // `activity` is the native live-host lifecycle surface. The separately
@@ -6376,6 +8361,9 @@ export function createNotificationsExtension(
6376
8361
  const rt = runtimes.get(id);
6377
8362
  if (!rt) return;
6378
8363
  rt.turnSeq = (rt.turnSeq ?? 0) + 1;
8364
+ // Agent-loop prompts arrive after turn_start. Retain already-bound logical
8365
+ // provenance across tool/message-less continuation turns; a user prompt can
8366
+ // still authoritatively override framework-injected context below.
6379
8367
  if (!rt.notificationsActive) return;
6380
8368
  // A new turn is live: re-open the live-stream window (see turnClosed).
6381
8369
  rt.turnClosed = false;
@@ -6394,7 +8382,7 @@ export function createNotificationsExtension(
6394
8382
  // per `turn_end`. turn_end fires once per turn iteration, so a single
6395
8383
  // user-visible idle previously produced many idle pings (the flood); agent_end
6396
8384
  // fires exactly once per settle, yielding exactly one idle notification.
6397
- api.on("agent_end", (event, ctx) => {
8385
+ api.on("agent_end", async (event, ctx) => {
6398
8386
  const id = sessionId(ctx);
6399
8387
  const rt = runtimes.get(id);
6400
8388
  if (!rt) return;
@@ -6452,7 +8440,7 @@ export function createNotificationsExtension(
6452
8440
  // contract (see `sanitizePromptFailure`). Assistant failure messages may
6453
8441
  // still remain in the local session transcript; terminalization copies only
6454
8442
  // a bounded reason into the local operator log.
6455
- void rt.terminalizePrompt(correlation, outcome, {
8443
+ await rt.terminalizePrompt(correlation, outcome, {
6456
8444
  ...(finalText ? { finalText } : {}),
6457
8445
  ...(outcome.kind === "failed"
6458
8446
  ? {
@@ -6497,7 +8485,7 @@ export function createNotificationsExtension(
6497
8485
  pushSessionFrame(rt, {
6498
8486
  type: "identity_header",
6499
8487
  sessionId: id,
6500
- ...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
8488
+ ...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName(), telegramTopicsEnabled()),
6501
8489
  });
6502
8490
  } catch {}
6503
8491
  try {
@@ -6522,6 +8510,10 @@ export function createNotificationsExtension(
6522
8510
  // Intermediate tool-turn narration was held on turn_end; ask lead-ins were
6523
8511
  // already flushed immediately and deduped via preAskFlushedText.
6524
8512
  flushPendingSettled(rt, id);
8513
+ // A terminal idle closes the logical settlement run. The next agent_start
8514
+ // must establish provenance from its own prompt messages.
8515
+ rt.currentTurnSettlementOrigin = undefined;
8516
+ rt.currentTurnSettlementWindow = undefined;
6525
8517
 
6526
8518
  // On idle, stream a context update with metadata (token/model usage +
6527
8519
  // working-tree diff) unless redaction is on. Under verbose the agent's last
@@ -6567,7 +8559,8 @@ export function createNotificationsExtension(
6567
8559
  rt.preAskFlushedText = text;
6568
8560
  // Ask lead-ins supersede any deferred lean settled answer from earlier turns
6569
8561
  // so agent_end does not re-emit stale intermediate narration (#2863 review).
6570
- if (!finalAnswer) rt.pendingSettled = undefined;
8562
+ if (!finalAnswer && rt.currentTurnSettlementOrigin !== "autonomous" && !hasUserSettlementReceipt(rt))
8563
+ rt.pendingSettled = undefined;
6571
8564
  // Decision A: a stream-enabled turn must finalize as an in-place edit of ONE
6572
8565
  // live message, never a fresh (rich-promotable) send. If live frames were
6573
8566
  // async-queued and none landed before this flush, reuse the per-turn ref
@@ -6602,6 +8595,8 @@ export function createNotificationsExtension(
6602
8595
  rt?.emitPromptEvent(event);
6603
8596
  if (event.toolName === "ask") {
6604
8597
  if (!rt?.notificationsActive || rt.redact) return;
8598
+ if (rt.currentTurnSettlementWindow !== undefined && rt.currentTurnSettlementWindow !== rt.settlementWindow)
8599
+ return;
6605
8600
  flushTurnText(rt, id, rt.currentTurnText, false);
6606
8601
  }
6607
8602
  if (!rt?.notificationsActive || rt.redact) return;
@@ -6714,7 +8709,7 @@ export function createNotificationsExtension(
6714
8709
  api.on("turn_end", (event, ctx) => {
6715
8710
  const id = sessionId(ctx);
6716
8711
  const rt = runtimes.get(id);
6717
- if (!rt?.notificationsActive) return;
8712
+ if (!rt || (!rt.notificationsActive && !rt.policySuspended)) return;
6718
8713
  const text = rt.policySuspended
6719
8714
  ? rt.committedRedact
6720
8715
  ? undefined
@@ -6723,25 +8718,27 @@ export function createNotificationsExtension(
6723
8718
  ? undefined
6724
8719
  : summaryFromMessage(event.message, turnTextMax());
6725
8720
  if (rt.policySuspended) {
6726
- rt.pendingFinal = { text, messageRef: rt.liveRef };
8721
+ deferProvisionalReceipt(rt, text, rt.liveRef);
6727
8722
  rt.turnClosed = true;
6728
8723
  return;
6729
8724
  }
6730
8725
  if (text) {
8726
+ if (rt.currentTurnSettlementWindow !== undefined && rt.currentTurnSettlementWindow !== rt.settlementWindow) {
8727
+ resetTurnStreamState(rt);
8728
+ return;
8729
+ }
6731
8730
  if (rt.verbosity === "verbose") {
6732
8731
  // Verbose: one finalized turn_stream per turn with assistant text.
6733
8732
  flushTurnText(rt, id, text, true);
6734
8733
  } else if (text !== rt.preAskFlushedText) {
6735
8734
  // Lean: hold the latest answer until agent_end. Skip when this turn
6736
8735
  // already flushed the same text as an ask lead-in (no duplicate at idle).
6737
- rt.pendingSettled = {
6738
- text,
6739
- ...(rt.liveRef ? { messageRef: rt.liveRef } : {}),
6740
- };
8736
+ deferLeanReceipt(rt, text, rt.liveRef);
6741
8737
  } else {
6742
8738
  // Lead-in already flushed: drop any older deferred settled text so idle
6743
8739
  // does not re-emit intermediate narration after the ask prompt (#2863).
6744
- rt.pendingSettled = undefined;
8740
+ if (rt.currentTurnSettlementOrigin === "user" || !hasUserSettlementReceipt(rt))
8741
+ rt.pendingSettled = undefined;
6745
8742
  }
6746
8743
  }
6747
8744
  resetTurnStreamState(rt);
@@ -6781,6 +8778,33 @@ export function createNotificationsExtension(
6781
8778
  const id = sessionId(ctx);
6782
8779
  const rt = runtimes.get(id);
6783
8780
  rt?.emitPromptEvent(event);
8781
+ if (rt) {
8782
+ const message = event.message as { role?: unknown; attribution?: unknown; customType?: unknown };
8783
+ if (isUserSettlementBoundary(message)) {
8784
+ const openingTurn = rt.currentTurnSettlementOrigin !== "user";
8785
+ const supersedingSettledTurn =
8786
+ rt.currentTurnSettlementOrigin === "user" &&
8787
+ (rt.pendingSettled !== undefined || rt.pendingFinal !== undefined);
8788
+ // A provider batch can carry several user messages after one turn_start.
8789
+ // They share that turn's boundary. A later user submission after the old
8790
+ // turn has produced a receipt opens a new window but must not relabel the
8791
+ // already-running turn as its response.
8792
+ if (openingTurn || supersedingSettledTurn) rt.settlementWindow++;
8793
+ if (openingTurn || supersedingSettledTurn) {
8794
+ rt.currentTurnSettlementWindow = rt.settlementWindow;
8795
+ rt.currentTurnSettlementOrigin = "user";
8796
+ }
8797
+ rt.pendingSettled = undefined;
8798
+ rt.pendingFinal = undefined;
8799
+ } else if (message.role !== "assistant") {
8800
+ // The assembled direct prompt can contain framework context after its
8801
+ // user message. A user boundary is authoritative for this turn.
8802
+ if (rt.currentTurnSettlementOrigin !== "user" || !isFrameworkPromptContext(message)) {
8803
+ rt.currentTurnSettlementWindow = rt.settlementWindow;
8804
+ rt.currentTurnSettlementOrigin = "autonomous";
8805
+ }
8806
+ }
8807
+ }
6784
8808
  if (!rt?.notificationsActive || rt.redact) return;
6785
8809
  // Capture the in-flight ASSISTANT text here (message_end is on the awaited
6786
8810
  // extension path and ordered before tool_execution_start) so the pre-ask