@gajae-code/coding-agent 0.13.2 → 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 (610) hide show
  1. package/CHANGELOG.md +215 -8
  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 +10 -1
  35. package/dist/types/config/models-config-schema.d.ts +2 -0
  36. package/dist/types/config/settings-schema.d.ts +73 -9
  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 +2 -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/dynamic-theme-text.d.ts +9 -0
  87. package/dist/types/modes/components/gajae-pet-widget.d.ts +6 -28
  88. package/dist/types/modes/components/irc-sidebar.d.ts +1 -0
  89. package/dist/types/modes/components/iterm-pet-transport.d.ts +122 -0
  90. package/dist/types/modes/components/oauth-selector.d.ts +10 -4
  91. package/dist/types/modes/components/pet-capability.d.ts +7 -21
  92. package/dist/types/modes/components/settings-selector.d.ts +5 -0
  93. package/dist/types/modes/components/status-line/gh.d.ts +6 -2
  94. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  95. package/dist/types/modes/controllers/selector-controller.d.ts +7 -8
  96. package/dist/types/modes/controllers/stt-controller.d.ts +1 -1
  97. package/dist/types/modes/interactive-mode.d.ts +1 -0
  98. package/dist/types/modes/theme/defaults/index.d.ts +118 -0
  99. package/dist/types/modes/types.d.ts +1 -0
  100. package/dist/types/modes/utils/injected-user-submission.d.ts +10 -0
  101. package/dist/types/runtime-mcp/config.d.ts +9 -0
  102. package/dist/types/runtime-mcp/manager.d.ts +26 -1
  103. package/dist/types/runtime-mcp/oauth-discovery.d.ts +4 -0
  104. package/dist/types/runtime-mcp/oauth-flow.d.ts +24 -1
  105. package/dist/types/runtime-mcp/pool-key.d.ts +2 -0
  106. package/dist/types/runtime-mcp/protocol.d.ts +278 -0
  107. package/dist/types/runtime-mcp/scope-config.d.ts +29 -0
  108. package/dist/types/runtime-mcp/tool-bridge.d.ts +15 -13
  109. package/dist/types/runtime-mcp/transports/http.d.ts +9 -0
  110. package/dist/types/runtime-mcp/types.d.ts +102 -10
  111. package/dist/types/sdk/acp/adapter.d.ts +26 -5
  112. package/dist/types/sdk/broker/broker.d.ts +3 -55
  113. package/dist/types/sdk/broker/lifecycle.d.ts +18 -12
  114. package/dist/types/sdk/broker/session-index.d.ts +14 -5
  115. package/dist/types/sdk/broker/startup-failure.d.ts +16 -1
  116. package/dist/types/sdk/bus/chat-daemon-control.d.ts +51 -1
  117. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -41
  118. package/dist/types/sdk/bus/config.d.ts +11 -0
  119. package/dist/types/sdk/bus/conversation-store.d.ts +0 -1
  120. package/dist/types/sdk/bus/daemon-paths.d.ts +6 -0
  121. package/dist/types/sdk/bus/discord-conversation.d.ts +2 -0
  122. package/dist/types/sdk/bus/discord-daemon.d.ts +17 -12
  123. package/dist/types/sdk/bus/existing-thread-readiness.d.ts +4 -34
  124. package/dist/types/sdk/bus/inbound-reaction-ordering.d.ts +61 -0
  125. package/dist/types/sdk/bus/index.d.ts +85 -99
  126. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +29 -7
  127. package/dist/types/sdk/bus/managed-daemon.d.ts +2 -32
  128. package/dist/types/sdk/bus/notification-service.d.ts +52 -0
  129. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -1
  130. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +16 -5
  131. package/dist/types/sdk/bus/reconciliation-store.d.ts +113 -5
  132. package/dist/types/sdk/bus/session-control.d.ts +25 -1
  133. package/dist/types/sdk/bus/slack-conversation.d.ts +4 -0
  134. package/dist/types/sdk/bus/slack-daemon.d.ts +9 -17
  135. package/dist/types/sdk/bus/slack-live-provider.d.ts +3 -1
  136. package/dist/types/sdk/bus/slack-provider.d.ts +4 -0
  137. package/dist/types/sdk/bus/slack-thread-binding.d.ts +6 -40
  138. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +30 -104
  139. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +10 -0
  140. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +262 -19
  141. package/dist/types/sdk/bus/telegram-daemon-orphan-reap.d.ts +125 -0
  142. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +22 -0
  143. package/dist/types/sdk/bus/telegram-daemon.d.ts +119 -310
  144. package/dist/types/sdk/bus/telegram-reference.d.ts +1 -47
  145. package/dist/types/sdk/bus/threaded-render.d.ts +1 -0
  146. package/dist/types/sdk/bus/topic-registry.d.ts +33 -17
  147. package/dist/types/sdk/cli/rows.d.ts +2 -11
  148. package/dist/types/sdk/cli/session-cli.d.ts +15 -25
  149. package/dist/types/sdk/client/client.d.ts +97 -1
  150. package/dist/types/sdk/host/control/dispatch.d.ts +0 -7
  151. package/dist/types/sdk/host/control/index.d.ts +1 -1
  152. package/dist/types/sdk/host/control/operations.d.ts +18 -5
  153. package/dist/types/sdk/host/control/runtime-gate.d.ts +19 -0
  154. package/dist/types/sdk/host/host.d.ts +15 -1
  155. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  156. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  157. package/dist/types/sdk/host/session-runtime.d.ts +127 -7
  158. package/dist/types/sdk/host/types.d.ts +1 -1
  159. package/dist/types/sdk/host/websocket-transport.d.ts +1 -1
  160. package/dist/types/sdk/index.d.ts +2 -1
  161. package/dist/types/sdk/lifecycle/broker-client.d.ts +8 -0
  162. package/dist/types/sdk/lifecycle/client.d.ts +59 -0
  163. package/dist/types/sdk/lifecycle/index.d.ts +3 -0
  164. package/dist/types/sdk/lifecycle/service.d.ts +232 -0
  165. package/dist/types/sdk/mcp/server.d.ts +9 -6
  166. package/dist/types/sdk/prompt-deadline-lease.d.ts +43 -0
  167. package/dist/types/sdk/prompt-deadline-manager.d.ts +28 -0
  168. package/dist/types/sdk/prompt-status.d.ts +10 -3
  169. package/dist/types/sdk/protocol/operation-registry.d.ts +5 -0
  170. package/dist/types/sdk/receipt-state.d.ts +17 -0
  171. package/dist/types/sdk/reconciliation-extensions.d.ts +2 -0
  172. package/dist/types/sdk/router/index.d.ts +1 -0
  173. package/dist/types/sdk/router/session-router.d.ts +152 -0
  174. package/dist/types/sdk/session-list.d.ts +22 -0
  175. package/dist/types/sdk/session-reconnect.d.ts +43 -1
  176. package/dist/types/sdk/session.d.ts +34 -1
  177. package/dist/types/sdk/transport/serve-cli.d.ts +1 -4
  178. package/dist/types/session/account-inventory.d.ts +86 -0
  179. package/dist/types/session/agent-session.d.ts +145 -6
  180. package/dist/types/session/agent-storage.d.ts +10 -0
  181. package/dist/types/session/coordinator-tool-label.d.ts +28 -0
  182. package/dist/types/session/internal/managed-session-storage.d.ts +23 -4
  183. package/dist/types/session/messages.d.ts +1 -1
  184. package/dist/types/session/secure-token-file.d.ts +3 -0
  185. package/dist/types/session/session-manager.d.ts +47 -1
  186. package/dist/types/session/startup-auth-config.d.ts +44 -0
  187. package/dist/types/session/terminal-abort.d.ts +378 -0
  188. package/dist/types/session/yield-queue.d.ts +7 -0
  189. package/dist/types/session-import/claude.d.ts +26 -0
  190. package/dist/types/session-import/codex.d.ts +88 -0
  191. package/dist/types/session-import/command.d.ts +35 -0
  192. package/dist/types/session-import/detect.d.ts +22 -0
  193. package/dist/types/session-import/index.d.ts +7 -0
  194. package/dist/types/session-import/provider-codex.d.ts +22 -0
  195. package/dist/types/session-import/provider-service.d.ts +38 -0
  196. package/dist/types/session-import/redact.d.ts +23 -0
  197. package/dist/types/session-import/service.d.ts +47 -0
  198. package/dist/types/session-import/types.d.ts +120 -0
  199. package/dist/types/setup/paseo/check.d.ts +16 -0
  200. package/dist/types/setup/paseo/install-saga.d.ts +115 -0
  201. package/dist/types/setup/paseo/json-publisher.d.ts +76 -0
  202. package/dist/types/setup/paseo/orchestration-preferences.d.ts +24 -0
  203. package/dist/types/setup/paseo/paseo-ownership.d.ts +87 -0
  204. package/dist/types/setup/paseo/paseo-setup.d.ts +29 -0
  205. package/dist/types/setup/paseo/provider-config.d.ts +35 -0
  206. package/dist/types/setup/paseo/remove.d.ts +14 -0
  207. package/dist/types/setup/paseo/result-types.d.ts +59 -0
  208. package/dist/types/setup/paseo/setup-deps.d.ts +73 -0
  209. package/dist/types/setup/paseo/skills-bridge.d.ts +44 -0
  210. package/dist/types/skill-state/active-state.d.ts +5 -1
  211. package/dist/types/slash-commands/acp-builtins.d.ts +5 -1
  212. package/dist/types/slash-commands/builtin-registry.d.ts +2 -0
  213. package/dist/types/slash-commands/helpers/credential-switch.d.ts +12 -0
  214. package/dist/types/slash-commands/helpers/usage-report.d.ts +5 -6
  215. package/dist/types/slash-commands/types.d.ts +7 -1
  216. package/dist/types/task/executor.d.ts +10 -0
  217. package/dist/types/task/index.d.ts +3 -1
  218. package/dist/types/tools/ask-answer-registry.d.ts +6 -3
  219. package/dist/types/tools/bash-sleep-advisory.d.ts +14 -0
  220. package/dist/types/tools/bash.d.ts +2 -6
  221. package/dist/types/tools/browser/attach.d.ts +11 -0
  222. package/dist/types/tools/browser/launch.d.ts +20 -0
  223. package/dist/types/tools/browser/profile-discovery.d.ts +6 -0
  224. package/dist/types/tools/browser/render.d.ts +1 -0
  225. package/dist/types/tools/browser.d.ts +3 -1
  226. package/dist/types/tools/image-gen.d.ts +10 -2
  227. package/dist/types/tools/index.d.ts +8 -0
  228. package/dist/types/tools/monitor.d.ts +1 -1
  229. package/dist/types/tools/skill-discovery.d.ts +8 -1
  230. package/dist/types/tools/subagent.d.ts +2 -0
  231. package/dist/types/tools/ultragoal-ask-guard.d.ts +2 -2
  232. package/dist/types/utils/herdr-pane.d.ts +76 -0
  233. package/package.json +19 -9
  234. package/scripts/build-binary.ts +2 -1
  235. package/scripts/build-sdk-package-smoke.ts +2 -13
  236. package/scripts/capture-sticky-viewport-showcase.ts +0 -1
  237. package/scripts/compile-args.ts +2 -0
  238. package/scripts/generate-sdk-adapter-parity-manifest.ts +31 -16
  239. package/scripts/generate-sdk-operation-inventory.ts +28 -0
  240. package/scripts/generate-telegram-baseline-manifest.ts +1 -0
  241. package/scripts/generate-tool-catalog.ts +6 -4
  242. package/scripts/verify-gjc-sdk-canonicalization.ts +5 -39
  243. package/src/async/job-manager.ts +352 -17
  244. package/src/async/support.ts +25 -0
  245. package/src/autoresearch/dashboard.ts +28 -13
  246. package/src/capability/index.ts +3 -1
  247. package/src/capability/mcp.ts +6 -0
  248. package/src/capability/types.ts +6 -0
  249. package/src/cli/accounts-cli.ts +395 -0
  250. package/src/cli/args.ts +23 -0
  251. package/src/cli/auth-broker-cli.ts +136 -66
  252. package/src/cli/auth-gateway-cli.ts +94 -70
  253. package/src/cli/config-cli.ts +27 -2
  254. package/src/cli/crash-cli.ts +78 -0
  255. package/src/cli/customize-doctor.ts +1605 -0
  256. package/src/cli/daemon-cli.ts +12 -3
  257. package/src/cli/fast-help.ts +1 -0
  258. package/src/cli/mcp-cli.ts +128 -60
  259. package/src/cli/notify-cli.ts +8 -14
  260. package/src/cli/root-flags.ts +8 -0
  261. package/src/cli/setup-cli.ts +145 -0
  262. package/src/cli/skills-cli.ts +38 -2
  263. package/src/cli/update-cli.ts +167 -12
  264. package/src/cli.ts +4 -0
  265. package/src/commands/accounts.ts +71 -0
  266. package/src/commands/crash.ts +35 -0
  267. package/src/commands/customize.ts +84 -0
  268. package/src/commands/daemon.ts +2 -2
  269. package/src/commands/deep-interview.ts +2 -0
  270. package/src/commands/launch.ts +1 -0
  271. package/src/commands/mcp.ts +12 -7
  272. package/src/commands/ralplan.ts +2 -0
  273. package/src/commands/sdk.ts +65 -32
  274. package/src/commands/setup.ts +5 -0
  275. package/src/commands/skills.ts +14 -4
  276. package/src/commands/ultragoal.ts +10 -1
  277. package/src/commands/update.ts +26 -15
  278. package/src/config/atomic-yaml-patch-worker.ts +57 -0
  279. package/src/config/atomic-yaml-patch.ts +769 -29
  280. package/src/config/config-schema-version.ts +10 -0
  281. package/src/config/file-lock.ts +70 -1
  282. package/src/config/mcp-schema.json +52 -14
  283. package/src/config/model-profile-activation.ts +23 -2
  284. package/src/config/model-profiles.ts +88 -21
  285. package/src/config/model-registry.ts +147 -21
  286. package/src/config/models-config-schema.ts +1 -1
  287. package/src/config/settings-schema.ts +77 -7
  288. package/src/config/settings.ts +4038 -199
  289. package/src/config/skill-settings-defaults.ts +41 -3
  290. package/src/config/task-delegation.ts +87 -0
  291. package/src/config/update-channel.ts +23 -0
  292. package/src/coordinator-mcp/server.ts +377 -308
  293. package/src/crash/index-store.ts +723 -0
  294. package/src/crash/nudge.ts +89 -0
  295. package/src/crash/record-loader.ts +149 -0
  296. package/src/crash/report.ts +551 -0
  297. package/src/crash/sanitize.ts +129 -0
  298. package/src/cursor.ts +36 -10
  299. package/src/customization/import.ts +946 -0
  300. package/src/customization/index.ts +4 -0
  301. package/src/customization/inventory.ts +335 -0
  302. package/src/customization/mutations.ts +141 -0
  303. package/src/customization/types.ts +223 -0
  304. package/src/daemon/control-types.ts +8 -1
  305. package/src/daemon/operator-contract.ts +6 -6
  306. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +31 -6
  307. package/src/defaults/gjc/skills/deep-interview/SKILL.md +11 -22
  308. package/src/defaults/gjc/skills/ralplan/SKILL.md +36 -23
  309. package/src/defaults/gjc/skills/ultragoal/SKILL.md +16 -0
  310. package/src/discovery/claude-hooks.ts +38 -0
  311. package/src/discovery/claude.ts +142 -209
  312. package/src/discovery/codex-hooks.ts +38 -0
  313. package/src/discovery/codex.ts +132 -198
  314. package/src/discovery/helpers.ts +8 -1
  315. package/src/discovery/index.ts +2 -0
  316. package/src/discovery/mcp-compat.ts +234 -0
  317. package/src/discovery/mcp-json.ts +6 -0
  318. package/src/edit/renderer.ts +40 -0
  319. package/src/edit/streaming.ts +4 -2
  320. package/src/exec/bash-executor.ts +24 -20
  321. package/src/export/html/index.ts +51 -2
  322. package/src/extensibility/extensions/loader.ts +9 -1
  323. package/src/extensibility/extensions/runner.ts +17 -4
  324. package/src/extensibility/extensions/types.ts +31 -6
  325. package/src/extensibility/gjc-plugins/constrained-hooks.ts +33 -0
  326. package/src/extensibility/gjc-plugins/installer.ts +9 -5
  327. package/src/extensibility/gjc-plugins/observability.ts +8 -2
  328. package/src/extensibility/gjc-plugins/registry.ts +13 -2
  329. package/src/extensibility/gjc-plugins/runtime-adapters.ts +3 -1
  330. package/src/extensibility/gjc-plugins/schema.ts +222 -39
  331. package/src/extensibility/hooks/loader.ts +154 -10
  332. package/src/extensibility/runtime-skill-discovery.ts +257 -60
  333. package/src/extensibility/skill-management.ts +368 -0
  334. package/src/extensibility/skills.ts +52 -16
  335. package/src/gjc-runtime/deep-interview-runtime.ts +45 -61
  336. package/src/gjc-runtime/launch-worktree.ts +56 -2
  337. package/src/gjc-runtime/linux-proc.ts +7 -1
  338. package/src/gjc-runtime/ralplan-runtime.ts +100 -164
  339. package/src/gjc-runtime/session-state-lock.ts +992 -0
  340. package/src/gjc-runtime/session-state-sidecar.ts +644 -142
  341. package/src/gjc-runtime/state-writer.ts +39 -14
  342. package/src/gjc-runtime/tmux-owner-isolation.ts +5 -4
  343. package/src/gjc-runtime/ultragoal-guard.ts +11 -4
  344. package/src/gjc-runtime/ultragoal-runtime.ts +44 -41
  345. package/src/gjc-runtime/workflow-settings.ts +596 -0
  346. package/src/goals/tools/goal-tool.ts +6 -1
  347. package/src/hooks/events.ts +291 -0
  348. package/src/hooks/native-skill-hook.ts +4 -0
  349. package/src/hooks/normalize.ts +369 -0
  350. package/src/hooks/skill-state.ts +165 -46
  351. package/src/internal-urls/docs-index.generated.ts +41 -32
  352. package/src/main.ts +36 -5
  353. package/src/migrate/mcp-mapper.ts +25 -2
  354. package/src/modes/acp/abort-scope.ts +29 -0
  355. package/src/modes/acp/acp-agent.ts +423 -98
  356. package/src/modes/components/assistant-message.ts +6 -1
  357. package/src/modes/components/chrome.ts +4 -3
  358. package/src/modes/components/customization/customization-dashboard.ts +443 -0
  359. package/src/modes/components/customization/import-wizard.ts +372 -0
  360. package/src/modes/components/customization/index.ts +1 -0
  361. package/src/modes/components/dynamic-theme-text.ts +22 -0
  362. package/src/modes/components/gajae-pet-widget.ts +607 -193
  363. package/src/modes/components/irc-sidebar.ts +11 -0
  364. package/src/modes/components/iterm-pet-transport.ts +585 -0
  365. package/src/modes/components/model-selector.ts +1 -6
  366. package/src/modes/components/oauth-selector.ts +277 -86
  367. package/src/modes/components/pet-capability.ts +35 -31
  368. package/src/modes/components/settings-selector.ts +29 -5
  369. package/src/modes/components/status-line/gh.ts +45 -1
  370. package/src/modes/components/status-line/segments.ts +19 -4
  371. package/src/modes/components/tool-status-header.ts +13 -3
  372. package/src/modes/controllers/command-controller.ts +20 -8
  373. package/src/modes/controllers/extension-ui-controller.ts +56 -257
  374. package/src/modes/controllers/input-controller.ts +10 -0
  375. package/src/modes/controllers/runtime-mcp-command-controller.ts +16 -3
  376. package/src/modes/controllers/selector-controller.ts +238 -77
  377. package/src/modes/controllers/stt-controller.ts +3 -3
  378. package/src/modes/interactive-mode.ts +88 -16
  379. package/src/modes/print-mode.ts +35 -6
  380. package/src/modes/runtime-init.ts +21 -68
  381. package/src/modes/theme/defaults/index.ts +2 -0
  382. package/src/modes/theme/defaults/ouroboros.json +118 -0
  383. package/src/modes/types.ts +1 -0
  384. package/src/modes/utils/injected-user-submission.ts +25 -0
  385. package/src/modes/utils/terminal-bell.ts +15 -1
  386. package/src/prompts/agent-fragments/ralplan-persistence.md +1 -1
  387. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  388. package/src/prompts/agents/critic.md +1 -1
  389. package/src/prompts/system/rlm-research.md +1 -1
  390. package/src/prompts/system/system-prompt.md +0 -2
  391. package/src/prompts/tools/bash.md +7 -1
  392. package/src/prompts/tools/browser.md +1 -1
  393. package/src/prompts/tools/cron.md +6 -6
  394. package/src/prompts/tools/read.md +0 -2
  395. package/src/prompts/tools/skill-discovery.md +4 -2
  396. package/src/runtime-mcp/client.ts +427 -23
  397. package/src/runtime-mcp/config.ts +37 -5
  398. package/src/runtime-mcp/manager-pool.test.ts +56 -0
  399. package/src/runtime-mcp/manager.ts +52 -2
  400. package/src/runtime-mcp/oauth-discovery.ts +13 -0
  401. package/src/runtime-mcp/oauth-flow.ts +46 -1
  402. package/src/runtime-mcp/pool-key.ts +5 -1
  403. package/src/runtime-mcp/pool.test.ts +9 -0
  404. package/src/runtime-mcp/protocol.test.ts +353 -0
  405. package/src/runtime-mcp/protocol.ts +624 -0
  406. package/src/runtime-mcp/scope-config.ts +78 -0
  407. package/src/runtime-mcp/tool-bridge.ts +44 -10
  408. package/src/runtime-mcp/transports/http.ts +84 -13
  409. package/src/runtime-mcp/types.ts +123 -12
  410. package/src/sdk/acp/adapter.ts +328 -97
  411. package/src/sdk/broker/broker.ts +160 -741
  412. package/src/sdk/broker/ensure.ts +9 -1
  413. package/src/sdk/broker/lifecycle.ts +510 -116
  414. package/src/sdk/broker/operation-receipt-ledger.ts +11 -2
  415. package/src/sdk/broker/process-incarnation.ts +19 -0
  416. package/src/sdk/broker/session-index.ts +516 -117
  417. package/src/sdk/broker/startup-failure.ts +28 -5
  418. package/src/sdk/broker/transport.ts +1 -13
  419. package/src/sdk/bus/chat-daemon-cli.ts +4 -2
  420. package/src/sdk/bus/chat-daemon-control.ts +67 -6
  421. package/src/sdk/bus/chat-daemon-runtime.ts +183 -859
  422. package/src/sdk/bus/config.ts +13 -0
  423. package/src/sdk/bus/conversation-store.ts +59 -171
  424. package/src/sdk/bus/daemon-paths.ts +20 -0
  425. package/src/sdk/bus/discord-conversation.ts +2 -0
  426. package/src/sdk/bus/discord-daemon.ts +524 -126
  427. package/src/sdk/bus/discord-live-provider.ts +206 -79
  428. package/src/sdk/bus/existing-thread-readiness.ts +4 -97
  429. package/src/sdk/bus/inbound-reaction-ordering.ts +122 -0
  430. package/src/sdk/bus/index.ts +2480 -456
  431. package/src/sdk/bus/kind-aware-reconciliation.ts +204 -34
  432. package/src/sdk/bus/lifecycle-commands.ts +2 -2
  433. package/src/sdk/bus/managed-daemon.ts +2 -145
  434. package/src/sdk/bus/notification-service.ts +196 -0
  435. package/src/sdk/bus/operator-runtime.ts +1 -1
  436. package/src/sdk/bus/prompt-reconciliation.ts +24 -15
  437. package/src/sdk/bus/reconciliation-store.ts +553 -24
  438. package/src/sdk/bus/session-control.ts +49 -3
  439. package/src/sdk/bus/slack-conversation.ts +4 -0
  440. package/src/sdk/bus/slack-daemon.ts +733 -134
  441. package/src/sdk/bus/slack-live-provider.ts +65 -15
  442. package/src/sdk/bus/slack-provider.ts +4 -0
  443. package/src/sdk/bus/slack-thread-binding.ts +9 -104
  444. package/src/sdk/bus/telegram-adoption-intent.ts +299 -209
  445. package/src/sdk/bus/telegram-daemon-cli.ts +194 -7
  446. package/src/sdk/bus/telegram-daemon-contract.ts +265 -19
  447. package/src/sdk/bus/telegram-daemon-control.ts +3 -13
  448. package/src/sdk/bus/telegram-daemon-orphan-reap.ts +498 -0
  449. package/src/sdk/bus/telegram-daemon-owner-registry.ts +176 -0
  450. package/src/sdk/bus/telegram-daemon.ts +2552 -2280
  451. package/src/sdk/bus/telegram-reference.ts +1 -226
  452. package/src/sdk/bus/telegram-setup.ts +2 -2
  453. package/src/sdk/bus/threaded-render.ts +1 -0
  454. package/src/sdk/bus/topic-registry.ts +279 -146
  455. package/src/sdk/cli/rows.ts +2 -35
  456. package/src/sdk/cli/session-cli.ts +776 -700
  457. package/src/sdk/client/client.ts +892 -1
  458. package/src/sdk/host/control/dispatch.ts +93 -58
  459. package/src/sdk/host/control/index.ts +1 -1
  460. package/src/sdk/host/control/operations.ts +18 -10
  461. package/src/sdk/host/control/runtime-gate.ts +40 -0
  462. package/src/sdk/host/host.ts +63 -12
  463. package/src/sdk/host/query/handlers.ts +66 -7
  464. package/src/sdk/host/reverse-leases.ts +2 -2
  465. package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
  466. package/src/sdk/host/session-runtime.test.ts +2787 -34
  467. package/src/sdk/host/session-runtime.ts +1489 -101
  468. package/src/sdk/host/surface-policy.ts +12 -8
  469. package/src/sdk/host/types.ts +1 -1
  470. package/src/sdk/host/websocket-transport.lifecycle.test.ts +31 -59
  471. package/src/sdk/host/websocket-transport.ts +85 -50
  472. package/src/sdk/index.ts +2 -1
  473. package/src/sdk/lifecycle/broker-client.ts +53 -0
  474. package/src/sdk/lifecycle/client.ts +234 -0
  475. package/src/sdk/lifecycle/index.ts +3 -0
  476. package/src/sdk/{bus/recent-activity.ts → lifecycle/recent-sessions.ts} +5 -2
  477. package/src/sdk/lifecycle/service.ts +720 -0
  478. package/src/sdk/mcp/server.ts +226 -188
  479. package/src/sdk/prompt-deadline-lease.ts +55 -0
  480. package/src/sdk/prompt-deadline-manager.ts +148 -0
  481. package/src/sdk/prompt-status.ts +11 -3
  482. package/src/sdk/protocol/operation-inventory.generated.json +272 -129
  483. package/src/sdk/protocol/operation-registry.ts +71 -8
  484. package/src/sdk/receipt-state.ts +43 -0
  485. package/src/sdk/reconciliation-extensions.ts +14 -0
  486. package/src/sdk/router/index.ts +1 -0
  487. package/src/sdk/router/session-router.ts +1658 -0
  488. package/src/sdk/session-list.ts +93 -0
  489. package/src/sdk/session-reconnect.ts +45 -1
  490. package/src/sdk/session.ts +716 -120
  491. package/src/sdk/transport/serve-cli.ts +18 -34
  492. package/src/session/account-inventory.ts +603 -0
  493. package/src/session/agent-session.ts +2873 -559
  494. package/src/session/agent-storage.ts +35 -0
  495. package/src/session/blob-store.ts +32 -1
  496. package/src/session/coordinator-tool-label.ts +40 -0
  497. package/src/session/internal/managed-session-scope.ts +4 -0
  498. package/src/session/internal/managed-session-storage.ts +199 -31
  499. package/src/session/messages.ts +1 -1
  500. package/src/session/secure-token-file.ts +80 -0
  501. package/src/session/session-manager.ts +757 -57
  502. package/src/session/session-storage.ts +25 -16
  503. package/src/session/startup-auth-config.ts +297 -0
  504. package/src/session/terminal-abort.ts +1253 -0
  505. package/src/session/yield-queue.ts +57 -15
  506. package/src/session-import/claude.ts +334 -0
  507. package/src/session-import/codex.ts +1033 -0
  508. package/src/session-import/command.ts +77 -0
  509. package/src/session-import/detect.ts +133 -0
  510. package/src/session-import/index.ts +7 -0
  511. package/src/session-import/provider-codex.ts +327 -0
  512. package/src/session-import/provider-service.ts +457 -0
  513. package/src/session-import/redact.ts +128 -0
  514. package/src/session-import/service.ts +1264 -0
  515. package/src/session-import/types.ts +148 -0
  516. package/src/setup/paseo/check.ts +213 -0
  517. package/src/setup/paseo/install-saga.ts +293 -0
  518. package/src/setup/paseo/json-publisher.ts +263 -0
  519. package/src/setup/paseo/orchestration-preferences.ts +86 -0
  520. package/src/setup/paseo/paseo-ownership.ts +224 -0
  521. package/src/setup/paseo/paseo-setup.ts +253 -0
  522. package/src/setup/paseo/provider-config.ts +139 -0
  523. package/src/setup/paseo/remove.ts +183 -0
  524. package/src/setup/paseo/result-types.ts +79 -0
  525. package/src/setup/paseo/setup-deps.ts +175 -0
  526. package/src/setup/paseo/skills-bridge.ts +260 -0
  527. package/src/skill-state/active-state.ts +21 -11
  528. package/src/slash-commands/acp-builtins.ts +9 -1
  529. package/src/slash-commands/builtin-registry.ts +117 -19
  530. package/src/slash-commands/helpers/credential-switch.ts +143 -0
  531. package/src/slash-commands/helpers/mcp.ts +9 -3
  532. package/src/slash-commands/helpers/usage-report.ts +61 -56
  533. package/src/slash-commands/types.ts +7 -2
  534. package/src/task/executor.ts +30 -11
  535. package/src/task/index.ts +66 -7
  536. package/src/task/render.ts +23 -9
  537. package/src/tools/ask-answer-registry.ts +23 -7
  538. package/src/tools/ask.ts +8 -4
  539. package/src/tools/bash-sleep-advisory.ts +115 -0
  540. package/src/tools/bash.ts +98 -18
  541. package/src/tools/browser/attach.ts +71 -3
  542. package/src/tools/browser/launch.ts +86 -13
  543. package/src/tools/browser/profile-discovery.ts +57 -7
  544. package/src/tools/browser/render.ts +8 -1
  545. package/src/tools/browser.ts +130 -21
  546. package/src/tools/computer.enforcement.test.ts +1 -1
  547. package/src/tools/computer.ts +3 -2
  548. package/src/tools/cron.ts +12 -1
  549. package/src/tools/image-gen.ts +349 -142
  550. package/src/tools/index.ts +8 -0
  551. package/src/tools/job.ts +33 -6
  552. package/src/tools/monitor.ts +41 -2
  553. package/src/tools/skill-discovery.ts +21 -29
  554. package/src/tools/subagent.ts +142 -24
  555. package/src/tools/todo-contract.ts +55 -3
  556. package/src/tools/todo-write.ts +4 -3
  557. package/src/tools/tool-catalog.generated.ts +6 -6
  558. package/src/tools/ultragoal-ask-guard.ts +10 -3
  559. package/src/utils/herdr-pane.ts +345 -0
  560. package/src/utils/title-generator.ts +2 -0
  561. package/src/web/scrapers/index.ts +6 -4
  562. package/dist/types/cli/malloc-env-guard.test.d.ts +0 -1
  563. package/dist/types/defaults/gjc-defaults.test.d.ts +0 -1
  564. package/dist/types/memory-backend/lazy-loading.test.d.ts +0 -1
  565. package/dist/types/runtime/boot-ordering.test.d.ts +0 -1
  566. package/dist/types/runtime/lazy-service.test.d.ts +0 -1
  567. package/dist/types/runtime/network-prewarm-service.test.d.ts +0 -1
  568. package/dist/types/runtime/workspace-tree-service.test.d.ts +0 -1
  569. package/dist/types/runtime-mcp/config.test.d.ts +0 -1
  570. package/dist/types/runtime-mcp/http.test.d.ts +0 -1
  571. package/dist/types/runtime-mcp/manager-pool.test.d.ts +0 -1
  572. package/dist/types/runtime-mcp/pool-key.test.d.ts +0 -1
  573. package/dist/types/runtime-mcp/pool.test.d.ts +0 -1
  574. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +0 -117
  575. package/dist/types/sdk/bus/lifecycle-orchestrator.d.ts +0 -141
  576. package/dist/types/sdk/bus/telegram-cli.d.ts +0 -31
  577. package/dist/types/sdk/elevation/allowlist.d.ts +0 -20
  578. package/dist/types/sdk/elevation/audit.d.ts +0 -93
  579. package/dist/types/sdk/elevation/capability.d.ts +0 -3
  580. package/dist/types/sdk/elevation/digest.d.ts +0 -72
  581. package/dist/types/sdk/elevation/dispatch-receipt.d.ts +0 -85
  582. package/dist/types/sdk/elevation/grant-ledger.d.ts +0 -163
  583. package/dist/types/sdk/elevation/owner.d.ts +0 -48
  584. package/dist/types/sdk/host/import-graph.test.d.ts +0 -1
  585. package/dist/types/sdk/host/sdk-surface-parity.test.d.ts +0 -1
  586. package/dist/types/sdk/host/session-runtime.test.d.ts +0 -1
  587. package/dist/types/sdk/host/websocket-transport.lifecycle.test.d.ts +0 -1
  588. package/dist/types/session/auth-broker-config.d.ts +0 -13
  589. package/dist/types/session/heap-eviction-artifacts.test.d.ts +0 -1
  590. package/dist/types/skills/skills.test.d.ts +0 -1
  591. package/dist/types/slash-commands/notify-command.test.d.ts +0 -1
  592. package/dist/types/task/token-log.test.d.ts +0 -1
  593. package/dist/types/tools/computer.enforcement.test.d.ts +0 -1
  594. package/dist/types/tools/descriptors.test.d.ts +0 -1
  595. package/scripts/g005-daemon-smoke.ts +0 -180
  596. package/scripts/g011-daemon-path-smoke.ts +0 -153
  597. package/src/sdk/bus/lifecycle-control-runtime.ts +0 -1733
  598. package/src/sdk/bus/lifecycle-orchestrator.ts +0 -489
  599. package/src/sdk/bus/telegram-cli.ts +0 -212
  600. package/src/sdk/elevation/allowlist.ts +0 -54
  601. package/src/sdk/elevation/audit.ts +0 -208
  602. package/src/sdk/elevation/capability.ts +0 -41
  603. package/src/sdk/elevation/digest.ts +0 -129
  604. package/src/sdk/elevation/dispatch-receipt.ts +0 -184
  605. package/src/sdk/elevation/grant-ledger.ts +0 -1142
  606. package/src/sdk/elevation/owner.ts +0 -109
  607. package/src/session/auth-broker-config.ts +0 -109
  608. /package/dist/types/{ai-core-import-gate.test.d.ts → discovery/claude-hooks.d.ts} +0 -0
  609. /package/dist/types/{cli/fixture-report.test.d.ts → discovery/codex-hooks.d.ts} +0 -0
  610. /package/dist/types/sdk/{bus/recent-activity.d.ts → lifecycle/recent-sessions.d.ts} +0 -0
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { execFile } from "node:child_process";
2
3
  import * as crypto from "node:crypto";
3
4
  import * as fs from "node:fs/promises";
@@ -6,16 +7,23 @@ import { promisify } from "node:util";
6
7
  import { ThinkingLevel } from "@gajae-code/agent-core";
7
8
  import type { Api, Model } from "@gajae-code/ai/core";
8
9
  import { logger } from "@gajae-code/utils";
10
+ import { AsyncJobManager } from "../../async";
9
11
  import { isModelProfileProviderAvailable, projectModelProfileCatalog } from "../../config/model-profile-contract";
10
12
  import { type ModelProfileDefinition, resolveProfileBindings } from "../../config/model-profiles";
11
13
  import { resolveModelChainWithAuth, splitSelectorThinkingSuffix } from "../../config/model-resolver";
12
14
  import { type ModelSelectorValue, normalizeModelSelectorValue } from "../../config/model-selector-value";
13
15
  import { type Settings, validateSettingPatch } from "../../config/settings";
14
16
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../../extensibility/extensions";
17
+ import {
18
+ boundTerminalRetentionState,
19
+ findOwnedRegistrationsForTurn,
20
+ isOwnedAttemptRegistrationIncomplete,
21
+ MAX_DURABLE_TERMINAL_RESERVATIONS,
22
+ settleOwnedWork,
23
+ } from "../../session/terminal-abort";
15
24
  import { parseThinkingLevel } from "../../thinking";
16
25
  import { ensureBroker } from "../broker/ensure";
17
26
  import { SessionIndex } from "../broker/session-index";
18
- import { elevationAuthorityPath, verifyElevationCapability } from "../elevation/capability";
19
27
  import {
20
28
  collectAuthenticatedProfileProviders,
21
29
  parseSyntheticModelId,
@@ -25,11 +33,19 @@ import {
25
33
  syntheticNamespaceCollision,
26
34
  } from "../model-profile-model";
27
35
  import { projectQ10Models } from "../models.js";
36
+ import { PromptDeadlineManager } from "../prompt-deadline-manager";
28
37
  import { formatPromptFailureForLocalLog, sanitizePromptFailure } from "../prompt-failure";
29
38
  import { OPERATIONS } from "../protocol/operation-registry";
39
+ import {
40
+ createKindAwareReconciliation,
41
+ createReconciliationStore,
42
+ type KindAwareReconciliation,
43
+ resolveReconciliationSessionFile,
44
+ } from "../reconciliation-extensions";
30
45
  import { type ControlSurface, controlRequestFromFrame, dispatchControl } from "./control";
46
+ import { BROKER_RUNTIME_CLOSE_CAPABILITY_FIELD } from "./control/runtime-gate";
31
47
  import { SessionSdkHost, type SessionSdkHostOptions } from "./host";
32
- import { CursorRegistry, QueryHandlers, RevisionStore, type SdkCheckpointRecord, type SessionSurface } from "./query";
48
+ import { CursorRegistry, QueryHandlers, RevisionStore, type SessionSurface } from "./query";
33
49
  import {
34
50
  createSdkCapabilities,
35
51
  createSdkSurfacePolicyForContext,
@@ -41,6 +57,35 @@ import {
41
57
  import type { BrokerIndexWriter, SdkFrame } from "./types";
42
58
 
43
59
  const execFileAsync = promisify(execFile);
60
+ const sdkControlRequesterContext = new AsyncLocalStorage<string>();
61
+
62
+ /**
63
+ * Thrown from a serialized durable terminal-scope transaction when the
64
+ * idempotency key is already owned by a DIFFERENT input (scope). After the
65
+ * dispatch cache evicts an in-flight entry, two concurrent requests can both
66
+ * pass the earlier snapshot check; the atomic recheck inside the transaction
67
+ * must reject the second instead of appending a duplicate-key row (review
68
+ * thread P2).
69
+ */
70
+ class SdkOnlyIdempotencyConflictError extends Error {
71
+ constructor() {
72
+ super("Idempotency key was reused with different input.");
73
+ }
74
+ }
75
+
76
+ /** Bounded wait for the correlated agent_end lifecycle publication after a
77
+ * terminal abort settles, before the durable row may claim
78
+ * `terminalPublished` (review thread P2). The bus runtime needs no such wait:
79
+ * it publishes the correlated event inline during terminalization and records
80
+ * the outcome synchronously on its capture slot, while this runtime observes
81
+ * the publication from the separate `agent_end` handler. */
82
+ const SDK_ONLY_TERMINAL_PUBLICATION_WAIT_MS = 1_000;
83
+ /** Bounded wait for in-flight workflow gate resolutions to settle during SDK
84
+ * runtime shutdown before proceeding with cleanup. Unresolved resolutions
85
+ * after this bound are abandoned — their durable broker state is the recovery
86
+ * authority, and outcomes are inherently uncertain. */
87
+ const GATE_RESOLUTION_QUIESCENCE_MS = 5_000;
88
+
44
89
  class DiffQueryError extends Error {
45
90
  constructor(
46
91
  readonly code: "not_git_repository" | "diff_too_large",
@@ -55,7 +100,10 @@ export interface SessionSdkTransport {
55
100
  readonly sessionId: string;
56
101
  readonly stateRoot: string;
57
102
  readonly token: string;
58
- sendFrame(connectionId: string, frame: SdkFrame): void | Promise<void>;
103
+ sendFrame(
104
+ connectionId: string,
105
+ frame: SdkFrame,
106
+ ): void | "written" | "dropped" | Promise<void> | Promise<"written" | "dropped">;
59
107
  onFrame(handler: (connectionId: string, frame: SdkFrame) => void): undefined | (() => void);
60
108
  onMalformedFrame?(handler: (connectionId: string, message: string) => void): undefined | (() => void);
61
109
  start(): Promise<{ url: string }>;
@@ -76,6 +124,110 @@ export interface SessionSdkRuntimeOptions
76
124
  configOverrides?: Map<string, unknown>;
77
125
  }
78
126
 
127
+ export interface SdkOnlyInvocationRecord extends InvocationCorrelation {
128
+ kind: InvocationKind | "terminal" | "steer";
129
+ clientRef?: string;
130
+ status: InvocationStatus | "dispatching" | "rejected";
131
+ acceptedAt: number;
132
+ startedAt?: number;
133
+ terminalAt?: number;
134
+ error?: { code: string; message: string };
135
+ outcome?: unknown;
136
+ pendingOutcome?: unknown;
137
+ skillName?: string;
138
+ /** Steer records (origin/dev) carry their own dispatching lifecycle. */
139
+ textDigest?: string;
140
+ createdAt?: number;
141
+ settledAt?: number;
142
+ }
143
+
144
+ export interface SdkOnlyTerminalScopeRecord {
145
+ selection: "turn" | "owned";
146
+ idempotencyKeyHash?: string;
147
+ idempotencyInputHash?: string;
148
+ turnDisposition:
149
+ | "pending"
150
+ | "stopped"
151
+ | "uncertain"
152
+ | "no_effect"
153
+ | "no_effect_reserved"
154
+ | "no_effect_marker_failure";
155
+ terminalPublished?: boolean;
156
+ ownedWorkDisposition: "not_requested" | "left_running" | "stopped" | "uncertain";
157
+ automaticDeliveryDisposition: "enabled" | "none";
158
+ resumeOnOwnedCompletion: boolean;
159
+ turnContinuationFence: {
160
+ state: "retained" | "released";
161
+ abortedAttemptEpoch: number;
162
+ blockedContinuationIds: string[];
163
+ predecessorTombstones: string[];
164
+ ownedCompletionPolicy: "enabled" | "disabled";
165
+ };
166
+ responseState: "pending" | "sent" | "delivered" | "failed";
167
+ responsePayloadHash: string;
168
+ replayPayloadHash?: string;
169
+ acceptedAt: number;
170
+ terminalAt?: number;
171
+ }
172
+
173
+ export interface SdkOnlyEvictedTerminalKeyEntry {
174
+ keyHash: string;
175
+ inputHash: string;
176
+ turnDisposition?: "stopped" | "uncertain" | "no_effect" | "no_effect_reserved" | "no_effect_marker_failure";
177
+ ownedWorkDisposition?: "not_requested" | "left_running" | "stopped" | "uncertain";
178
+ responseState?: "pending" | "sent" | "delivered" | "failed";
179
+ responsePayloadHash?: string;
180
+ replayPayloadHash?: string;
181
+ terminalPublished?: boolean;
182
+ }
183
+
184
+ export interface SdkOnlyReconciliationStore {
185
+ readonly path: string | null;
186
+ load(): Promise<unknown[]>;
187
+ transact(mutator: (records: SdkOnlyInvocationRecord[]) => SdkOnlyInvocationRecord[]): Promise<void>;
188
+ snapshotTerminalScopes(): SdkOnlyTerminalScopeRecord[];
189
+ snapshotTerminalKeys(): SdkOnlyEvictedTerminalKeyEntry[];
190
+ transactTerminalScopes(
191
+ mutator: (scopes: SdkOnlyTerminalScopeRecord[]) => SdkOnlyTerminalScopeRecord[],
192
+ ): Promise<void>;
193
+ transactTerminalState(
194
+ mutator: (state: { scopes: SdkOnlyTerminalScopeRecord[]; keys: SdkOnlyEvictedTerminalKeyEntry[] }) => {
195
+ scopes: SdkOnlyTerminalScopeRecord[];
196
+ keys: SdkOnlyEvictedTerminalKeyEntry[];
197
+ },
198
+ ): Promise<void>;
199
+ }
200
+
201
+ export interface SdkOnlyTerminalAbortSeams {
202
+ getReconciliationStore?: () => SdkOnlyReconciliationStore | undefined;
203
+ getTerminalTurnEpoch: () => number | undefined;
204
+ getActivePromptHandle: () => string | undefined;
205
+ /** Re-read the active prompt's owning SDK connection for the owner-mismatch
206
+ * recheck; falls back to the runtime-tracked owner when absent (review
207
+ * thread P1). */
208
+ getActivePromptOwnerConnectionId?: () => string | undefined;
209
+ cancelPendingPreflightForTerminalAbort: () => void;
210
+ /** Capture the steering admission snapshot at abort ADMISSION (before any
211
+ * durable transaction), so steers admitted while the abort is in flight
212
+ * classify as post-snapshot (review thread P1). */
213
+ captureTerminalAbortSteeringSnapshot?: () => number | undefined;
214
+ /** Discard the steering snapshot when a replay-only abort never settles
215
+ * (review thread P1). */
216
+ discardTerminalAbortSteeringSnapshot?: (token: number) => void;
217
+ /** Rebind the snapshot to the current turn when the requester's turn
218
+ * wins the race (review thread P1). */
219
+ rebindTerminalAbortSteeringSnapshot?: (token: number) => void;
220
+ abortPromptAndWaitWithTerminal: (
221
+ handle: string,
222
+ options: {
223
+ graceMs: number;
224
+ terminal?: { scope: "turn" | "owned"; expectedEpoch?: number; steeringSnapshotToken?: number };
225
+ },
226
+ ) => Promise<{ status: string; terminalScope?: unknown }>;
227
+ /** Test override for the maximum durable terminal reservation rows. */
228
+ maxDurableTerminalReservationsForTests?: number;
229
+ }
230
+
79
231
  /**
80
232
  * The transport-neutral SDK session runtime.
81
233
  *
@@ -102,7 +254,11 @@ export class SessionSdkSessionRuntime {
102
254
  sessionId: options.transport.sessionId,
103
255
  stateRoot: options.transport.stateRoot,
104
256
  token: options.transport.token,
105
- sendFrame: options.transport.sendFrame,
257
+ sendFrame: (connectionId, frame) => {
258
+ const result = options.transport.sendFrame(connectionId, frame);
259
+ if (result instanceof Promise) return result.then(outcome => outcome ?? "written");
260
+ return result ?? "written";
261
+ },
106
262
  onFrame: options.transport.onFrame,
107
263
  });
108
264
  this.#connectionDisposer = options.transport.onConnectionClose?.(connectionId => {
@@ -234,6 +390,10 @@ export interface CreateSdkSessionRuntimeOptions {
234
390
  settings?: Settings;
235
391
  /** Mutable shadow of patched config values merged into query readback. */
236
392
  configOverrides?: Map<string, unknown>;
393
+ /** Private session-owned terminal-abort capabilities; never exposed on ExtensionContext. */
394
+ terminalAbortSeams?: SdkOnlyTerminalAbortSeams;
395
+ /** Callback when a frame is admitted to the runtime (test harness). */
396
+ onFrameAdmitted?: () => void;
237
397
  }
238
398
 
239
399
  function unavailable(operation: string): () => never {
@@ -249,8 +409,8 @@ export interface InvocationCorrelation {
249
409
  turnId: string;
250
410
  }
251
411
 
252
- export type InvocationKind = "prompt" | "skill";
253
- type InvocationStatus = "accepted" | "in_flight" | "terminal_ok" | "failed";
412
+ export type InvocationKind = "prompt" | "skill" | "steer";
413
+ type InvocationStatus = "accepted" | "in_flight" | "terminal_ok" | "failed" | "uncertain";
254
414
  interface InvocationRecord extends InvocationCorrelation {
255
415
  kind: InvocationKind;
256
416
  clientRef?: string;
@@ -261,6 +421,8 @@ interface InvocationRecord extends InvocationCorrelation {
261
421
  error?: { code: string; message: string };
262
422
  }
263
423
  export interface InvocationReconciliation {
424
+ /** Shared v2 reconciliation owner; present for durable terminal admission. */
425
+ readonly store?: SdkOnlyReconciliationStore;
264
426
  admit(kind: InvocationKind, clientRef?: string): void;
265
427
  release(kind: InvocationKind, clientRef?: string): void;
266
428
  noteAccepted(kind: InvocationKind, correlation: InvocationCorrelation, clientRef?: string): Promise<void>;
@@ -272,31 +434,49 @@ export interface InvocationReconciliation {
272
434
  lookup(kind: InvocationKind, selector: { commandId?: string; turnId?: string; clientRef?: string }): unknown;
273
435
  lookupResult(kind: InvocationKind, selector: { commandId?: string; turnId?: string; clientRef?: string }): unknown;
274
436
  hydrate(): Promise<void>;
437
+ claimPendingOutcome(
438
+ kind: InvocationKind,
439
+ correlation: InvocationCorrelation,
440
+ outcome: { kind: string; code: string; message: string; provenance?: string },
441
+ ): Promise<unknown>;
442
+ finalizeOutcome(
443
+ kind: InvocationKind,
444
+ correlation: InvocationCorrelation,
445
+ outcome?: { kind: string; code: string; message: string; provenance?: string },
446
+ ): Promise<void>;
275
447
  }
276
448
 
277
449
  export function createInvocationReconciliation(
278
- options: { stateRoot?: string; sessionId?: string } = {},
450
+ options: { stateRoot?: string; sessionId?: string; store?: SdkOnlyReconciliationStore } = {},
279
451
  ): InvocationReconciliation {
280
452
  const ACTIVE_CAPACITY = 256;
281
453
  const TERMINAL_CAPACITY = 512;
282
- const TERMINAL_TTL_MS = 15 * 60_000;
283
454
  const records = new Map<string, InvocationRecord>();
284
455
  const reservations = new Map<string, InvocationKind>();
285
456
  const reservationCounts = new Map<InvocationKind, number>([
286
457
  ["prompt", 0],
287
458
  ["skill", 0],
459
+ ["steer", 0],
288
460
  ]);
289
461
  const key = (kind: InvocationKind, correlation: InvocationCorrelation) =>
290
462
  `${kind}:${correlation.commandId}:${correlation.turnId}`;
291
463
  const ref = (kind: InvocationKind, clientRef: string) => `${kind}\\0${clientRef}`;
292
464
  if (options.sessionId && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(options.sessionId))
293
465
  throw Object.assign(new Error("Unsafe SDK reconciliation session id."), { code: "invalid_input" });
466
+ const store = options.store;
467
+ // Legacy fs-backed path retained for callers that pass stateRoot/sessionId
468
+ // without a store (origin/dev behavior); the store path is authoritative
469
+ // when present (shared v2 reconciliation owner).
294
470
  const reconciliationFile =
295
- options.stateRoot && options.sessionId
471
+ options.stateRoot && options.sessionId && !store
296
472
  ? path.join(options.stateRoot, ".sdk-reconciliation", `${options.sessionId}.json`)
297
473
  : undefined;
298
474
  let persistenceChain: Promise<void> = Promise.resolve();
299
475
  const persist = async (): Promise<void> => {
476
+ if (store) {
477
+ await store.transact(() => [...records.values()]);
478
+ return;
479
+ }
300
480
  if (!reconciliationFile) return;
301
481
  const run = async (): Promise<void> => {
302
482
  const directory = path.dirname(reconciliationFile);
@@ -317,11 +497,11 @@ export function createInvocationReconciliation(
317
497
  );
318
498
  await pending;
319
499
  };
500
+ // Retention contract (#4547): terminal records are never age-evicted; only
501
+ // the per-kind oldest-terminal-first capacity trim removes them, so a
502
+ // fire-and-wake consumer can still query the canonical terminal outcome
503
+ // until capacity eviction honestly reports `unknown`.
320
504
  const cleanup = (): void => {
321
- const now = Date.now();
322
- for (const [recordKey, record] of records) {
323
- if (record.terminalAt !== undefined && record.terminalAt + TERMINAL_TTL_MS <= now) records.delete(recordKey);
324
- }
325
505
  for (const kind of ["prompt", "skill"] as const) {
326
506
  const terminal = [...records.entries()]
327
507
  .filter(([, record]) => record.kind === kind && record.terminalAt !== undefined)
@@ -341,6 +521,21 @@ export function createInvocationReconciliation(
341
521
  return records.get(key(kind, { commandId: selector.commandId, turnId: selector.turnId }));
342
522
  };
343
523
  const hydrate = async (): Promise<void> => {
524
+ if (store) {
525
+ const loaded = (await store.load()) as SdkOnlyInvocationRecord[];
526
+ for (const candidate of loaded) {
527
+ if (candidate.kind !== "prompt" && candidate.kind !== "skill") continue;
528
+ if (!candidate.commandId || !candidate.turnId || typeof candidate.acceptedAt !== "number") continue;
529
+ const kind = candidate.kind as InvocationKind;
530
+ const record: InvocationRecord = { ...candidate, kind } as InvocationRecord;
531
+ // Never re-hydrate a failure reason that could contain provider secrets
532
+ // into a fresh process (origin/dev sanitization preserved).
533
+ if (record.status === "failed") record.error = sanitizePromptFailure(record.error);
534
+ records.set(key(kind, candidate), record);
535
+ }
536
+ cleanup();
537
+ return;
538
+ }
344
539
  if (!reconciliationFile) return;
345
540
  let raw: string;
346
541
  try {
@@ -377,6 +572,7 @@ export function createInvocationReconciliation(
377
572
  cleanup();
378
573
  };
379
574
  return {
575
+ store,
380
576
  admit(kind, clientRef) {
381
577
  cleanup();
382
578
  const active = [...records.values()].filter(
@@ -479,6 +675,31 @@ export function createInvocationReconciliation(
479
675
  return result.status === "unknown" ? result : { kind, ...result };
480
676
  },
481
677
  hydrate,
678
+ async claimPendingOutcome(kind, correlation, outcome) {
679
+ const record = records.get(key(kind, correlation));
680
+ if (!record || record.terminalAt !== undefined || record.kind !== kind) return outcome;
681
+ const pending = (record as unknown as { pendingOutcome?: unknown }).pendingOutcome;
682
+ if (pending !== undefined) return pending;
683
+ (record as unknown as Record<string, unknown>).pendingOutcome = outcome;
684
+ await persist();
685
+ return outcome;
686
+ },
687
+ async finalizeOutcome(kind, correlation, outcome) {
688
+ const record = records.get(key(kind, correlation));
689
+ if (!record || record.terminalAt !== undefined || record.kind !== kind) return;
690
+ const finalOutcome = (outcome ??
691
+ (record as unknown as { pendingOutcome?: { kind: string; code: string; message: string } })
692
+ .pendingOutcome) as { kind: string; code: string; message: string } | undefined;
693
+ record.terminalAt = Date.now();
694
+ if (finalOutcome?.kind === "failed") {
695
+ record.status = "failed";
696
+ record.error = { code: finalOutcome.code, message: finalOutcome.message };
697
+ } else {
698
+ record.status = "terminal_ok";
699
+ }
700
+ (record as unknown as Record<string, unknown>).pendingOutcome = undefined;
701
+ await persist();
702
+ },
482
703
  };
483
704
  }
484
705
 
@@ -498,9 +719,8 @@ export interface SdkSurfaceFactoryOptions {
498
719
  turnId?: string;
499
720
  clientRef?: string;
500
721
  }) => unknown;
722
+ steerStatusLookup?: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown;
501
723
  hostTools?: boolean | (() => boolean);
502
- /** Q30 event-ring watermark source (the live session event stream); synchronous. */
503
- getEventWatermark?: () => { generation: number; seq: number };
504
724
  }
505
725
 
506
726
  /** Shared policy, capability, and query-surface factory for every SDK transport. */
@@ -528,9 +748,8 @@ function createQuerySurface(
528
748
  turnId?: string;
529
749
  clientRef?: string;
530
750
  }) => unknown;
751
+ steerStatusLookup?: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown;
531
752
  hostTools?: boolean | (() => boolean);
532
- /** Q30 event-ring watermark source (the live session event stream); synchronous. */
533
- getEventWatermark?: () => { generation: number; seq: number };
534
753
  } = {},
535
754
  ): SessionSurface {
536
755
  const policy =
@@ -804,6 +1023,8 @@ function createQuerySurface(
804
1023
  turnId?: string;
805
1024
  clientRef?: string;
806
1025
  }) => (options.turnResultLookup ?? (value => reconciliation.lookupResult(value.kind, value)))(selector),
1026
+ getSteerStatus: (selector: { commandId?: string; turnId?: string; clientRef?: string }) =>
1027
+ (options.steerStatusLookup ?? (value => reconciliation.lookup("steer", value)))(selector),
807
1028
  getModelProfiles: async () => {
808
1029
  const profiles = ctx.modelRegistry.getModelProfiles();
809
1030
  const authenticatedProviders = await collectAuthenticatedProfileProviders(profiles, provider =>
@@ -820,23 +1041,6 @@ function createQuerySurface(
820
1041
  })),
821
1042
  )) as unknown[];
822
1043
  },
823
- getCheckpointSnapshot: (): { entries: unknown[]; watermark: SdkCheckpointRecord } => {
824
- // Q30 atomic checkpoint capture (C9): the transcript entries and the
825
- // event-ring watermark are read in one synchronous call from the host
826
- // event stream, so the snapshot revision and the subscribe position
827
- // can never straddle a concurrent append.
828
- const entries =
829
- typeof (ctx as Partial<ExtensionContext>).getTranscript === "function" ? ctx.getTranscript() : [];
830
- const watermark = options.getEventWatermark?.() ?? { generation: 0, seq: 0 };
831
- return {
832
- entries,
833
- watermark: {
834
- revision: Array.isArray(entries) ? entries.length : 0,
835
- generation: watermark.generation,
836
- seq: watermark.seq,
837
- },
838
- };
839
- },
840
1044
  installedQueries: policy.installedQueries,
841
1045
  };
842
1046
  }
@@ -865,8 +1069,8 @@ export function createSdkSurfaceFactory(
865
1069
  configOverrides: options.configOverrides,
866
1070
  settings: options.settings,
867
1071
  turnResultLookup: options.turnResultLookup,
1072
+ steerStatusLookup: options.steerStatusLookup,
868
1073
  hostTools: options.hostTools,
869
- getEventWatermark: options.getEventWatermark,
870
1074
  });
871
1075
  return {
872
1076
  policy,
@@ -957,7 +1161,13 @@ async function resolveSdkWorkflowGate(
957
1161
  const completed = workflowGate.lookupCompletedResolution(response);
958
1162
  if (completed.kind === "completed") return completed.resolution;
959
1163
  if (completed.kind === "accepted_incomplete") {
960
- await workflowGate.recoverAcceptedGates();
1164
+ try {
1165
+ await workflowGate.recoverAcceptedGates();
1166
+ } catch {
1167
+ throw Object.assign(new Error("Workflow gate resolution outcome is uncertain."), {
1168
+ code: "terminal_uncertain",
1169
+ });
1170
+ }
961
1171
  const recovered = workflowGate.lookupCompletedResolution(response);
962
1172
  if (recovered.kind === "completed") return recovered.resolution;
963
1173
  throw Object.assign(new Error("Workflow gate resolution outcome is uncertain."), { code: "terminal_uncertain" });
@@ -969,6 +1179,22 @@ async function resolveSdkWorkflowGate(
969
1179
  if ((resolution as { status?: unknown }).status === "rejected") workflowGate.clearPreparedTerminalization(id);
970
1180
  return resolution;
971
1181
  } catch (error) {
1182
+ const completedAfterFailure = workflowGate.lookupCompletedResolution(response);
1183
+ if (completedAfterFailure.kind === "completed") return completedAfterFailure.resolution;
1184
+ if (completedAfterFailure.kind === "accepted_incomplete") {
1185
+ try {
1186
+ await workflowGate.recoverAcceptedGates();
1187
+ } catch {
1188
+ throw Object.assign(new Error("Workflow gate resolution outcome is uncertain."), {
1189
+ code: "terminal_uncertain",
1190
+ });
1191
+ }
1192
+ const recovered = workflowGate.lookupCompletedResolution(response);
1193
+ if (recovered.kind === "completed") return recovered.resolution;
1194
+ throw Object.assign(new Error("Workflow gate resolution outcome is uncertain."), {
1195
+ code: "terminal_uncertain",
1196
+ });
1197
+ }
972
1198
  const stillPending = workflowGate.listPendingGates?.().some(gate => gate.gate_id === id) === true;
973
1199
  if (stillPending) workflowGate.clearPreparedTerminalization(id);
974
1200
  else workflowGate.quarantineGate?.(id);
@@ -980,12 +1206,26 @@ function createControlSurface(
980
1206
  ctx: ExtensionContext,
981
1207
  api: ExtensionAPI,
982
1208
  reconciliation: InvocationReconciliation,
983
- onAccepted: (kind: InvocationKind, correlation: InvocationCorrelation) => void,
1209
+ onAccepted: (
1210
+ kind: InvocationKind,
1211
+ correlation: InvocationCorrelation,
1212
+ connectionId: string | undefined,
1213
+ startsOwnTurn: boolean,
1214
+ ) => void,
1215
+ steerReconciliation: KindAwareReconciliation,
1216
+ onPromotedTurn?: (
1217
+ kind: InvocationKind,
1218
+ correlation: InvocationCorrelation,
1219
+ connectionId: string | undefined,
1220
+ ) => void,
984
1221
  policy?: SdkSurfacePolicy,
985
1222
  settings?: Settings,
986
1223
  configOverrides?: Map<string, unknown>,
987
1224
  configRevision: { current: number } = { current: 0 },
988
- elevationAuthorityToken?: string,
1225
+ terminalAbortSeams?: SdkOnlyTerminalAbortSeams,
1226
+ terminalPublicationCapture?: { resolvers?: Array<(observed: boolean) => void> },
1227
+ activePromptOwnerHolder?: { connectionIds?: ReadonlySet<string> },
1228
+ retirePendingOwner?: (correlation: InvocationCorrelation) => void,
989
1229
  canResolveGate: () => boolean = () => true,
990
1230
  trackGateResolution: <T>(resolution: Promise<T>) => Promise<T> = async resolution => await resolution,
991
1231
  ): ControlSurface {
@@ -1042,6 +1282,24 @@ function createControlSurface(
1042
1282
  : { changed: true };
1043
1283
  };
1044
1284
  const newCorrelation = () => ({ commandId: crypto.randomUUID(), turnId: crypto.randomUUID() });
1285
+ const pendingPreflights = new Map<string, Set<() => void>>();
1286
+ // The SDK connection that accepted the currently active prompt/skill, if
1287
+ // any: terminal aborts are requester-scoped, so another connection must
1288
+ // never stop it, and an agent-initiated turn (monitor/cron follow-up) has
1289
+ // no owner — every client is refused (review thread P1). Shared with the
1290
+ // runtime extension so agent_end clears it: a stale owner must not
1291
+ // authorize its old client against a later turn it did not submit (review
1292
+ // thread P1).
1293
+ const activePromptOwner = activePromptOwnerHolder ?? { connectionIds: new Set<string>() };
1294
+ const currentRequesterPreflights = (): Set<() => void> => {
1295
+ const key = sdkControlRequesterContext.getStore() ?? "";
1296
+ let pending = pendingPreflights.get(key);
1297
+ if (!pending) {
1298
+ pending = new Set();
1299
+ pendingPreflights.set(key, pending);
1300
+ }
1301
+ return pending;
1302
+ };
1045
1303
  const normalizeClientRef = (clientRef: string | undefined): string | undefined => {
1046
1304
  if (clientRef === undefined) return undefined;
1047
1305
  const trimmed = clientRef.trim();
@@ -1057,25 +1315,48 @@ function createControlSurface(
1057
1315
  run: (options: {
1058
1316
  onPreflightAccepted: () => void;
1059
1317
  onPreflightAcceptCommit: () => Promise<void>;
1318
+ /** Fired when a queued submission (steering or follow-up) is promoted to its own run (SDK ownership correlation). */
1319
+ onQueuedPromoted: () => void;
1320
+ queuedAtDispatch: boolean;
1060
1321
  }) => Promise<unknown>,
1061
1322
  acceptedFields?: () => Record<string, unknown>,
1062
1323
  allowCompletionFallback = false,
1063
1324
  alwaysQueued = false,
1064
1325
  ): Promise<unknown> => {
1326
+ // Capture the REQUESTING connection at admission: a terminal abort from
1327
+ // another SDK connection must never stop the prompt this one accepts
1328
+ // (review thread P1).
1329
+ const requesterConnectionId = sdkControlRequesterContext.getStore();
1065
1330
  const retainedClientRef = normalizeClientRef(clientRef);
1066
1331
  reconciliation.admit(kind, retainedClientRef);
1067
1332
  const correlation = newCorrelation();
1068
1333
  const preflight = Promise.withResolvers<void>();
1069
1334
  let accepted = false;
1070
1335
  let settled = false;
1336
+ const cancelPreflight = () => {
1337
+ if (settled) return;
1338
+ settled = true;
1339
+ preflight.reject(
1340
+ Object.assign(new Error("Prompt preflight was cancelled before execution."), { code: "busy" }),
1341
+ );
1342
+ };
1343
+ const requesterPreflights = currentRequesterPreflights();
1344
+ requesterPreflights.add(cancelPreflight);
1071
1345
  const accept = async (): Promise<void> => {
1072
1346
  if (settled) return;
1073
-
1347
+ // startsOwnTurn is captured from the pre-dispatch idle snapshot (see
1348
+ // below): re-reading ctx.isIdle() here would observe the session as
1349
+ // already streaming, because the production AgentSession begins its
1350
+ // in-flight bookkeeping before the preflight acceptance callback
1351
+ // (review thread P1).
1074
1352
  try {
1075
1353
  await reconciliation.noteAccepted(kind, correlation, retainedClientRef);
1076
1354
  accepted = true;
1077
1355
  settled = true;
1078
- onAccepted(kind, correlation);
1356
+ // The accepted submission does NOT own the active turn until its run
1357
+ // actually STARTS: the connection is carried on the pending entry and
1358
+ // associated at agent_start instead (review thread P1).
1359
+ onAccepted(kind, correlation, requesterConnectionId, startsOwnTurn);
1079
1360
  preflight.resolve();
1080
1361
  } catch (error) {
1081
1362
  settled = true;
@@ -1087,12 +1368,32 @@ function createControlSurface(
1087
1368
  // sendUserMessage will divert to steer-queue and resolve before the turn runs.
1088
1369
  // Re-reading ctx.isIdle() after run() would race — accept() does async fs I/O that
1089
1370
  // yields, so isStreaming can flip during the persist window.
1090
- const queuedAtDispatch = alwaysQueued || !ctx.isIdle();
1371
+ // Queued when the submission is always-queued or the session is NOT idle; the
1372
+ // optional chaining keeps harness contexts without isIdle working (the branch
1373
+ // model treats an absent isIdle as idle).
1374
+ const queuedAtDispatch = alwaysQueued || ctx.isIdle?.() === false;
1375
+ // Decide whether this submission ever starts its OWN turn from the SAME
1376
+ // pre-dispatch snapshot: a plain prompt accepted while another turn
1377
+ // streams is queued as STEERING and consumed inside the current run — it
1378
+ // emits no agent_start, so its pending entry would be wrongly consumed
1379
+ // (and its connection associated as owner) by a later agent-initiated
1380
+ // monitor/cron turn (review thread P1). A follow-up is ALWAYS queued
1381
+ // (never started inline): its ownership entry is created only when the
1382
+ // queued follow-up is actually promoted to a run (review thread P1).
1383
+ // Skills always start their own invocation; a plain prompt starts one
1384
+ // only when idle at dispatch time.
1385
+ const startsOwnTurn = kind === "skill" || (kind === "prompt" && !alwaysQueued && !queuedAtDispatch);
1091
1386
  try {
1092
1387
  const submission = Promise.resolve(
1093
1388
  run({
1094
- onPreflightAccepted: () => void accept().catch(error => preflight.reject(error)),
1389
+ onPreflightAccepted: () => void accept().catch(() => undefined),
1095
1390
  onPreflightAcceptCommit: accept,
1391
+ // A queued submission (busy-accepted steering, or a follow-up) that
1392
+ // is later PROMOTED to its own run needs its pending ownership entry
1393
+ // created at promotion so the submitting connection can
1394
+ // terminal-abort that turn (review threads P1/P2).
1395
+ onQueuedPromoted: () => onPromotedTurn?.(kind, correlation, requesterConnectionId),
1396
+ queuedAtDispatch,
1096
1397
  }),
1097
1398
  );
1098
1399
  void submission.then(
@@ -1113,7 +1414,7 @@ function createControlSurface(
1113
1414
  return;
1114
1415
  }
1115
1416
  if (allowCompletionFallback) {
1116
- void accept().catch(error => preflight.reject(error));
1417
+ void accept().catch(() => undefined);
1117
1418
  return;
1118
1419
  }
1119
1420
  settled = true;
@@ -1127,8 +1428,12 @@ function createControlSurface(
1127
1428
  if (settled) {
1128
1429
  // The submission promise rejects after preflight acceptance only when the
1129
1430
  // work itself is over (provider stream interrupt, abort, queue failure).
1130
- // Every kind must terminalize here or the record stays non-terminal
1131
- // forever; `noteTransition` ignores an already-terminal record.
1431
+ // The accepted run never started (agent_start never fired), so its pending
1432
+ // entry must be retired — otherwise a later agent-initiated
1433
+ // monitor/cron turn's agent_start would shift the stale entry and
1434
+ // associate the failed submission's connection as owner (review
1435
+ // thread P1).
1436
+ retirePendingOwner?.(correlation);
1132
1437
  void reconciliation.noteTransition(kind, correlation, { type: "agent_failed", error });
1133
1438
  return;
1134
1439
  }
@@ -1146,22 +1451,895 @@ function createControlSurface(
1146
1451
  } catch (error) {
1147
1452
  if (!accepted) reconciliation.release(kind, retainedClientRef);
1148
1453
  throw error;
1454
+ } finally {
1455
+ requesterPreflights.delete(cancelPreflight);
1456
+ if (requesterPreflights.size === 0) pendingPreflights.delete(sdkControlRequesterContext.getStore() ?? "");
1457
+ }
1458
+ };
1459
+ const terminalAbort = async (
1460
+ input: { mode: "terminal"; scope?: "turn" | "owned" },
1461
+ idempotencyKey?: string,
1462
+ ): Promise<unknown> => {
1463
+ const scope = input.scope === "owned" ? "owned" : "turn";
1464
+ // Capture the steering snapshot at ADMISSION (before any durable
1465
+ // transaction): client steering admitted while the abort is in flight
1466
+ // classifies as post-snapshot and is preserved at abortPromptAndWait
1467
+ // (review thread P1).
1468
+ const steeringSnapshotToken = terminalAbortSeams?.captureTerminalAbortSteeringSnapshot?.();
1469
+ let steeringSnapshotConsumed = false;
1470
+ const terminalReservationLimit =
1471
+ terminalAbortSeams?.maxDurableTerminalReservationsForTests ?? MAX_DURABLE_TERMINAL_RESERVATIONS;
1472
+ try {
1473
+ // Hash the EXACT response payload this abort will return: the durable row
1474
+ // stores it at finalization so the response-state advance requires
1475
+ // equality instead of trusting a non-pending placeholder (review thread P2).
1476
+ const hashResult = (value: unknown): string =>
1477
+ crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex");
1478
+ const store = reconciliation.store;
1479
+ if (!store?.path || !terminalAbortSeams) {
1480
+ return {
1481
+ ok: true,
1482
+ selection: scope,
1483
+ turn: "no_store",
1484
+ terminal: "terminal_no_effect",
1485
+ };
1486
+ }
1487
+ const keyHash =
1488
+ typeof idempotencyKey === "string"
1489
+ ? crypto.createHash("sha256").update(idempotencyKey).digest("hex")
1490
+ : undefined;
1491
+ const inputHash = crypto
1492
+ .createHash("sha256")
1493
+ .update(JSON.stringify({ mode: "terminal", scope }))
1494
+ .digest("hex");
1495
+ const stored = (record: SdkOnlyTerminalScopeRecord | SdkOnlyEvictedTerminalKeyEntry) => ({
1496
+ responseState: record.responseState ?? "pending",
1497
+ responsePayloadHash: record.responsePayloadHash ?? inputHash,
1498
+ terminalPublished: record.terminalPublished === true,
1499
+ });
1500
+ // The exact response a same-key retry delivers appends the replay envelope
1501
+ // (and, for uncertainty, the replay reason). The delivery hash check
1502
+ // requires exact equality, so the durable row must store BOTH the original
1503
+ // response hash (first write) and the replay-shaped hash (retry write) or a
1504
+ // successfully written replay could never advance a pending row to sent
1505
+ // (review thread P2).
1506
+ const replayShapedHash = (
1507
+ record: SdkOnlyTerminalScopeRecord,
1508
+ result: Record<string, unknown>,
1509
+ payloadHash: string,
1510
+ ): string =>
1511
+ crypto
1512
+ .createHash("sha256")
1513
+ .update(
1514
+ JSON.stringify({
1515
+ ...result,
1516
+ ...(result.turn === "uncertain" && typeof result.reason === "string"
1517
+ ? { reason: "replay_uncertain" }
1518
+ : {}),
1519
+ replay: {
1520
+ responseState: record.responseState ?? "pending",
1521
+ responsePayloadHash: payloadHash,
1522
+ terminalPublished: record.terminalPublished === true,
1523
+ },
1524
+ }),
1525
+ )
1526
+ .digest("hex");
1527
+ const replay = (): unknown => {
1528
+ const scopes = store.snapshotTerminalScopes();
1529
+ const existing = keyHash ? scopes.find(record => record.idempotencyKeyHash === keyHash) : undefined;
1530
+ if (existing) {
1531
+ if (existing.idempotencyInputHash !== inputHash)
1532
+ throw Object.assign(new Error("Idempotency key was reused with different input."), {
1533
+ code: "idempotency_conflict",
1534
+ });
1535
+ const persisted = stored(existing);
1536
+ if (existing.turnDisposition === "stopped")
1537
+ return {
1538
+ ok: true,
1539
+ selection: scope,
1540
+ turn: "stopped",
1541
+ ...(scope === "owned"
1542
+ ? {
1543
+ ownedWork: existing.ownedWorkDisposition === "stopped" ? "stopped" : "uncertain",
1544
+ automaticDelivery: "none",
1545
+ resumeOnOwnedCompletion: false,
1546
+ }
1547
+ : { ownedWork: "left_running", automaticDelivery: "enabled", resumeOnOwnedCompletion: true }),
1548
+ replay: persisted,
1549
+ };
1550
+ if (existing.turnDisposition === "no_effect")
1551
+ return {
1552
+ ok: true,
1553
+ selection: scope,
1554
+ turn: "no_active_turn",
1555
+ terminal: "terminal_no_effect",
1556
+ replay: persisted,
1557
+ };
1558
+ if (existing.turnDisposition === "no_effect_marker_failure")
1559
+ // The initial marker write failed before any destructive work;
1560
+ // replay the SAME no_effect result the request returned, never
1561
+ // a no_active_turn fabrication (review thread P2).
1562
+ return {
1563
+ ok: true,
1564
+ selection: scope,
1565
+ turn: "no_effect",
1566
+ terminal: "terminal_no_effect",
1567
+ replay: persisted,
1568
+ };
1569
+ if (existing.turnDisposition === "no_effect_reserved")
1570
+ // A no-effect reservation that may still transition to active: a
1571
+ // duplicate must never claim no_active_turn over a provisional row
1572
+ // (review thread P2).
1573
+ return {
1574
+ ok: true,
1575
+ selection: scope,
1576
+ turn: "uncertain",
1577
+ ownedWork: scope === "turn" ? "left_running" : "uncertain",
1578
+ automaticDelivery: scope === "turn" ? "enabled" : "none",
1579
+ resumeOnOwnedCompletion: scope === "turn",
1580
+ reason: "reservation_in_flight",
1581
+ replay: persisted,
1582
+ };
1583
+ return {
1584
+ ok: true,
1585
+ selection: scope,
1586
+ turn: "uncertain",
1587
+ ownedWork: scope === "turn" ? "left_running" : "uncertain",
1588
+ automaticDelivery: scope === "turn" ? "enabled" : "none",
1589
+ resumeOnOwnedCompletion: scope === "turn",
1590
+ reason: existing.turnDisposition === "pending" ? "replay_pending" : "replay_uncertain",
1591
+ replay: persisted,
1592
+ };
1593
+ }
1594
+ if (keyHash) {
1595
+ const tombstone = store.snapshotTerminalKeys().find(record => record.keyHash === keyHash);
1596
+ if (tombstone) {
1597
+ if (tombstone.inputHash !== inputHash)
1598
+ throw Object.assign(new Error("Idempotency key was reused with different input."), {
1599
+ code: "idempotency_conflict",
1600
+ });
1601
+ return tombstone.turnDisposition === "stopped"
1602
+ ? {
1603
+ ok: true,
1604
+ selection: scope,
1605
+ turn: "stopped",
1606
+ ...(scope === "owned"
1607
+ ? {
1608
+ ownedWork: tombstone.ownedWorkDisposition === "stopped" ? "stopped" : "uncertain",
1609
+ automaticDelivery: "none",
1610
+ resumeOnOwnedCompletion: false,
1611
+ }
1612
+ : {
1613
+ ownedWork: "left_running",
1614
+ automaticDelivery: "enabled",
1615
+ resumeOnOwnedCompletion: true,
1616
+ }),
1617
+ replay: stored(tombstone),
1618
+ }
1619
+ : tombstone.turnDisposition === "no_effect"
1620
+ ? {
1621
+ ok: true,
1622
+ selection: scope,
1623
+ turn: "no_active_turn",
1624
+ terminal: "terminal_no_effect",
1625
+ replay: stored(tombstone),
1626
+ }
1627
+ : tombstone.turnDisposition === "no_effect_marker_failure"
1628
+ ? {
1629
+ ok: true,
1630
+ selection: scope,
1631
+ turn: "no_effect",
1632
+ terminal: "terminal_no_effect",
1633
+ replay: stored(tombstone),
1634
+ }
1635
+ : {
1636
+ ok: true,
1637
+ selection: scope,
1638
+ turn: "uncertain",
1639
+ ownedWork: scope === "turn" ? "left_running" : "uncertain",
1640
+ automaticDelivery: scope === "turn" ? "enabled" : "none",
1641
+ resumeOnOwnedCompletion: scope === "turn",
1642
+ replay: stored(tombstone),
1643
+ };
1644
+ }
1645
+ }
1646
+ return undefined;
1647
+ };
1648
+ const prior = replay();
1649
+ if (prior !== undefined) return prior;
1650
+ const writeNoEffect = async (markerFailure = false): Promise<"ok" | "conflict"> => {
1651
+ try {
1652
+ await store.transactTerminalState(state => {
1653
+ // Atomic recheck: a concurrent request may have committed a
1654
+ // DIFFERENT input under this key after the earlier snapshot
1655
+ // check; appending a second same-key row would make later
1656
+ // replay's .find() by key hash ambiguous (review thread P2).
1657
+ const conflicting = state.scopes.find(record => keyHash && record.idempotencyKeyHash === keyHash);
1658
+ if (conflicting && conflicting.idempotencyInputHash !== inputHash)
1659
+ throw new SdkOnlyIdempotencyConflictError();
1660
+ // A SAME-input live row is durable replay authority (the original
1661
+ // in-flight abort's marker): never replace it with a no-effect
1662
+ // reservation, or the successful abort would replay later as
1663
+ // no_active_turn. Leave the store unchanged and let the caller
1664
+ // re-run the replay snapshot (review thread P2).
1665
+ if (conflicting) {
1666
+ existingReplay = conflicting;
1667
+ return { scopes: state.scopes, keys: state.keys };
1668
+ }
1669
+ // A concurrent admission may ALSO have evicted a same-key row into
1670
+ // the tombstone collection after this request's snapshot; recheck
1671
+ // keys so a different input can never install a fresh marker over
1672
+ // existing durable replay authority (review thread P2). A
1673
+ // same-input tombstone already carries the reservation: leave the
1674
+ // store unchanged and replay it.
1675
+ const tombstone = state.keys.find(record => keyHash && record.keyHash === keyHash);
1676
+ if (tombstone) {
1677
+ if (tombstone.inputHash !== inputHash) throw new SdkOnlyIdempotencyConflictError();
1678
+ existingReplay = tombstone;
1679
+ return { scopes: state.scopes, keys: state.keys };
1680
+ }
1681
+ const preBound: SdkOnlyTerminalScopeRecord[] = [
1682
+ ...state.scopes.filter(record => !(keyHash && record.idempotencyKeyHash === keyHash)),
1683
+ {
1684
+ selection: scope,
1685
+ ...(keyHash ? { idempotencyKeyHash: keyHash, idempotencyInputHash: inputHash } : {}),
1686
+ // A marker-failure reservation must replay as the SAME
1687
+ // no_effect result it was returned with, so one idempotency
1688
+ // key can never produce no_effect first and no_active_turn
1689
+ // after eviction/restart (review thread P2). The idle-abort
1690
+ // path writes a TRANSITIONAL reserved disposition: the
1691
+ // requester's prompt may become active while the reservation
1692
+ // is awaited, and a duplicate must never claim no_active_turn
1693
+ // over a provisional row — the reserved row is finalized to
1694
+ // plain no_effect only when the recheck confirms no active
1695
+ // turn (review thread P2).
1696
+ turnDisposition: markerFailure
1697
+ ? "no_effect_marker_failure"
1698
+ : keyHash
1699
+ ? "no_effect_reserved"
1700
+ : "no_effect",
1701
+ ownedWorkDisposition: "not_requested",
1702
+ automaticDeliveryDisposition: scope === "turn" ? "enabled" : "none",
1703
+ resumeOnOwnedCompletion: scope === "turn",
1704
+ turnContinuationFence: {
1705
+ state: "retained",
1706
+ abortedAttemptEpoch: 0,
1707
+ blockedContinuationIds: [],
1708
+ predecessorTombstones: [],
1709
+ ownedCompletionPolicy: scope === "turn" ? "enabled" : "disabled",
1710
+ },
1711
+ responseState: "pending",
1712
+ // marker_failure rows are FINAL as written (the abort returns the
1713
+ // public no_effect disposition immediately, no later
1714
+ // finalization), so store the public payload hash; idle
1715
+ // reservations are finalized by finalizeNoEffectReservation
1716
+ // (review thread P2). The replay-shaped hash is stored too so a
1717
+ // same-key retry's metadata-bearing replay can still advance the
1718
+ // row on delivery (review thread P2).
1719
+ responsePayloadHash: markerFailure
1720
+ ? hashResult({
1721
+ ok: true,
1722
+ selection: scope,
1723
+ turn: "no_effect",
1724
+ terminal: "terminal_no_effect",
1725
+ })
1726
+ : inputHash,
1727
+ replayPayloadHash: markerFailure
1728
+ ? hashResult({
1729
+ ok: true,
1730
+ selection: scope,
1731
+ turn: "no_effect",
1732
+ terminal: "terminal_no_effect",
1733
+ replay: {
1734
+ responseState: "pending",
1735
+ responsePayloadHash: hashResult({
1736
+ ok: true,
1737
+ selection: scope,
1738
+ turn: "no_effect",
1739
+ terminal: "terminal_no_effect",
1740
+ }),
1741
+ terminalPublished: false,
1742
+ },
1743
+ })
1744
+ : undefined,
1745
+ acceptedAt: Date.now(),
1746
+ },
1747
+ ];
1748
+ return boundTerminalRetentionState(state.keys, preBound, terminalReservationLimit);
1749
+ });
1750
+ return "ok";
1751
+ } catch (error) {
1752
+ if (error instanceof SdkOnlyIdempotencyConflictError) return "conflict";
1753
+ throw error;
1754
+ }
1755
+ };
1756
+ // Finalize THIS abort's transitional no_effect_reserved reservation to
1757
+ // plain no_effect once the recheck confirms there is no active turn to
1758
+ // stop: a later same-key retry then replays the deterministic
1759
+ // no_active_turn result instead of reservation_in_flight uncertainty
1760
+ // (review thread P2). Only OUR row (exact key+input, still reserved) is
1761
+ // touched — a concurrent transition that already replaced it is left
1762
+ // alone. The EXACT final response payload hash is stored so the
1763
+ // response-state advance can require equality instead of trusting a
1764
+ // non-pending placeholder (review thread P2).
1765
+ const finalizeNoEffectReservation = async (result: {
1766
+ ok: boolean;
1767
+ selection: string;
1768
+ turn: string;
1769
+ terminal: string;
1770
+ }): Promise<void> => {
1771
+ if (!keyHash) return;
1772
+ const payloadHash = hashResult(result);
1773
+ // The same-key retry delivers the metadata-bearing replay envelope;
1774
+ // store its hash too so the retry's written response can advance the
1775
+ // finalized row (review thread P2).
1776
+ const replayPayloadHash = hashResult({
1777
+ ...result,
1778
+ replay: { responseState: "pending", responsePayloadHash: payloadHash, terminalPublished: false },
1779
+ });
1780
+ try {
1781
+ await store.transactTerminalState(state => {
1782
+ const scopes: SdkOnlyTerminalScopeRecord[] = state.scopes.map(record =>
1783
+ record.idempotencyKeyHash === keyHash &&
1784
+ record.idempotencyInputHash === inputHash &&
1785
+ record.turnDisposition === "no_effect_reserved"
1786
+ ? {
1787
+ ...record,
1788
+ turnDisposition: "no_effect",
1789
+ responsePayloadHash: payloadHash,
1790
+ replayPayloadHash,
1791
+ }
1792
+ : record,
1793
+ );
1794
+ // Finalized reservations become evictable completed rows: apply
1795
+ // the SAME bounded retention as writeNoEffect so a burst of idle
1796
+ // aborts cannot grow the document (review thread P2).
1797
+ return boundTerminalRetentionState(state.keys, scopes, terminalReservationLimit);
1798
+ });
1799
+ } catch {
1800
+ // Best-effort: the row stays reserved (replays as uncertainty)
1801
+ // rather than failing the abort (review thread P2).
1802
+ }
1803
+ };
1804
+ // Finalize pending markers through the SAME bounded retention as the
1805
+ // admission writes: mapping pending rows to completed dispositions
1806
+ // (uncertain/stopped) must evict the oldest completed rows and retain
1807
+ // tombstones, or a burst of concurrent distinct-key aborts of one slow
1808
+ // turn leaves an arbitrarily large reconciliation document (review
1809
+ // thread P2).
1810
+ const transactBoundedTerminalScopes = async (
1811
+ mutate: (scopes: SdkOnlyTerminalScopeRecord[]) => SdkOnlyTerminalScopeRecord[],
1812
+ ): Promise<void> => {
1813
+ await store.transactTerminalState(state => {
1814
+ return boundTerminalRetentionState(state.keys, mutate(state.scopes), terminalReservationLimit);
1815
+ });
1816
+ };
1817
+ let handle = terminalAbortSeams.getActivePromptHandle();
1818
+ let epoch = terminalAbortSeams.getTerminalTurnEpoch();
1819
+ // Set when the no-effect reservation found an existing SAME-input row or
1820
+ // tombstone: the caller re-runs the replay snapshot instead of returning
1821
+ // a no-active result over the original row's replay authority (review
1822
+ // thread P2).
1823
+ let existingReplay: SdkOnlyTerminalScopeRecord | SdkOnlyEvictedTerminalKeyEntry | undefined;
1824
+ // Read the requester's preflight bucket WITHOUT creating one: an
1825
+ // abort-only request that returns via an early replay/marker path (which
1826
+ // never runs the bucket cleanup) must not leave an empty per-connection
1827
+ // entry behind — reconnecting clients would otherwise accumulate buckets
1828
+ // indefinitely (review thread P2).
1829
+ const requesterBucketKey = sdkControlRequesterContext.getStore() ?? "";
1830
+ const requesterPreflights = pendingPreflights.get(requesterBucketKey);
1831
+ // Snapshot the requester's preflight callbacks AT ADMISSION: a successor
1832
+ // turn.prompt pipelined by the same connection while the abort awaits
1833
+ // (e.g. the reconciliation transaction) must never be cancelled as part
1834
+ // of this abort — only the callbacks present when it was admitted are
1835
+ // its to cancel, mirroring the full-bus capture (review thread P1).
1836
+ const admittedRequesterPreflights = new Set(requesterPreflights ?? []);
1837
+ const cancelRequesterPreflights = () => {
1838
+ for (const cancel of [...admittedRequesterPreflights]) cancel();
1839
+ if (admittedRequesterPreflights.size > 0) {
1840
+ // Remove the admitted callbacks from the live set so a preflight
1841
+ // added by a LATER submission is untouched by this abort (and the
1842
+ // bucket cleanup below reflects what actually remains).
1843
+ for (const cancel of admittedRequesterPreflights) requesterPreflights?.delete(cancel);
1844
+ // The seam cancels the SESSION-WIDE preflight controller, so only
1845
+ // invoke it when NO OTHER connection has a pending admission: a
1846
+ // queued requester's abort must reject its own wrapper callback
1847
+ // (above) without cancelling another connection's active
1848
+ // preflight — the aborting requester's admission is already
1849
+ // rejected, so the session-wide abort is never required for it
1850
+ // (review thread P1).
1851
+ const otherConnectionPreflights = [...pendingPreflights.entries()].some(
1852
+ ([bucket, callbacks]) => bucket !== requesterBucketKey && callbacks.size > 0,
1853
+ );
1854
+ if (!otherConnectionPreflights) terminalAbortSeams.cancelPendingPreflightForTerminalAbort();
1855
+ }
1856
+ if (requesterPreflights && requesterPreflights.size === 0) {
1857
+ // Abort-only lookups must not retain an empty per-connection bucket:
1858
+ // connections are ephemeral UUIDs and nothing else removes the
1859
+ // bucket when no prompt submission ever registered on it, so a
1860
+ // long-lived runtime handling aborts from reconnecting clients
1861
+ // would accumulate one entry per connection forever (review
1862
+ // thread P2).
1863
+ if (pendingPreflights.get(requesterBucketKey) === requesterPreflights)
1864
+ pendingPreflights.delete(requesterBucketKey);
1865
+ }
1866
+ };
1867
+ if (!handle || epoch === undefined) {
1868
+ if ((await writeNoEffect()) === "conflict") {
1869
+ throw Object.assign(new Error("Idempotency key was reused with different input."), {
1870
+ code: "idempotency_conflict",
1871
+ });
1872
+ }
1873
+ // A SAME-input row or tombstone existed while the reservation
1874
+ // awaited the store: it is durable replay authority, so replay its
1875
+ // stored result (stopped/pending/uncertain/no_effect) instead of
1876
+ // returning no_active_turn over it (review thread P2).
1877
+ if (existingReplay) {
1878
+ const replayed = replay();
1879
+ if (replayed !== undefined) return replayed;
1880
+ }
1881
+ cancelRequesterPreflights();
1882
+ // A prompt for this requester may have become ACTIVE while the
1883
+ // reservation awaited the filesystem transaction: its submit()
1884
+ // cleanup already removed the preflight callback, so cancelling here
1885
+ // saw an empty set and never reached the session cancellation seam —
1886
+ // and the durable no-effect row would prevent a same-key retry from
1887
+ // ever stopping the now-running prompt. Re-read the active prompt and
1888
+ // fall through to ACTIVE terminalization when it won the race
1889
+ // (review thread P1); the active-turn marker write replaces the
1890
+ // no-effect reservation.
1891
+ const recheckedHandle = terminalAbortSeams.getActivePromptHandle();
1892
+ const recheckedEpoch = terminalAbortSeams.getTerminalTurnEpoch();
1893
+ if (!recheckedHandle || recheckedEpoch === undefined) {
1894
+ // No prompt won the race: finalize the reserved row so a later
1895
+ // same-key retry replays this deterministic no_active_turn result
1896
+ // (review thread P2).
1897
+ const noActiveTurnResult = {
1898
+ ok: true,
1899
+ selection: scope,
1900
+ turn: "no_active_turn",
1901
+ terminal: "terminal_no_effect",
1902
+ };
1903
+ await finalizeNoEffectReservation(noActiveTurnResult);
1904
+ return noActiveTurnResult;
1905
+ }
1906
+ handle = recheckedHandle;
1907
+ epoch = recheckedEpoch;
1908
+ // The requester's OWN prompt won the race: rebind the snapshot
1909
+ // to the current turn so the settlement classifies steering
1910
+ // admitted since admission as post-snapshot (review thread P1).
1911
+ if (steeringSnapshotToken !== undefined) {
1912
+ terminalAbortSeams?.rebindTerminalAbortSteeringSnapshot?.(steeringSnapshotToken);
1913
+ }
1914
+ }
1915
+ // Requester ownership: the active prompt belongs to the SDK connection
1916
+ // that accepted it. Another connection's terminal abort must not stop it
1917
+ // (review thread P1) — no-op with an idle reservation, mirroring the
1918
+ // per-connection selection of the full bus path. The owner is re-read
1919
+ // through the seam when provided (deterministic tests) and otherwise
1920
+ // from the runtime-tracked accepting connection.
1921
+ const abortingConnectionId = sdkControlRequesterContext.getStore();
1922
+ const currentOwnerConnectionIds = (): ReadonlySet<string> => {
1923
+ const seam = terminalAbortSeams.getActivePromptOwnerConnectionId?.();
1924
+ // The seam reports a single deterministic owner (test harnesses); the
1925
+ // runtime holder may carry every connection whose follow-up was
1926
+ // promoted into the current run (review thread P2).
1927
+ return seam === undefined ? (activePromptOwner.connectionIds ?? new Set<string>()) : new Set([seam]);
1928
+ };
1929
+ // FAIL CLOSED unless the active handle is POSITIVELY associated with the
1930
+ // requester: an undefined owner (agent-initiated monitor/cron turn, or
1931
+ // cleared after a terminal lifecycle boundary) authorizes no client, and
1932
+ // a stale prior owner authorizes only that old client — never a later
1933
+ // turn it did not submit (review thread P1).
1934
+ if (handle && (abortingConnectionId === undefined || !currentOwnerConnectionIds().has(abortingConnectionId))) {
1935
+ if ((await writeNoEffect()) === "conflict") {
1936
+ throw Object.assign(new Error("Idempotency key was reused with different input."), {
1937
+ code: "idempotency_conflict",
1938
+ });
1939
+ }
1940
+ if (existingReplay) {
1941
+ const replayed = replay();
1942
+ if (replayed !== undefined) return replayed;
1943
+ }
1944
+ cancelRequesterPreflights();
1945
+ // A prompt for this requester may have become ACTIVE while the
1946
+ // no-effect reservation awaited the filesystem transaction: the
1947
+ // owner-mismatch decision was taken against the OLD owner, and the
1948
+ // newly active submission already removed its preflight callback, so
1949
+ // cancelling here saw an empty set and never reached the session
1950
+ // cancellation seam — and the durable no-effect row would prevent a
1951
+ // same-key retry from ever stopping the now-running prompt. Re-read
1952
+ // the active prompt, its epoch, and its owner; when the ABORTING
1953
+ // connection now owns the turn, fall through to ACTIVE
1954
+ // terminalization (the active-turn marker write replaces the
1955
+ // no-effect reservation) (review thread P1).
1956
+ const recheckedHandle = terminalAbortSeams.getActivePromptHandle();
1957
+ const recheckedEpoch = terminalAbortSeams.getTerminalTurnEpoch();
1958
+ const recheckedOwners = currentOwnerConnectionIds();
1959
+ if (
1960
+ !recheckedHandle ||
1961
+ recheckedEpoch === undefined ||
1962
+ abortingConnectionId === undefined ||
1963
+ !recheckedOwners.has(abortingConnectionId)
1964
+ ) {
1965
+ // The turn is still not the aborting connection's: finalize the
1966
+ // reserved row so a later same-key retry replays no_active_turn
1967
+ // deterministically (review thread P2).
1968
+ const noActiveTurnResult = {
1969
+ ok: true,
1970
+ selection: scope,
1971
+ turn: "no_active_turn",
1972
+ terminal: "terminal_no_effect",
1973
+ };
1974
+ await finalizeNoEffectReservation(noActiveTurnResult);
1975
+ return noActiveTurnResult;
1976
+ }
1977
+ handle = recheckedHandle;
1978
+ epoch = recheckedEpoch;
1979
+ // The requester's OWN prompt won the race: rebind the snapshot
1980
+ // to the current turn so the settlement classifies steering
1981
+ // admitted since admission as post-snapshot (review thread P1).
1982
+ if (steeringSnapshotToken !== undefined) {
1983
+ terminalAbortSeams?.rebindTerminalAbortSteeringSnapshot?.(steeringSnapshotToken);
1984
+ }
1985
+ }
1986
+ let pendingReplay: SdkOnlyTerminalScopeRecord | undefined;
1987
+ let tombstoneReplay: SdkOnlyEvictedTerminalKeyEntry | undefined;
1988
+ try {
1989
+ await store.transactTerminalState(state => {
1990
+ // Atomic recheck (same rationale as writeNoEffect): never wipe a
1991
+ // row a concurrent request committed under this key (review thread
1992
+ // P2). A same-input PENDING row is an in-flight duplicate admitted
1993
+ // past the snapshot (dispatch-cache eviction): replay it instead of
1994
+ // replacing the marker, so the duplicate cannot race terminalization
1995
+ // and flip the row to uncertain while the original returns stopped
1996
+ // (or execute the abort twice).
1997
+ const conflicting = state.scopes.find(record => keyHash && record.idempotencyKeyHash === keyHash);
1998
+ if (conflicting) {
1999
+ if (conflicting.idempotencyInputHash !== inputHash) throw new SdkOnlyIdempotencyConflictError();
2000
+ if (conflicting.turnDisposition === "pending") {
2001
+ pendingReplay = conflicting;
2002
+ return { scopes: state.scopes, keys: state.keys };
2003
+ }
2004
+ }
2005
+ // A concurrent admission may ALSO have evicted a same-key row into
2006
+ // the tombstone collection after this request's snapshot. Recheck
2007
+ // keys so a different input can never install a fresh marker over
2008
+ // existing durable replay authority; a same-input tombstone already
2009
+ // carries replay authority, so never install a second marker here
2010
+ // (review thread P2).
2011
+ const tombstone = state.keys.find(record => keyHash && record.keyHash === keyHash);
2012
+ if (tombstone) {
2013
+ if (tombstone.inputHash !== inputHash) throw new SdkOnlyIdempotencyConflictError();
2014
+ tombstoneReplay = tombstone;
2015
+ return { scopes: state.scopes, keys: state.keys };
2016
+ }
2017
+ const preBound: SdkOnlyTerminalScopeRecord[] = [
2018
+ ...state.scopes.filter(record => !(keyHash && record.idempotencyKeyHash === keyHash)),
2019
+ {
2020
+ selection: scope,
2021
+ ...(keyHash ? { idempotencyKeyHash: keyHash, idempotencyInputHash: inputHash } : {}),
2022
+ turnDisposition: "pending",
2023
+ terminalPublished: false,
2024
+ ownedWorkDisposition: "not_requested",
2025
+ automaticDeliveryDisposition: scope === "turn" ? "enabled" : "none",
2026
+ resumeOnOwnedCompletion: scope === "turn",
2027
+ turnContinuationFence: {
2028
+ state: "retained",
2029
+ abortedAttemptEpoch: epoch,
2030
+ blockedContinuationIds: [],
2031
+ predecessorTombstones: [],
2032
+ ownedCompletionPolicy: scope === "turn" ? "enabled" : "disabled",
2033
+ },
2034
+ responseState: "pending",
2035
+ responsePayloadHash: inputHash,
2036
+ acceptedAt: Date.now(),
2037
+ },
2038
+ ];
2039
+ return boundTerminalRetentionState(state.keys, preBound, terminalReservationLimit);
2040
+ });
2041
+ } catch (error) {
2042
+ if (error instanceof SdkOnlyIdempotencyConflictError) {
2043
+ throw Object.assign(new Error("Idempotency key was reused with different input."), {
2044
+ code: "idempotency_conflict",
2045
+ });
2046
+ }
2047
+ // Marker persistence failed before any destructive work: reserve a
2048
+ // distinct marker-failure disposition so replay returns the same
2049
+ // no_effect result (review thread P2).
2050
+ if ((await writeNoEffect(true)) === "conflict") {
2051
+ throw Object.assign(new Error("Idempotency key was reused with different input."), {
2052
+ code: "idempotency_conflict",
2053
+ });
2054
+ }
2055
+ if (existingReplay) {
2056
+ const replayed = replay();
2057
+ if (replayed !== undefined) return replayed;
2058
+ }
2059
+ return {
2060
+ ok: true,
2061
+ selection: scope,
2062
+ turn: "no_effect",
2063
+ terminal: "terminal_no_effect",
2064
+ };
2065
+ }
2066
+ if (pendingReplay) {
2067
+ // An in-flight duplicate of this exact key+input was already admitted;
2068
+ // replay its pending row WITHOUT touching the seam, so the duplicate
2069
+ // cannot abort the run a second time or race the terminalization.
2070
+ return {
2071
+ ok: true,
2072
+ selection: scope,
2073
+ turn: "uncertain",
2074
+ ownedWork: scope === "turn" ? "left_running" : "uncertain",
2075
+ automaticDelivery: scope === "turn" ? "enabled" : "none",
2076
+ resumeOnOwnedCompletion: scope === "turn",
2077
+ reason: "replay_pending",
2078
+ replay: {
2079
+ responseState: pendingReplay.responseState,
2080
+ responsePayloadHash: pendingReplay.responsePayloadHash,
2081
+ terminalPublished: pendingReplay.terminalPublished === true,
2082
+ },
2083
+ };
2084
+ }
2085
+ if (tombstoneReplay) {
2086
+ // The key gained durable replay authority via an eviction tombstone
2087
+ // while this request was in flight; never install a second marker or
2088
+ // run the abort. Re-run the replay snapshot (the tombstone is now
2089
+ // visible) so the STORED result is returned (review thread P2).
2090
+ const replayed = replay();
2091
+ if (replayed !== undefined) return replayed;
2092
+ }
2093
+ // A new prompt won the race while the marker was being persisted. Never
2094
+ // apply this request to that later handle; replay remains a safe uncertainty.
2095
+ if (
2096
+ terminalAbortSeams.getActivePromptHandle() !== handle ||
2097
+ terminalAbortSeams.getTerminalTurnEpoch() !== epoch
2098
+ ) {
2099
+ const result = {
2100
+ ok: true,
2101
+ selection: scope,
2102
+ turn: "uncertain",
2103
+ ownedWork: scope === "turn" ? "left_running" : "uncertain",
2104
+ automaticDelivery: scope === "turn" ? "enabled" : "none",
2105
+ resumeOnOwnedCompletion: scope === "turn",
2106
+ reason: "active_turn_changed",
2107
+ };
2108
+ const activeTurnPayloadHash = hashResult(result);
2109
+ await transactBoundedTerminalScopes(scopes =>
2110
+ scopes.map(record =>
2111
+ (keyHash
2112
+ ? record.idempotencyKeyHash === keyHash
2113
+ : record.turnContinuationFence.abortedAttemptEpoch === epoch) &&
2114
+ record.turnDisposition === "pending"
2115
+ ? {
2116
+ ...record,
2117
+ turnDisposition: "uncertain",
2118
+ responsePayloadHash: activeTurnPayloadHash,
2119
+ replayPayloadHash: replayShapedHash(record, result, activeTurnPayloadHash),
2120
+ terminalAt: Date.now(),
2121
+ }
2122
+ : record,
2123
+ ),
2124
+ );
2125
+ return result;
2126
+ }
2127
+ cancelRequesterPreflights();
2128
+ // Observe the correlated agent_end publication (AC 19) instead of
2129
+ // assuming it: the aborted run's lifecycle event is published
2130
+ // independently by emitLifecycle, so the durable stopped row must only
2131
+ // claim terminalPublished when the publication was actually observed
2132
+ // (review thread P2). Multiple concurrent aborts of the SAME turn (distinct
2133
+ // idempotency keys, same scope) are all admitted and all await the ONE
2134
+ // agent_end the turn emits, so every waiter is registered — a single slot
2135
+ // would resolve only the latest and leave the earlier abort to record a
2136
+ // false negative (review thread P2).
2137
+ const terminalPublication = Promise.withResolvers<boolean>();
2138
+ const removeTerminalPublicationWaiter = () => {
2139
+ const resolvers = terminalPublicationCapture?.resolvers;
2140
+ if (!resolvers) return;
2141
+ const index = resolvers.indexOf(terminalPublication.resolve);
2142
+ if (index >= 0) resolvers.splice(index, 1);
2143
+ };
2144
+ if (terminalPublicationCapture) {
2145
+ if (!terminalPublicationCapture.resolvers) terminalPublicationCapture.resolvers = [];
2146
+ terminalPublicationCapture.resolvers.push(terminalPublication.resolve);
2147
+ }
2148
+ let proof: { status: string; terminalScope?: unknown };
2149
+ try {
2150
+ proof = await terminalAbortSeams.abortPromptAndWaitWithTerminal(handle, {
2151
+ graceMs: 10_000,
2152
+ terminal: {
2153
+ scope,
2154
+ expectedEpoch: epoch,
2155
+ ...(steeringSnapshotToken !== undefined ? { steeringSnapshotToken } : {}),
2156
+ },
2157
+ });
2158
+ } catch {
2159
+ proof = { status: "unfenced" };
2160
+ }
2161
+ steeringSnapshotConsumed = proof.status === "settled";
2162
+ if (proof.status !== "settled" || proof.terminalScope === undefined) {
2163
+ removeTerminalPublicationWaiter();
2164
+ const result = {
2165
+ ok: true,
2166
+ selection: scope,
2167
+ turn: "uncertain",
2168
+ ownedWork: scope === "turn" ? "left_running" : "uncertain",
2169
+ automaticDelivery: scope === "turn" ? "enabled" : "none",
2170
+ resumeOnOwnedCompletion: scope === "turn",
2171
+ reason: "worker_unsettled",
2172
+ };
2173
+ const workerUnsettledPayloadHash = hashResult(result);
2174
+ await transactBoundedTerminalScopes(scopes =>
2175
+ scopes.map(record =>
2176
+ (keyHash
2177
+ ? record.idempotencyKeyHash === keyHash
2178
+ : record.turnContinuationFence.abortedAttemptEpoch === epoch) &&
2179
+ record.turnDisposition === "pending"
2180
+ ? {
2181
+ ...record,
2182
+ turnDisposition: "uncertain",
2183
+ ownedWorkDisposition: "uncertain",
2184
+ responsePayloadHash: workerUnsettledPayloadHash,
2185
+ replayPayloadHash: replayShapedHash(record, result, workerUnsettledPayloadHash),
2186
+ terminalAt: Date.now(),
2187
+ }
2188
+ : record,
2189
+ ),
2190
+ );
2191
+ return result;
2192
+ }
2193
+ // scope:"owned" must generation-verify and CANCEL the exact owned work
2194
+ // before reporting it stopped: abortPromptAndWaitWithTerminal only aborts
2195
+ // the foreground run and registers the disabled-delivery scope — a
2196
+ // background Bash/task/detached subagent would otherwise keep running
2197
+ // while the client receives stopped_owned (review thread P1).
2198
+ const ownedStopped = true;
2199
+ if (scope === "owned") {
2200
+ const terminalScope = proof.terminalScope as
2201
+ | { abortedAttemptEpoch?: number; lineageIdHash?: string }
2202
+ | undefined;
2203
+ const failOwnedUncertain = async (): Promise<unknown> => {
2204
+ removeTerminalPublicationWaiter();
2205
+ const result = {
2206
+ ok: true,
2207
+ selection: scope,
2208
+ turn: "uncertain",
2209
+ ownedWork: "uncertain",
2210
+ automaticDelivery: "none",
2211
+ resumeOnOwnedCompletion: false,
2212
+ reason: "owned_unsettled",
2213
+ };
2214
+ const ownedUnsettledPayloadHash = hashResult(result);
2215
+ await transactBoundedTerminalScopes(scopes =>
2216
+ scopes.map(record =>
2217
+ (keyHash
2218
+ ? record.idempotencyKeyHash === keyHash
2219
+ : record.turnContinuationFence.abortedAttemptEpoch === epoch) &&
2220
+ record.turnDisposition === "pending"
2221
+ ? {
2222
+ ...record,
2223
+ turnDisposition: "uncertain",
2224
+ ownedWorkDisposition: "uncertain",
2225
+ responsePayloadHash: ownedUnsettledPayloadHash,
2226
+ replayPayloadHash: replayShapedHash(record, result, ownedUnsettledPayloadHash),
2227
+ terminalAt: Date.now(),
2228
+ }
2229
+ : record,
2230
+ ),
2231
+ );
2232
+ return result;
2233
+ };
2234
+ if (
2235
+ !terminalScope ||
2236
+ terminalScope.abortedAttemptEpoch === undefined ||
2237
+ !terminalScope.lineageIdHash ||
2238
+ isOwnedAttemptRegistrationIncomplete(terminalScope.lineageIdHash, terminalScope.abortedAttemptEpoch)
2239
+ ) {
2240
+ // The attempt's registration set may be KNOWN incomplete (registry
2241
+ // saturation or an evicted in-flight binding): never claim
2242
+ // stopped_owned over an incomplete causal set.
2243
+ return await failOwnedUncertain();
2244
+ }
2245
+ const exactJobs = findOwnedRegistrationsForTurn(
2246
+ terminalScope.lineageIdHash,
2247
+ terminalScope.abortedAttemptEpoch,
2248
+ );
2249
+ if (exactJobs.length > 0) {
2250
+ // Resolve the manager from the ABORTING ENDPOINT captured on the
2251
+ // registrations — never the process-global last-created session,
2252
+ // which could cancel a foreign same-id job and report stopped_owned
2253
+ // while the aborting session's job keeps running (review thread P1).
2254
+ const endpointId = exactJobs[0]?.endpointId;
2255
+ const manager = AsyncJobManager.forEndpoint(endpointId) ?? AsyncJobManager.instance();
2256
+ if (!manager || (await settleOwnedWork(manager, exactJobs, 500)) !== "stopped") {
2257
+ return await failOwnedUncertain();
2258
+ }
2259
+ }
2260
+ }
2261
+ // The worker settled; await the correlated agent_end publication for a
2262
+ // bounded window and persist the OBSERVED result (review thread P2). A
2263
+ // publication that never lands (lifecycle listener absent, still
2264
+ // pending, or failed) yields observed=false — the durable row never
2265
+ // claims a terminal event reached clients unless it was actually
2266
+ // published.
2267
+ const observed = await Promise.race([
2268
+ terminalPublication.promise,
2269
+ Bun.sleep(SDK_ONLY_TERMINAL_PUBLICATION_WAIT_MS).then(() => false as const),
2270
+ ]);
2271
+ removeTerminalPublicationWaiter();
2272
+ const terminalPublished = observed === true;
2273
+ const result = {
2274
+ ok: true,
2275
+ selection: scope,
2276
+ turn: "stopped",
2277
+ ...(scope === "turn"
2278
+ ? { ownedWork: "left_running", automaticDelivery: "enabled", resumeOnOwnedCompletion: true }
2279
+ : {
2280
+ ownedWork: ownedStopped ? "stopped" : "uncertain",
2281
+ automaticDelivery: "none",
2282
+ resumeOnOwnedCompletion: false,
2283
+ }),
2284
+ };
2285
+ const payloadHash = crypto.createHash("sha256").update(JSON.stringify(result)).digest("hex");
2286
+ await transactBoundedTerminalScopes(scopes =>
2287
+ scopes.map(record =>
2288
+ (keyHash
2289
+ ? record.idempotencyKeyHash === keyHash
2290
+ : record.turnContinuationFence.abortedAttemptEpoch === epoch) && record.turnDisposition === "pending"
2291
+ ? {
2292
+ ...record,
2293
+ turnDisposition: "stopped",
2294
+ terminalPublished,
2295
+ ownedWorkDisposition:
2296
+ scope === "turn" ? "left_running" : ownedStopped ? "stopped" : "uncertain",
2297
+ responsePayloadHash: payloadHash,
2298
+ // The replay envelope carries the POST-CAS publication
2299
+ // flag; the replay-shaped hash must be computed from the
2300
+ // updated row or a written replay could never match it
2301
+ // (review thread P2).
2302
+ replayPayloadHash: replayShapedHash({ ...record, terminalPublished }, result, payloadHash),
2303
+ terminalAt: Date.now(),
2304
+ }
2305
+ : record,
2306
+ ),
2307
+ );
2308
+ return result;
2309
+ } finally {
2310
+ // A replay-only abort (or any pre-settlement failure) never consumed its
2311
+ // snapshot: discard it so a later real abort cannot consume the stale
2312
+ // entry and treat steering admitted since the replay as post-abort
2313
+ // (review thread P1).
2314
+ if (steeringSnapshotToken !== undefined && !steeringSnapshotConsumed) {
2315
+ terminalAbortSeams?.discardTerminalAbortSteeringSnapshot?.(steeringSnapshotToken);
2316
+ }
1149
2317
  }
1150
2318
  };
1151
2319
  return {
1152
- authorizeElevationClaim: (sdkId, input, capability) =>
1153
- elevationAuthorityToken !== undefined &&
1154
- verifyElevationCapability(elevationAuthorityToken, capability, sdkId, input),
1155
2320
  prompt: async (text, images, clientRef) =>
1156
- submit("prompt", clientRef, options =>
2321
+ submit("prompt", clientRef, ({ queuedAtDispatch, ...options }) =>
1157
2322
  api.sendUserMessage(
1158
2323
  typeof images === "undefined" ? text : ([{ type: "text", text }, ...(images as never[])] as never),
1159
- options,
2324
+ queuedAtDispatch ? { ...options, queuedAtDispatch: true } : options,
1160
2325
  ),
1161
2326
  ),
1162
- steer: async text => {
1163
- await api.sendUserMessage(text, { deliverAs: "steer" });
1164
- return { commandId: crypto.randomUUID(), accepted: true };
2327
+ steer: async (text, clientRef) => {
2328
+ const retainedClientRef = normalizeClientRef(clientRef);
2329
+ if (retainedClientRef === undefined) {
2330
+ const correlation = newCorrelation();
2331
+ await api.sendUserMessage(text, { deliverAs: "steer" });
2332
+ return { accepted: true, ...correlation };
2333
+ }
2334
+ const durable = steerReconciliation;
2335
+ const reservation = await durable.reserveSteer(retainedClientRef, text);
2336
+ if (reservation.replay) return { accepted: reservation.result.status === "accepted", ...reservation.result };
2337
+ try {
2338
+ await api.sendUserMessage(text, { deliverAs: "steer" });
2339
+ return { accepted: true, ...(await durable.settleSteer(retainedClientRef, "accepted")) };
2340
+ } catch (error) {
2341
+ return { accepted: false, ...(await durable.settleSteer(retainedClientRef, "rejected", error)) };
2342
+ }
1165
2343
  },
1166
2344
  followUp: async text =>
1167
2345
  submit(
@@ -1170,14 +2348,16 @@ function createControlSurface(
1170
2348
  options => api.sendUserMessage(text, { ...options, deliverAs: "followUp" }),
1171
2349
  undefined,
1172
2350
  false,
2351
+ // Follow-ups never start inline; ownership correlates at promotion.
1173
2352
  true,
1174
2353
  ),
1175
2354
  abort: () => {
1176
2355
  ctx.abort();
1177
2356
  return { aborted: true };
1178
2357
  },
2358
+ abortTerminal: terminalAbort,
1179
2359
  abortAndPrompt: async text => {
1180
- ctx.abort();
2360
+ await ctx.abort();
1181
2361
  return await submit("prompt", undefined, options => api.sendUserMessage(text, options));
1182
2362
  },
1183
2363
  answerAsk: unavailable("ask.answer"),
@@ -1271,7 +2451,11 @@ function createControlSurface(
1271
2451
  newSession: () => typed("session.new"),
1272
2452
  forkSession: () => typed("session.fork"),
1273
2453
  resumeSession: id => typed("session.resume", { id }),
1274
- closeSession: () => typed("session.close"),
2454
+ closeSession: capability =>
2455
+ typed(
2456
+ "session.close",
2457
+ capability === undefined ? {} : { [BROKER_RUNTIME_CLOSE_CAPABILITY_FIELD]: capability },
2458
+ ),
1275
2459
  switchSession: id => typed("session.switch", { id }),
1276
2460
  branchSession: entryId => typed("session.branch", { entryId }),
1277
2461
  renameSession: name => typed("session.rename", { name }),
@@ -1377,25 +2561,108 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
1377
2561
  revisions: RevisionStore;
1378
2562
  cursors: CursorRegistry;
1379
2563
  reconciliation: InvocationReconciliation;
1380
- pending: Array<{ kind: InvocationKind; correlation: InvocationCorrelation }>;
2564
+ steerReconciliation: KindAwareReconciliation;
2565
+ deadlineManager: PromptDeadlineManager;
2566
+ pending: Array<{
2567
+ kind: InvocationKind;
2568
+ correlation: InvocationCorrelation;
2569
+ connectionId: string | undefined;
2570
+ }>;
1381
2571
  registerBroker: () => Promise<void>;
1382
2572
  fenceGateResolutions: () => void;
1383
2573
  waitForGateResolutionQuiescence: () => Promise<void>;
1384
2574
  activeInvocation?: { kind: InvocationKind; correlation: InvocationCorrelation };
2575
+ drainedInvocations?: Array<{ kind: InvocationKind; correlation: InvocationCorrelation }>;
1385
2576
  disposeGate?: () => void;
1386
2577
  }
1387
2578
  | undefined;
2579
+ // Shared with the control surface's terminal abort: the correlated
2580
+ // agent_end publication capture (AC 19). terminalAbort installs a fresh
2581
+ // resolver before settling the abort; emitLifecycle resolves it with the
2582
+ // OBSERVED publication result when the aborted run's lifecycle event
2583
+ // lands, so the durable stopped row never claims terminalPublished without
2584
+ // observing it (review thread P2). Single slot: only the abort that
2585
+ // reaches the stopped path awaits it; a concurrent abort for the same turn
2586
+ // is settled by the durable marker transaction instead.
2587
+ const terminalPublicationCapture: { resolvers?: Array<(observed: boolean) => void> } = {};
2588
+ // Shared with the control surface: the SDK connection owning the currently
2589
+ // active prompt/skill turn. Cleared at every agent_end (terminal lifecycle
2590
+ // boundary) so a stale owner never authorizes an abort against a later
2591
+ // turn it did not submit, and never set for agent-initiated turns (review
2592
+ // thread P1).
2593
+ const activePromptOwnerHolder: { connectionIds?: Set<string> } = {};
1388
2594
  const emitLifecycle = async (type: "agent_start" | "agent_end", ctx: ExtensionContext): Promise<void> => {
1389
2595
  const current = active;
1390
2596
  if (!current) return;
1391
- if (type === "agent_start") current.activeInvocation = current.pending.shift();
1392
- await current.reconciliation.noteTransition(
1393
- current.activeInvocation?.kind ?? "prompt",
1394
- current.activeInvocation?.correlation,
1395
- { type },
1396
- );
1397
- current.runtime.emitEvent({ type, sessionId: ctx.sessionManager.getSessionId() });
1398
- if (type === "agent_end") current.activeInvocation = undefined;
2597
+ if (type === "agent_start") {
2598
+ // Drain EVERY entry admitted for this run: a continuation may promote
2599
+ // several follow-ups (each with its own requester correlation) into one
2600
+ // run, and each submitting connection must be able to terminal-abort it.
2601
+ // Entries are only created for submissions that actually start their own
2602
+ // turn (queued-while-streaming submissions never push), so a mid-prompt
2603
+ // continuation agent_start with an empty queue leaves the current owner
2604
+ // untouched (review thread P1).
2605
+ const drained = current.pending.splice(0);
2606
+ current.activeInvocation = drained[0];
2607
+ if (drained.length > 0) {
2608
+ const owners = new Set<string>();
2609
+ for (const entry of drained) if (entry.connectionId !== undefined) owners.add(entry.connectionId);
2610
+ activePromptOwnerHolder.connectionIds = owners;
2611
+ // A single run may drain several follow-ups promoted together; each
2612
+ // has its own durable record that must reach terminal state, so keep
2613
+ // the full batch for the transition pass below (review thread P1).
2614
+ current.drainedInvocations = drained.map(({ kind, correlation }) => ({ kind, correlation }));
2615
+ }
2616
+ if (current.activeInvocation?.kind === "prompt") {
2617
+ current.deadlineManager.onAccepted(current.activeInvocation.correlation);
2618
+ }
2619
+ }
2620
+ // Observe whether the lifecycle publication actually landed: a terminal
2621
+ // abort awaits this result so its durable row only claims
2622
+ // terminalPublished when the correlated agent_end event reached the
2623
+ // ring/broadcast (review thread P2). Reconciliation or event failure is
2624
+ // recorded as observed=false, never rethrown into the api handler.
2625
+ let observed = true;
2626
+ try {
2627
+ const transitions =
2628
+ current.drainedInvocations && current.drainedInvocations.length > 0
2629
+ ? current.drainedInvocations
2630
+ : current.activeInvocation
2631
+ ? [current.activeInvocation]
2632
+ : [];
2633
+ for (const invocation of transitions) {
2634
+ await current.reconciliation.noteTransition(invocation.kind, invocation.correlation, { type } as never);
2635
+ if ((type as string) === "agent_end" || (type as string) === "agent_failed") {
2636
+ if (invocation.kind === "prompt") current.deadlineManager.clear(invocation.correlation);
2637
+ }
2638
+ }
2639
+ current.runtime.emitEvent({ type, sessionId: ctx.sessionManager.getSessionId() });
2640
+ } catch {
2641
+ observed = false;
2642
+ }
2643
+ if (type === "agent_end") {
2644
+ if (current.activeInvocation?.kind === "prompt") {
2645
+ current.deadlineManager.clear(current.activeInvocation.correlation);
2646
+ } else if (current.drainedInvocations) {
2647
+ for (const inv of current.drainedInvocations)
2648
+ if (inv.kind === "prompt") current.deadlineManager.clear(inv.correlation);
2649
+ }
2650
+ current.activeInvocation = undefined;
2651
+ current.drainedInvocations = undefined;
2652
+ // The turn is over: no connection owns it anymore. Clearing here means
2653
+ // an abort against a later agent-initiated turn (monitor/cron
2654
+ // follow-up) finds no owner and fails closed, instead of letting the
2655
+ // previous prompt's owner stop a turn it did not submit (review
2656
+ // thread P1).
2657
+ activePromptOwnerHolder.connectionIds = undefined;
2658
+ // Resolve EVERY concurrent waiter for the aborted turn: the turn emits
2659
+ // exactly one agent_end, and each admitted abort of it must observe the
2660
+ // same publication result rather than a single latest-wins slot (review
2661
+ // thread P2).
2662
+ const resolvers = terminalPublicationCapture.resolvers;
2663
+ terminalPublicationCapture.resolvers = undefined;
2664
+ for (const resolve of resolvers ?? []) resolve(observed);
2665
+ }
1399
2666
  };
1400
2667
  api.on("agent_start", async (_event, ctx) => await emitLifecycle("agent_start", ctx));
1401
2668
  api.on("agent_end", async (_event, ctx) => await emitLifecycle("agent_end", ctx));
@@ -1408,6 +2675,18 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
1408
2675
  api.on("turn_end", (_event, ctx) =>
1409
2676
  active?.runtime.emitEvent({ type: "turn_end", sessionId: ctx.sessionManager.getSessionId() }),
1410
2677
  );
2678
+ api.on("tool_execution_start", async (_event, ctx) => {
2679
+ const current = active;
2680
+ if (current?.activeInvocation?.kind !== "prompt") return;
2681
+ current.deadlineManager.onAttributableEvent(current.activeInvocation.correlation, "tool_execution_start");
2682
+ void ctx;
2683
+ });
2684
+ api.on("tool_execution_end", async (_event, ctx) => {
2685
+ const current = active;
2686
+ if (current?.activeInvocation?.kind !== "prompt") return;
2687
+ current.deadlineManager.onAttributableEvent(current.activeInvocation.correlation, "tool_execution_end");
2688
+ void ctx;
2689
+ });
1411
2690
  const errorCode = (error: unknown): string | undefined =>
1412
2691
  typeof error === "object" &&
1413
2692
  error !== null &&
@@ -1420,23 +2699,36 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
1420
2699
  const sessionId = ctx.sessionManager.getSessionId();
1421
2700
  const stateRoot = path.join(ctx.cwd, ".gjc", "state");
1422
2701
  const token = crypto.randomBytes(24).toString("base64url");
1423
- const elevationAuthorityToken = crypto.randomBytes(32).toString("base64url");
1424
- const elevationAuthorityFile = elevationAuthorityPath(stateRoot, sessionId);
1425
- await fs.mkdir(path.dirname(elevationAuthorityFile), { recursive: true, mode: 0o700 });
1426
- await Bun.write(elevationAuthorityFile, `${JSON.stringify({ version: 1, token: elevationAuthorityToken })}\n`);
1427
- await fs.chmod(elevationAuthorityFile, 0o600);
1428
2702
  const transport = await options.createTransport({ sessionId, stateRoot, token });
1429
2703
  const revisions = new RevisionStore(sessionId, Date.now, { storageDir: stateRoot });
1430
2704
  const cursors = new CursorRegistry(token, revisions);
1431
- const reconciliation = createInvocationReconciliation({ stateRoot, sessionId });
2705
+ const reconciliationStore = options.terminalAbortSeams?.getReconciliationStore?.();
2706
+ const reconciliation = createInvocationReconciliation({ store: reconciliationStore });
1432
2707
  await reconciliation.hydrate();
1433
- const pending: Array<{ kind: InvocationKind; correlation: InvocationCorrelation }> = [];
2708
+ const sessionFile =
2709
+ (typeof ctx.sessionManager.getSessionFile === "function" ? ctx.sessionManager.getSessionFile() : undefined) ??
2710
+ resolveReconciliationSessionFile(undefined, stateRoot, sessionId);
2711
+ const steerReconciliation = createKindAwareReconciliation({
2712
+ store: createReconciliationStore({ sessionFile, sessionId }),
2713
+ });
2714
+ await steerReconciliation.hydrateFromStore();
2715
+ const deadlineManager = new PromptDeadlineManager({
2716
+ reconciliation,
2717
+ getLeaseMs: () => {
2718
+ const v = options.settings?.get("sdk.promptDeadlineMs" as never) as number | undefined;
2719
+ return typeof v === "number" && Number.isFinite(v) ? v : 1_800_000;
2720
+ },
2721
+ getMaxMs: () => {
2722
+ const v = options.settings?.get("sdk.promptMaxRuntimeMs" as never) as number | undefined;
2723
+ return typeof v === "number" && Number.isFinite(v) ? v : 21_600_000;
2724
+ },
2725
+ });
2726
+ const pending: Array<{
2727
+ kind: InvocationKind;
2728
+ correlation: InvocationCorrelation;
2729
+ connectionId: string | undefined;
2730
+ }> = [];
1434
2731
  const configRevision = { current: 0 };
1435
- // The event-ring watermark lives on the runtime host, which is constructed
1436
- // after the surface factory; the lazy source indirection lets the surface
1437
- // read the live generation/seq once the runtime exists (Q30 atomic
1438
- // capture, C9).
1439
- let eventWatermarkSource: () => { generation: number; seq: number } = () => ({ generation: 0, seq: 0 });
1440
2732
  let acceptingGateResolutions = true;
1441
2733
  const inFlightGateResolutions = new Set<Promise<unknown>>();
1442
2734
  const trackGateResolution = <T>(resolution: Promise<T>): Promise<T> => {
@@ -1446,8 +2738,8 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
1446
2738
  };
1447
2739
  const waitForGateResolutionQuiescence = async (): Promise<void> => {
1448
2740
  const settled = Promise.allSettled(inFlightGateResolutions);
1449
- const timeout = Bun.sleep(5_000).then(() => {
1450
- throw new Error("Timed out waiting for SDK workflow gate resolutions to settle.");
2741
+ const timeout = Bun.sleep(GATE_RESOLUTION_QUIESCENCE_MS).then(() => {
2742
+ logger.warn("SDK workflow gate resolution drain timed out; proceeding with uncertain outcomes.");
1451
2743
  });
1452
2744
  await Promise.race([settled, timeout]);
1453
2745
  };
@@ -1457,23 +2749,47 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
1457
2749
  api,
1458
2750
  reconciliation,
1459
2751
  turnResultLookup: selector => reconciliation.lookupResult(selector.kind, selector),
2752
+ steerStatusLookup: selector => steerReconciliation.lookupSteer(selector),
1460
2753
  configOverrides: options.configOverrides,
1461
2754
  settings: options.settings,
1462
- getEventWatermark: () => eventWatermarkSource(),
1463
2755
  });
1464
2756
  const queryHandlers = new QueryHandlers(surfaceFactory.query, sessionId, revisions, cursors);
1465
2757
  const controlSurface = createControlSurface(
1466
2758
  ctx,
1467
2759
  api,
1468
2760
  reconciliation,
1469
- (kind, correlation) => {
1470
- pending.push({ kind, correlation });
2761
+ (kind, correlation, connectionId, startsOwnTurn) => {
2762
+ // Only submissions that start their OWN turn get a pending entry: a
2763
+ // steering-queued submission consumed inside the current run never
2764
+ // emits the agent_start that would consume the entry, so leaving it
2765
+ // queued would assign its stale connection as owner of a later
2766
+ // agent-initiated turn (review thread P1).
2767
+ if (startsOwnTurn) pending.push({ kind, correlation, connectionId });
2768
+ },
2769
+ steerReconciliation,
2770
+ (kind, correlation, connectionId) => {
2771
+ // A steering-queued submission PROMOTED to its own run (finished
2772
+ // prompt unwinding) starts with an empty pending queue at its
2773
+ // agent_start; create the entry at promotion so the submitting
2774
+ // connection owns that turn (review thread P2).
2775
+ pending.push({ kind, correlation, connectionId });
1471
2776
  },
1472
2777
  surfaceFactory.policy,
1473
2778
  options.settings,
1474
2779
  options.configOverrides,
1475
2780
  configRevision,
1476
- elevationAuthorityToken,
2781
+ options.terminalAbortSeams,
2782
+ terminalPublicationCapture,
2783
+ activePromptOwnerHolder,
2784
+ correlation => {
2785
+ // An accepted submission that settles WITHOUT starting (a rejection
2786
+ // after acceptance) must not leave its pending entry behind: remove
2787
+ // the matching correlation so a later agent-initiated turn never
2788
+ // inherits the failed submission's connection as owner (review
2789
+ // thread P1).
2790
+ const index = pending.findIndex(entry => entry.correlation === correlation);
2791
+ if (index >= 0) pending.splice(index, 1);
2792
+ },
1477
2793
  () => acceptingGateResolutions,
1478
2794
  trackGateResolution,
1479
2795
  );
@@ -1544,24 +2860,23 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
1544
2860
  };
1545
2861
  runtime = new SessionSdkSessionRuntime({
1546
2862
  transport,
1547
- control: async (_connectionId, frame) => {
2863
+ control: async (connectionId, frame) => {
2864
+ options.onFrameAdmitted?.();
1548
2865
  const request = controlRequestFromFrame(frame as Record<string, unknown>);
1549
- return dispatchControl(
1550
- controlSurface,
1551
- OPERATIONS.find(operation => operation.kind === "control" && operation.sdkId === request.operation),
1552
- request,
2866
+ // Scope preflight cancellation to the REQUESTING SDK connection: the
2867
+ // requester preflight buckets are keyed by this context, so a
2868
+ // terminal abort from one client must never cancel another client's
2869
+ // pending preflight (review thread P1).
2870
+ return sdkControlRequesterContext.run(connectionId, () =>
2871
+ dispatchControl(
2872
+ controlSurface,
2873
+ OPERATIONS.find(operation => operation.kind === "control" && operation.sdkId === request.operation),
2874
+ request,
2875
+ ),
1553
2876
  );
1554
2877
  },
1555
2878
  query: async (connectionId, frame) => {
1556
2879
  const request = frame as Record<string, unknown>;
1557
- // D1: a non-string top-level cursor is rejected, never silently
1558
- // dropped into a fresh-snapshot query.
1559
- if (request.cursor !== undefined && typeof request.cursor !== "string")
1560
- return {
1561
- id: typeof request.id === "string" ? request.id : undefined,
1562
- ok: false,
1563
- error: { code: "invalid_input", message: "cursor must be a non-empty string" },
1564
- };
1565
2880
  return queryHandlers.dispatch({
1566
2881
  id: typeof request.id === "string" ? request.id : undefined,
1567
2882
  query: typeof request.query === "string" ? request.query : "",
@@ -1574,18 +2889,86 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
1574
2889
  });
1575
2890
  },
1576
2891
  onRequest: options.onSdkRequest,
2892
+ onControlResponseDelivery: async (_connectionId, request, response, outcome) => {
2893
+ // Strict key validation: a malformed retry (e.g. numeric
2894
+ // idempotencyKey) rejected by dispatch must NEVER hash to the same
2895
+ // key as a legitimate stored string and advance its response state
2896
+ // (review thread P2).
2897
+ if (
2898
+ !reconciliationStore ||
2899
+ request.operation !== "turn.abort" ||
2900
+ typeof request.idempotencyKey !== "string" ||
2901
+ typeof request.input !== "object" ||
2902
+ request.input === null ||
2903
+ (request.input as { mode?: unknown }).mode !== "terminal"
2904
+ )
2905
+ return;
2906
+ const input = request.input as Record<string, unknown>;
2907
+ const mode = input.mode;
2908
+ const rawScope = input.scope;
2909
+ if (mode !== "terminal") return;
2910
+ if (rawScope !== undefined && rawScope !== "turn" && rawScope !== "owned") return;
2911
+ // A malformed retry (e.g. {mode:"terminal", scope:"turn", extra:true})
2912
+ // rejected by dispatch must never hash as the valid turn input and
2913
+ // advance the legitimate stored row (review thread P2).
2914
+ for (const key of Object.keys(input)) if (key !== "mode" && key !== "scope") return;
2915
+ const scopeInput = rawScope === "owned" ? "owned" : "turn";
2916
+ const keyHash = crypto.createHash("sha256").update(String(request.idempotencyKey)).digest("hex");
2917
+ const inputHash = crypto
2918
+ .createHash("sha256")
2919
+ .update(JSON.stringify({ mode: "terminal", scope: scopeInput }))
2920
+ .digest("hex");
2921
+ // Hash the ACTUAL written response payload: the durable state may only
2922
+ // advance when the written response corresponds to the row's payload.
2923
+ // When more than 256 concurrent requests evict an in-flight abort from
2924
+ // the dispatch cache, a same-key retry can return pending_replay while
2925
+ // the original is still terminalizing — matching only key+input would
2926
+ // mark the original marker sent for the retry's uncertainty response,
2927
+ // and the original's later stopped CAS would replace the payload hash
2928
+ // without resetting the state, making durable replay claim the stopped
2929
+ // payload was sent when only the pending response was written (review
2930
+ // thread P2). A final non-pending row whose stored hash is the input
2931
+ // placeholder (no_effect/uncertain) still advances: its own response is
2932
+ // the only one written for it.
2933
+ const responsePayloadHash =
2934
+ response && typeof response === "object" && "result" in response
2935
+ ? crypto
2936
+ .createHash("sha256")
2937
+ .update(JSON.stringify((response as { result: unknown }).result))
2938
+ .digest("hex")
2939
+ : undefined;
2940
+ // Require EXACT payload equality: finalization now stores the precise
2941
+ // final response hash for every disposition (including uncertainty and
2942
+ // no-effect), so a pending_replay retry whose payload differs can never
2943
+ // mark the durable row sent (review thread P2).
2944
+ const payloadMatches = (record: { responsePayloadHash?: string; replayPayloadHash?: string }) =>
2945
+ responsePayloadHash !== undefined &&
2946
+ (record.responsePayloadHash === responsePayloadHash || record.replayPayloadHash === responsePayloadHash);
2947
+ await reconciliationStore.transactTerminalState(state => ({
2948
+ scopes: state.scopes.map(record =>
2949
+ record.idempotencyKeyHash === keyHash &&
2950
+ record.idempotencyInputHash === inputHash &&
2951
+ record.responseState === "pending" &&
2952
+ payloadMatches(record)
2953
+ ? { ...record, responseState: outcome === "written" ? "sent" : "failed" }
2954
+ : record,
2955
+ ),
2956
+ keys: state.keys.map(record =>
2957
+ record.keyHash === keyHash &&
2958
+ record.inputHash === inputHash &&
2959
+ record.responseState === "pending" &&
2960
+ payloadMatches(record)
2961
+ ? { ...record, responseState: outcome === "written" ? "sent" : "failed" }
2962
+ : record,
2963
+ ),
2964
+ }));
2965
+ },
1577
2966
  installProviderDefinitions,
1578
2967
  onProviderDefinitionsRemoved: removeProviderDefinitions,
1579
2968
  afterControlResponse: async (_connectionId, request, response) => {
1580
2969
  if (request.operation === "session.close" && response.ok === true) ctx.shutdown();
1581
2970
  },
1582
2971
  });
1583
- // The runtime host now owns the live event stream; point the surface's
1584
- // checkpoint watermark at the real generation/seq (C9).
1585
- eventWatermarkSource = () => ({
1586
- generation: runtime.host.events.generation,
1587
- seq: runtime.host.events.sequence,
1588
- });
1589
2972
  const disposeGate = ctx.workflowGate?.onGateEmitted?.(gate =>
1590
2973
  runtime.emitEvent({ kind: "workflow_gate", payload: gate }),
1591
2974
  );
@@ -1617,6 +3000,8 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
1617
3000
  revisions,
1618
3001
  cursors,
1619
3002
  reconciliation,
3003
+ steerReconciliation,
3004
+ deadlineManager,
1620
3005
  pending,
1621
3006
  registerBroker,
1622
3007
  fenceGateResolutions: () => {
@@ -1643,6 +3028,8 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
1643
3028
  revisions,
1644
3029
  cursors,
1645
3030
  reconciliation,
3031
+ steerReconciliation,
3032
+ deadlineManager,
1646
3033
  pending,
1647
3034
  registerBroker,
1648
3035
  fenceGateResolutions: () => {
@@ -1661,6 +3048,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
1661
3048
  const stopActive = async (): Promise<void> => {
1662
3049
  const current = active;
1663
3050
  if (!current) return;
3051
+ current.deadlineManager.clearAll();
1664
3052
  current.fenceGateResolutions();
1665
3053
  try {
1666
3054
  await current.waitForGateResolutionQuiescence();