@gajae-code/coding-agent 0.11.11 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (344) hide show
  1. package/CHANGELOG.md +131 -4
  2. package/dist/types/async/job-manager.d.ts +17 -3
  3. package/dist/types/cli/read-cli.d.ts +1 -0
  4. package/dist/types/commands/deep-interview.d.ts +9 -0
  5. package/dist/types/commands/harness.d.ts +22 -0
  6. package/dist/types/commands/read.d.ts +6 -0
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/config/provider-auth-health.d.ts +14 -0
  9. package/dist/types/config/provider-ranking.d.ts +59 -0
  10. package/dist/types/config/settings-schema.d.ts +83 -1
  11. package/dist/types/config/settings.d.ts +2 -0
  12. package/dist/types/exa/mcp-client.d.ts +10 -1
  13. package/dist/types/exec/bash-executor.d.ts +45 -12
  14. package/dist/types/extensibility/extensions/types.d.ts +38 -4
  15. package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
  16. package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
  17. package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
  18. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
  19. package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
  20. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
  21. package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
  22. package/dist/types/extensibility/shared-events.d.ts +6 -0
  23. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +5 -12
  24. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +17 -32
  25. package/dist/types/gjc-runtime/deep-interview-stage.d.ts +37 -0
  26. package/dist/types/gjc-runtime/deep-interview-state.d.ts +4 -135
  27. package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
  28. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  29. package/dist/types/gjc-runtime/memory-guard-owner-claims.d.ts +47 -0
  30. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +70 -1
  31. package/dist/types/gjc-runtime/state-writer.d.ts +5 -46
  32. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  33. package/dist/types/gjc-runtime/team-runtime.d.ts +27 -1
  34. package/dist/types/gjc-runtime/team-store.d.ts +12 -0
  35. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
  36. package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +92 -0
  37. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +17 -0
  38. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +8 -0
  39. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +31 -80
  40. package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -5
  41. package/dist/types/internal-urls/local-root-gc.d.ts +2 -0
  42. package/dist/types/main.d.ts +1 -1
  43. package/dist/types/memories/index.d.ts +2 -0
  44. package/dist/types/modes/acp/acp-agent.d.ts +10 -19
  45. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  46. package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
  47. package/dist/types/modes/components/irc-sidebar.d.ts +41 -0
  48. package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
  49. package/dist/types/modes/components/read-tool-group.d.ts +4 -4
  50. package/dist/types/modes/components/session-observer-overlay.d.ts +22 -0
  51. package/dist/types/modes/components/settings-selector.d.ts +11 -0
  52. package/dist/types/modes/components/status-line/gh.d.ts +5 -0
  53. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  54. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
  55. package/dist/types/modes/controllers/event-controller.d.ts +3 -0
  56. package/dist/types/modes/interactive-mode.d.ts +2 -0
  57. package/dist/types/modes/irc-observation-ledger.d.ts +17 -1
  58. package/dist/types/modes/types.d.ts +2 -0
  59. package/dist/types/modes/utils/ui-helpers.d.ts +16 -0
  60. package/dist/types/reminders/star-reminder.d.ts +3 -13
  61. package/dist/types/runtime-mcp/manager.d.ts +1 -0
  62. package/dist/types/runtime-mcp/smithery-auth.d.ts +5 -0
  63. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  64. package/dist/types/sdk/broker/lifecycle.d.ts +5 -0
  65. package/dist/types/sdk/broker/transport.d.ts +2 -0
  66. package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
  67. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -1
  68. package/dist/types/sdk/bus/config.d.ts +6 -2
  69. package/dist/types/sdk/bus/control-drain-lease.d.ts +52 -0
  70. package/dist/types/sdk/bus/index.d.ts +44 -1
  71. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +44 -0
  72. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  73. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -1
  74. package/dist/types/sdk/bus/reconciliation-store.d.ts +69 -0
  75. package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
  76. package/dist/types/sdk/bus/rich-render.d.ts +2 -0
  77. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
  78. package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
  79. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -4
  80. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
  81. package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
  82. package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
  83. package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
  84. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  85. package/dist/types/sdk/host/host.d.ts +2 -0
  86. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  87. package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
  88. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  89. package/dist/types/sdk/prompt-status.d.ts +23 -3
  90. package/dist/types/sdk/session.d.ts +6 -0
  91. package/dist/types/session/agent-session.d.ts +82 -12
  92. package/dist/types/session/artifacts.d.ts +8 -0
  93. package/dist/types/session/blob-store.d.ts +57 -4
  94. package/dist/types/session/internal/managed-session-storage.d.ts +8 -1
  95. package/dist/types/session/memory-guard-checkpoint-participant.d.ts +69 -0
  96. package/dist/types/session/session-manager.d.ts +48 -19
  97. package/dist/types/session/session-storage.d.ts +4 -0
  98. package/dist/types/session/streaming-output.d.ts +96 -12
  99. package/dist/types/setup/credential-import.d.ts +12 -0
  100. package/dist/types/skill-state/active-state.d.ts +12 -4
  101. package/dist/types/skill-state/workflow-hud.d.ts +9 -0
  102. package/dist/types/task/executor.d.ts +3 -2
  103. package/dist/types/task/index.d.ts +3 -1
  104. package/dist/types/task/provider-retry-status.d.ts +8 -0
  105. package/dist/types/task/receipt.d.ts +7 -1
  106. package/dist/types/task/types.d.ts +23 -0
  107. package/dist/types/tools/ask-answer-registry.d.ts +2 -2
  108. package/dist/types/tools/bash-interactive.d.ts +4 -1
  109. package/dist/types/tools/bash.d.ts +3 -1
  110. package/dist/types/tools/browser/launch.d.ts +7 -0
  111. package/dist/types/tools/fetch.d.ts +17 -1
  112. package/dist/types/tools/image-gen.d.ts +4 -0
  113. package/dist/types/tools/index.d.ts +4 -0
  114. package/dist/types/tools/output-meta.d.ts +44 -3
  115. package/dist/types/tools/path-utils.d.ts +1 -0
  116. package/dist/types/tools/read-internals.d.ts +12 -0
  117. package/dist/types/tools/read.d.ts +45 -7
  118. package/dist/types/tools/resource-gc.d.ts +4 -0
  119. package/dist/types/tools/review.d.ts +5 -7
  120. package/dist/types/tools/subagent.d.ts +2 -0
  121. package/dist/types/tools/tool-result.d.ts +7 -1
  122. package/dist/types/utils/gh.d.ts +13 -0
  123. package/dist/types/utils/image-resize.d.ts +1 -0
  124. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  125. package/dist/types/web/search/providers/searxng.d.ts +7 -0
  126. package/dist/types/web/search/providers/xai.d.ts +2 -0
  127. package/package.json +11 -13
  128. package/scripts/acp-conformance-agent.ts +218 -0
  129. package/scripts/benchmark-sticky-viewport-pr1.ts +239 -0
  130. package/scripts/capture-gjc-bundle-settings.ts +306 -0
  131. package/scripts/capture-sticky-viewport-showcase.ts +292 -0
  132. package/scripts/generate-sdk-operation-inventory.ts +5 -0
  133. package/scripts/resident-memory-bench.ts +1341 -0
  134. package/scripts/run-acp-conformance.ts +285 -0
  135. package/scripts/verify-gjc-sdk-canonicalization.ts +122 -18
  136. package/scripts/verify-sticky-viewport-showcase.ts +611 -0
  137. package/src/async/job-manager.ts +78 -20
  138. package/src/capability/index.ts +8 -0
  139. package/src/cli/fast-help.ts +1 -1
  140. package/src/cli/plugin-cli.ts +176 -18
  141. package/src/cli/read-cli.ts +2 -1
  142. package/src/cli.ts +1 -0
  143. package/src/commands/deep-interview.ts +29 -9
  144. package/src/commands/harness.ts +35 -12
  145. package/src/commands/read.ts +11 -2
  146. package/src/commands/team.ts +20 -2
  147. package/src/config/file-lock.ts +11 -2
  148. package/src/config/model-registry.ts +21 -2
  149. package/src/config/provider-auth-health.ts +42 -0
  150. package/src/config/provider-ranking.ts +119 -0
  151. package/src/config/settings-schema.ts +74 -1
  152. package/src/config/settings.ts +246 -108
  153. package/src/coordinator-mcp/question-gate-codec.ts +1 -1
  154. package/src/coordinator-mcp/server.ts +5 -12
  155. package/src/defaults/gjc/skills/deep-interview/SKILL.md +230 -82
  156. package/src/defaults/gjc/skills/ralplan/SKILL.md +77 -41
  157. package/src/defaults/gjc/skills/team/SKILL.md +3 -1
  158. package/src/defaults/gjc/skills/ultragoal/SKILL.md +62 -39
  159. package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
  160. package/src/defaults/gjc-defaults.ts +4 -4
  161. package/src/eval/py/executor.ts +7 -2
  162. package/src/exa/mcp-client.ts +12 -3
  163. package/src/exec/bash-executor.ts +144 -27
  164. package/src/extensibility/extensions/runner.ts +15 -0
  165. package/src/extensibility/extensions/types.ts +46 -5
  166. package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
  167. package/src/extensibility/gjc-plugins/index.ts +19 -2
  168. package/src/extensibility/gjc-plugins/installer.ts +189 -50
  169. package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
  170. package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
  171. package/src/extensibility/gjc-plugins/observability.ts +3 -3
  172. package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
  173. package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
  174. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
  175. package/src/extensibility/gjc-plugins/schema.ts +84 -12
  176. package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
  177. package/src/extensibility/gjc-plugins/types.ts +141 -0
  178. package/src/extensibility/gjc-plugins/validation.ts +9 -0
  179. package/src/extensibility/shared-events.ts +5 -0
  180. package/src/gjc-runtime/deep-interview-ambiguity.ts +47 -82
  181. package/src/gjc-runtime/deep-interview-recorder.ts +227 -310
  182. package/src/gjc-runtime/deep-interview-runtime.ts +109 -270
  183. package/src/gjc-runtime/deep-interview-stage.ts +1016 -0
  184. package/src/gjc-runtime/deep-interview-state.ts +12 -943
  185. package/src/gjc-runtime/gc-render.ts +1 -0
  186. package/src/gjc-runtime/gc-runtime.ts +11 -1
  187. package/src/gjc-runtime/launch-tmux.ts +25 -6
  188. package/src/gjc-runtime/managed-owner-admission.ts +1 -1
  189. package/src/gjc-runtime/memory-guard-owner-claims.ts +389 -0
  190. package/src/gjc-runtime/ralplan-runtime.ts +1149 -98
  191. package/src/gjc-runtime/state-runtime.ts +2 -1
  192. package/src/gjc-runtime/state-writer.ts +70 -222
  193. package/src/gjc-runtime/team-launch.ts +42 -5
  194. package/src/gjc-runtime/team-runtime.ts +1085 -40
  195. package/src/gjc-runtime/team-store.ts +32 -0
  196. package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
  197. package/src/gjc-runtime/team-worker-memory-guard.ts +363 -0
  198. package/src/gjc-runtime/team-workers.ts +1 -0
  199. package/src/gjc-runtime/tmux-owner-isolation.ts +28 -0
  200. package/src/gjc-runtime/tmux-sessions.ts +58 -13
  201. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +20 -0
  202. package/src/gjc-runtime/ultragoal-runtime.ts +414 -618
  203. package/src/gjc-runtime/workflow-manifest.generated.json +105 -449
  204. package/src/gjc-runtime/workflow-manifest.ts +38 -247
  205. package/src/hooks/native-skill-hook.ts +17 -4
  206. package/src/hooks/skill-state.ts +1 -23
  207. package/src/internal-urls/docs-index.generated.ts +21 -22
  208. package/src/internal-urls/local-root-gc.ts +127 -0
  209. package/src/main.ts +9 -5
  210. package/src/memories/index.ts +10 -0
  211. package/src/modes/DESIGN.md +98 -0
  212. package/src/modes/acp/acp-agent.ts +339 -191
  213. package/src/modes/acp/acp-event-mapper.ts +14 -7
  214. package/src/modes/acp/acp-mode.ts +39 -3
  215. package/src/modes/components/assistant-message.ts +123 -22
  216. package/src/modes/components/gjc-bundle-settings.ts +508 -0
  217. package/src/modes/components/irc-sidebar.ts +176 -62
  218. package/src/modes/components/model-selector.ts +61 -4
  219. package/src/modes/components/notifications-settings-editor.ts +12 -1
  220. package/src/modes/components/oauth-selector.ts +96 -28
  221. package/src/modes/components/read-tool-group.ts +82 -14
  222. package/src/modes/components/session-observer-overlay.ts +457 -83
  223. package/src/modes/components/settings-selector.ts +116 -23
  224. package/src/modes/components/status-line/gh.ts +16 -0
  225. package/src/modes/components/tool-execution.ts +138 -27
  226. package/src/modes/components/tool-status-header.ts +3 -14
  227. package/src/modes/components/transcript-viewer-overlay.ts +99 -24
  228. package/src/modes/controllers/command-controller.ts +4 -0
  229. package/src/modes/controllers/event-controller.ts +109 -27
  230. package/src/modes/controllers/extension-ui-controller.ts +26 -2
  231. package/src/modes/controllers/input-controller.ts +18 -3
  232. package/src/modes/controllers/selector-controller.ts +127 -36
  233. package/src/modes/interactive-mode.ts +62 -3
  234. package/src/modes/irc-observation-ledger.ts +71 -15
  235. package/src/modes/runtime-init.ts +22 -2
  236. package/src/modes/types.ts +2 -0
  237. package/src/modes/utils/ui-helpers.ts +94 -1
  238. package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
  239. package/src/prompts/agents/architect.md +9 -1
  240. package/src/prompts/agents/critic.md +9 -0
  241. package/src/prompts/system/eager-todo.md +8 -4
  242. package/src/prompts/system/system-prompt.md +11 -5
  243. package/src/prompts/tools/bash.md +5 -5
  244. package/src/prompts/tools/read.md +4 -2
  245. package/src/prompts/tools/search-tool-bm25.md +5 -0
  246. package/src/prompts/tools/task.md +1 -1
  247. package/src/reminders/star-reminder.ts +4 -42
  248. package/src/runtime-mcp/manager.ts +19 -4
  249. package/src/runtime-mcp/smithery-auth.ts +17 -2
  250. package/src/runtime-mcp/smithery-connect.ts +11 -1
  251. package/src/sdk/acp/adapter.ts +22 -6
  252. package/src/sdk/broker/lifecycle.ts +25 -6
  253. package/src/sdk/broker/transport.ts +35 -3
  254. package/src/sdk/bus/attachment-registry.ts +5 -1
  255. package/src/sdk/bus/chat-daemon-cli.ts +4 -39
  256. package/src/sdk/bus/chat-daemon-control.ts +6 -3
  257. package/src/sdk/bus/config.ts +18 -2
  258. package/src/sdk/bus/control-drain-lease.ts +104 -0
  259. package/src/sdk/bus/index.ts +984 -146
  260. package/src/sdk/bus/kind-aware-reconciliation.ts +317 -0
  261. package/src/sdk/bus/lifecycle-commands.ts +4 -1
  262. package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
  263. package/src/sdk/bus/prompt-reconciliation.ts +2 -1
  264. package/src/sdk/bus/reconciliation-store.ts +303 -0
  265. package/src/sdk/bus/rich-draft.ts +1 -1
  266. package/src/sdk/bus/rich-render.ts +2 -2
  267. package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
  268. package/src/sdk/bus/telegram-cli.ts +33 -8
  269. package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
  270. package/src/sdk/bus/telegram-daemon-contract.ts +11 -4
  271. package/src/sdk/bus/telegram-daemon.ts +916 -23
  272. package/src/sdk/bus/telegram-reference.ts +40 -3
  273. package/src/sdk/bus/threaded-render.ts +48 -3
  274. package/src/sdk/bus/topic-registry.ts +20 -0
  275. package/src/sdk/host/control/dispatch.ts +5 -1
  276. package/src/sdk/host/control/operations.ts +1 -1
  277. package/src/sdk/host/host.ts +23 -1
  278. package/src/sdk/host/query/handlers.ts +55 -0
  279. package/src/sdk/host/query/revision-store.ts +36 -2
  280. package/src/sdk/host/reverse-leases.ts +44 -7
  281. package/src/sdk/mcp/server.ts +46 -18
  282. package/src/sdk/prompt-status.ts +16 -3
  283. package/src/sdk/protocol/operation-inventory.generated.json +51 -0
  284. package/src/sdk/protocol/operation-registry.ts +7 -2
  285. package/src/sdk/session.ts +101 -10
  286. package/src/session/agent-session.ts +1286 -220
  287. package/src/session/artifacts.ts +22 -0
  288. package/src/session/auth-broker-config.ts +10 -3
  289. package/src/session/blob-store.ts +915 -24
  290. package/src/session/internal/managed-session-scope.ts +44 -6
  291. package/src/session/internal/managed-session-storage.ts +48 -6
  292. package/src/session/memory-guard-checkpoint-participant.ts +131 -0
  293. package/src/session/session-manager.ts +2364 -438
  294. package/src/session/session-storage.ts +9 -1
  295. package/src/session/streaming-output.ts +500 -111
  296. package/src/setup/credential-import.ts +75 -13
  297. package/src/setup/provider-onboarding.ts +19 -4
  298. package/src/skill-state/active-state.ts +32 -10
  299. package/src/skill-state/workflow-hud.ts +42 -2
  300. package/src/skill-state/workflow-mutation-guard.ts +560 -8
  301. package/src/task/executor.ts +122 -39
  302. package/src/task/index.ts +23 -14
  303. package/src/task/provider-retry-status.ts +16 -0
  304. package/src/task/receipt.ts +23 -7
  305. package/src/task/render.ts +6 -2
  306. package/src/task/types.ts +108 -0
  307. package/src/tools/ask-answer-registry.ts +12 -6
  308. package/src/tools/ask.ts +169 -118
  309. package/src/tools/ast-edit.ts +1 -0
  310. package/src/tools/ast-grep.ts +1 -0
  311. package/src/tools/bash-interactive.ts +8 -3
  312. package/src/tools/bash.ts +503 -60
  313. package/src/tools/browser/launch.ts +41 -6
  314. package/src/tools/fetch.ts +114 -23
  315. package/src/tools/find.ts +1 -0
  316. package/src/tools/image-gen.ts +29 -5
  317. package/src/tools/index.ts +4 -0
  318. package/src/tools/output-meta.ts +281 -20
  319. package/src/tools/path-utils.ts +8 -3
  320. package/src/tools/read-internals.ts +44 -0
  321. package/src/tools/read.ts +1560 -310
  322. package/src/tools/resource-gc.ts +182 -18
  323. package/src/tools/review.ts +5 -7
  324. package/src/tools/search.ts +1 -0
  325. package/src/tools/subagent-render.ts +6 -2
  326. package/src/tools/subagent.ts +6 -0
  327. package/src/tools/telegram-send.ts +6 -3
  328. package/src/tools/tool-result.ts +14 -1
  329. package/src/utils/gh.ts +39 -0
  330. package/src/utils/image-resize.ts +3 -0
  331. package/src/web/search/providers/anthropic.ts +4 -4
  332. package/src/web/search/providers/codex.ts +1 -1
  333. package/src/web/search/providers/kimi.ts +15 -5
  334. package/src/web/search/providers/searxng.ts +47 -4
  335. package/src/web/search/providers/xai.ts +7 -2
  336. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +0 -8
  337. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +0 -41
  338. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +0 -17
  339. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
  340. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
  341. package/src/gjc-runtime/deep-interview-draft.ts +0 -843
  342. package/src/gjc-runtime/deep-interview-payload.ts +0 -262
  343. package/src/gjc-runtime/deep-interview-repair.ts +0 -1674
  344. package/src/gjc-runtime/ultragoal-pipeline.ts +0 -306
@@ -30,11 +30,12 @@ import { promisify } from "node:util";
30
30
  import { ThinkingLevel } from "@gajae-code/agent-core";
31
31
  import type { ImageContent, TextContent, Tool } from "@gajae-code/ai";
32
32
  import { NotificationServer, nativeBuildInfo } from "@gajae-code/natives";
33
- import { logger, postmortem, VERSION } from "@gajae-code/utils";
33
+ import { $credentialEnv, logger, postmortem, VERSION } from "@gajae-code/utils";
34
34
  import { isModelProfileProviderAvailable, projectModelProfileCatalog } from "../../config/model-profile-contract";
35
35
  import { isAuthenticated, kNoAuth } from "../../config/model-registry";
36
36
  import { Settings } from "../../config/settings";
37
37
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../../extensibility/extensions";
38
+ import { INTERACTIVE_SELECTOR_RESUME_ORIGIN } from "../../extensibility/shared-events";
38
39
  import { toAgentWireEventPayload } from "../../modes/shared/agent-wire/event-envelope";
39
40
  import {
40
41
  NotificationGatePolicyChangedError,
@@ -43,9 +44,11 @@ import {
43
44
  type WorkflowGateTerminalProof,
44
45
  } from "../../modes/shared/agent-wire/workflow-gate-broker";
45
46
  import type { AgentSessionEvent } from "../../session/agent-session";
47
+ import type { ClientBridge } from "../../session/client-bridge";
46
48
  import { parseThinkingLevel } from "../../thinking";
47
49
  import type {
48
50
  AskAnswerRequest,
51
+ AskAnswerSource,
49
52
  AskAnswerSourceResult,
50
53
  AskRemoteControl,
51
54
  AskRemoteInteraction,
@@ -62,7 +65,7 @@ import { SessionSdkHost, shouldHostSdk } from "../host";
62
65
  import { type ControlSurface, dispatchControl } from "../host/control";
63
66
  import { CursorRegistry, QueryHandlers, RevisionStore, type SessionSurface } from "../host/query";
64
67
  import { projectQ10Models } from "../models.js";
65
- import { PROMPT_CLIENT_REF_MAX_LENGTH } from "../prompt-status";
68
+ import { PROMPT_CLIENT_REF_MAX_LENGTH, type SdkPromptTerminalOutcome } from "../prompt-status";
66
69
  import { OPERATIONS } from "../protocol/operation-registry";
67
70
  import {
68
71
  lifecycleStartupCapabilityForApi,
@@ -82,9 +85,16 @@ import {
82
85
  sessionTag,
83
86
  } from "./config";
84
87
  import { telegramControlCommandUsage } from "./config-commands";
88
+ import {
89
+ isNativeControlDrainAvailable,
90
+ runIdentityControlSuccessPath,
91
+ type TerminalSendOutcome,
92
+ } from "./control-drain-lease";
85
93
  import { imageAttachmentsFromMessage, notificationActionPayload, summaryFromMessage, truncate } from "./helpers";
94
+ import { createKindAwareReconciliation } from "./kind-aware-reconciliation";
86
95
  import { assertNativeRuntimeCompatibility } from "./native-runtime-compatibility";
87
96
  import { createPromptReconciliation, sanitizePromptFailure } from "./prompt-reconciliation";
97
+ import { createReconciliationStore } from "./reconciliation-store";
88
98
  import { NotificationSessionController, type NotificationSessionRuntime } from "./session-control";
89
99
  import {
90
100
  ASK_SELECTED_ACK_CAPABILITY,
@@ -95,6 +105,17 @@ import {
95
105
  unregisterNotificationRoot,
96
106
  } from "./telegram-daemon";
97
107
 
108
+ export type {
109
+ IdentityControlSuccessPathInput,
110
+ IdentityControlTerminalPathInput,
111
+ TerminalSendOutcome,
112
+ } from "./control-drain-lease";
113
+ export {
114
+ isNativeControlDrainAvailable,
115
+ runIdentityControlSuccessPath,
116
+ runIdentityControlTerminalPath,
117
+ } from "./control-drain-lease";
118
+
98
119
  // ===========================================================================
99
120
  // Session lifecycle control protocol (TypeScript mirror of the Rust wire
100
121
  // contract in `crates/gjc-sdk/src/lifecycle.rs`).
@@ -625,7 +646,31 @@ export class PresentationArbiter {
625
646
  ) {}
626
647
 
627
648
  retain(presentation: UnattendedGatePresentation): void {
628
- const alreadyPresent = this.presentations.has(presentation.gateId);
649
+ const existing = this.presentations.get(presentation.gateId);
650
+ if (
651
+ existing &&
652
+ this.active?.gateId === presentation.gateId &&
653
+ (existing.options.length !== presentation.options.length ||
654
+ existing.options.some((option, index) => presentation.options[index] !== option))
655
+ ) {
656
+ const active = this.active;
657
+ let status: RetireStatus;
658
+ try {
659
+ status = parseRetireStatus(this.server.retireIfUnclaimed(active).status);
660
+ } catch (error) {
661
+ logger.warn(`interactive presentation replay retirement failed: ${String(error)}`);
662
+ return;
663
+ }
664
+ if (!isTerminalProof(status)) return;
665
+ this.routes.delete(active.actionId);
666
+ this.active = undefined;
667
+ }
668
+ const alreadyPresent = existing !== undefined;
669
+ if (existing?.multi && presentation.multi) {
670
+ presentation.selectedOptions = existing.selectedOptions.filter(option =>
671
+ presentation.options.includes(option),
672
+ );
673
+ }
629
674
  if (!alreadyPresent) this.queue.push(presentation.gateId);
630
675
  this.presentations.set(presentation.gateId, presentation);
631
676
  // A fresh durable replay is explicit production recovery after transient N-API exhaustion.
@@ -754,6 +799,13 @@ export class PresentationArbiter {
754
799
  ? `(${presentation.selectedOptions.length} selected) ${presentation.question}`
755
800
  : presentation.question,
756
801
  options: presentation.options,
802
+ ...(presentation.multi
803
+ ? {
804
+ selectedOptionIndices: presentation.options.flatMap((option, index) =>
805
+ presentation.selectedOptions.includes(option) ? [index] : [],
806
+ ),
807
+ }
808
+ : {}),
757
809
  ...(presentation.recommendedIndex === undefined
758
810
  ? {}
759
811
  : { recommendedIndex: presentation.recommendedIndex }),
@@ -886,6 +938,8 @@ interface SessionRuntime {
886
938
  disposeGateListener: () => void;
887
939
  /** Whether notification-only delivery and answer resources are active. */
888
940
  notificationsActive: boolean;
941
+ /** Rejects new SDK frames while a leased terminal response drains. */
942
+ inboundFenced: boolean;
889
943
  /** Set as soon as terminal teardown is requested, before startup settles. */
890
944
  stopping: boolean;
891
945
  /** Recreates notification-only resources after `/notify on`. */
@@ -923,6 +977,19 @@ interface SessionRuntime {
923
977
  pendingPromptCorrelations: Array<{ commandId: string; turnId: string }>;
924
978
  /** Identity bound to the agent lifecycle currently in flight. */
925
979
  activePromptCorrelation?: { commandId: string; turnId: string };
980
+ /** Binds the executing Agent run to a correlated prompt so cleanup targets only it. */
981
+ bindPromptExecutionHandle: (correlation: { commandId: string; turnId: string }, handle: string | undefined) => void;
982
+ /** Reads the durable non-terminal claim for a correlated prompt, if any. */
983
+ peekPromptPendingOutcome: (correlation: {
984
+ commandId: string;
985
+ turnId: string;
986
+ }) => SdkPromptTerminalOutcome | undefined;
987
+ /** Claims, fences, finalizes, and publishes exactly one normalized prompt terminal. */
988
+ terminalizePrompt: (
989
+ correlation: { commandId: string; turnId: string },
990
+ outcome: SdkPromptTerminalOutcome,
991
+ extra?: { finalText?: string },
992
+ ) => Promise<void>;
926
993
  /** Records a correlated prompt terminal boundary after agent unwind. */
927
994
  /** Atomically claims a correlated prompt terminal boundary after agent unwind. */
928
995
  recordPromptTerminal: (correlation: { commandId: string; turnId: string } | undefined) => boolean;
@@ -943,6 +1010,7 @@ interface SessionRuntime {
943
1010
  commandId?: string;
944
1011
  turnId?: string;
945
1012
  finalText?: string;
1013
+ outcome?: SdkPromptTerminalOutcome;
946
1014
  }
947
1015
  | {
948
1016
  type: "agent_failed";
@@ -950,6 +1018,7 @@ interface SessionRuntime {
950
1018
  commandId: string;
951
1019
  turnId: string;
952
1020
  error: { code: string; message: string };
1021
+ outcome?: SdkPromptTerminalOutcome;
953
1022
  },
954
1023
  ) => void;
955
1024
  /** Publishes one canonical agent-wire event to the client that owns the active prompt. */
@@ -1113,6 +1182,7 @@ const defaultConfig: NotificationConfig = {
1113
1182
  redact: false,
1114
1183
  verbosity: "lean",
1115
1184
  sessionScope: "all",
1185
+ sound: "all",
1116
1186
  idleTimeoutMs: 60_000,
1117
1187
  rich: { enabled: true },
1118
1188
  richDraft: { enabled: false },
@@ -1122,8 +1192,17 @@ const defaultConfig: NotificationConfig = {
1122
1192
  btw: { enabled: true },
1123
1193
  };
1124
1194
 
1195
+ /**
1196
+ * Whether the notifications control channel is enabled.
1197
+ *
1198
+ * Trusted sources only: enabling it opens the session control/answer channel, so
1199
+ * a repository must not be able to turn it on. `$env` merges the caller's
1200
+ * `cwd/.env` into `process.env`; the sibling resolvers in `config.ts` and
1201
+ * `session-control.ts` already read an injected env record rather than the merged
1202
+ * view, and this direct read was the outlier.
1203
+ */
1125
1204
  export function notificationsEnabled(): boolean {
1126
- return process.env.GJC_NOTIFICATIONS === "1" || Boolean(process.env.GJC_NOTIFICATIONS_TOKEN);
1205
+ return $credentialEnv("GJC_NOTIFICATIONS") === "1" || Boolean($credentialEnv("GJC_NOTIFICATIONS_TOKEN"));
1127
1206
  }
1128
1207
 
1129
1208
  function streamIntervalMs(): number {
@@ -1596,6 +1675,80 @@ async function requestRecoveredSelectedAck(
1596
1675
  }
1597
1676
  }
1598
1677
 
1678
+ function createSdkUiAskAnswerSource(
1679
+ requestElicitation: (params: Record<string, unknown>, signal?: AbortSignal) => Promise<unknown>,
1680
+ ): AskAnswerSource {
1681
+ const awaitAnswerRequest = async (
1682
+ request: AskAnswerRequest,
1683
+ signal?: AbortSignal,
1684
+ ): Promise<AskAnswerSourceResult> => {
1685
+ if (signal?.aborted) return undefined;
1686
+ const choices = new Map<string, AskRemoteInteraction>();
1687
+ const oneOf =
1688
+ request.interaction === "selector"
1689
+ ? [
1690
+ ...request.options.map((label, index) => {
1691
+ const value = `option:${index}`;
1692
+ choices.set(value, { kind: "value", value: label });
1693
+ return { const: value, title: label };
1694
+ }),
1695
+ ...request.controls
1696
+ .filter(control => control.enabled)
1697
+ .map(control => {
1698
+ const value = `control:${control.id}`;
1699
+ choices.set(value, { kind: "control", controlId: control.id });
1700
+ return { const: value, title: control.label };
1701
+ }),
1702
+ ]
1703
+ : [];
1704
+ const response = await requestElicitation(
1705
+ {
1706
+ mode: "form",
1707
+ message: request.question,
1708
+ requestedSchema: {
1709
+ type: "object",
1710
+ properties: {
1711
+ value: { type: "string", ...(oneOf.length > 0 ? { oneOf } : {}) },
1712
+ },
1713
+ required: ["value"],
1714
+ },
1715
+ },
1716
+ signal,
1717
+ );
1718
+ if (signal?.aborted || !isRecord(response) || response.action !== "accept") return undefined;
1719
+ const value = isRecord(response.content) ? response.content.value : undefined;
1720
+ if (typeof value !== "string") return undefined;
1721
+ if (request.interaction !== "selector") return value;
1722
+ const interaction = choices.get(value);
1723
+ if (!interaction) return undefined;
1724
+ if (interaction.kind === "value") return interaction.value;
1725
+ let settled: Promise<AskSettlementResult> | undefined;
1726
+ return {
1727
+ source: "remote",
1728
+ interaction,
1729
+ settle(settlement) {
1730
+ if (!settled) {
1731
+ settled = Promise.resolve(
1732
+ settlement.kind === "commit"
1733
+ ? { kind: "committed", ack: { status: "failed", reason: "unsupported" } }
1734
+ : settlement.kind === "invalid"
1735
+ ? { kind: "invalid_closed" }
1736
+ : { kind: "resolved_without_commit" },
1737
+ );
1738
+ }
1739
+ return settled;
1740
+ },
1741
+ };
1742
+ };
1743
+ return {
1744
+ async awaitAnswer(question, options, signal) {
1745
+ const answer = await awaitAnswerRequest({ question, options, interaction: "selector", controls: [] }, signal);
1746
+ if (!answer || typeof answer === "string") return answer;
1747
+ return answer.interaction.kind === "value" ? answer.interaction.value : undefined;
1748
+ },
1749
+ awaitAnswerRequest,
1750
+ };
1751
+ }
1599
1752
  /** Register the interactive `ask` answer source for a session (the ask tool
1600
1753
  * races the local UI against a remote reply). Returns the deregister disposer. */
1601
1754
  function registerInteractiveAnswerSource(
@@ -1807,6 +1960,9 @@ function sdkQuerySurface(
1807
1960
  }),
1808
1961
  configOverrides: ReadonlyMap<string, unknown> = new Map(),
1809
1962
  promptStatusLookup: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown,
1963
+ skillStatusLookup: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown = () => ({
1964
+ status: "unknown",
1965
+ }),
1810
1966
  ): SessionSurface {
1811
1967
  const metadata = () => ({
1812
1968
  sessionId: id,
@@ -1923,6 +2079,7 @@ function sdkQuerySurface(
1923
2079
  getCapabilities: () => ({
1924
2080
  operations: [...installedOperations(ctx, "control"), ...installedOperations(ctx, "query")],
1925
2081
  hostTools: getInstalledDefinitions("host_tools") !== undefined,
2082
+ promptTerminalOutcomeVersion: 1,
1926
2083
  }),
1927
2084
  getAuthProviders: () => [...new Set(ctx.modelRegistry.getAll().map(model => model.provider))],
1928
2085
  getTools: () => {
@@ -1935,6 +2092,8 @@ function sdkQuerySurface(
1935
2092
  getJobs: () => ctx.getJobs(),
1936
2093
  getPromptStatus: (selector: { commandId?: string; turnId?: string; clientRef?: string }) =>
1937
2094
  promptStatusLookup(selector),
2095
+ getSkillInvokeStatus: (selector: { commandId?: string; turnId?: string; clientRef?: string }) =>
2096
+ skillStatusLookup(selector),
1938
2097
  installedQueries: installedOperations(ctx, "query"),
1939
2098
  };
1940
2099
  }
@@ -1962,16 +2121,41 @@ function sdkControlSurface(
1962
2121
  requesterConnectionId?: string,
1963
2122
  clientRef?: string,
1964
2123
  trackReconciliation?: boolean,
1965
- ) => void = () => {},
2124
+ ) => void | Promise<void> = () => {},
1966
2125
  onPromptFailed: (correlation: { commandId: string; turnId: string }, error: unknown) => void = () => {},
2126
+ onPromptAcceptFailed: (correlation: { commandId: string; turnId: string }) => void = () => {},
1967
2127
  acceptGateResolution: () => boolean,
1968
2128
  trackGateResolution: <T>(resolution: Promise<T>) => Promise<T>,
1969
2129
  admitPrompt: (clientRef?: string) => void,
1970
2130
  releasePromptAdmission: (clientRef?: string) => void,
2131
+ awaitReconciliationReady: () => Promise<void> = async () => {},
1971
2132
  settings?: Settings,
1972
2133
  configOverrides: Map<string, unknown> = new Map(),
1973
2134
  configRevision: { current: number } = { current: 0 },
1974
- ): ControlSurface {
2135
+ abortOwnedPrompt: (
2136
+ connectionId: string | undefined,
2137
+ ) => Promise<{ aborted: true; disposition: "cancelled" | "already_terminal" | "idle" }> = async () => ({
2138
+ aborted: true,
2139
+ disposition: "idle",
2140
+ }),
2141
+ skillRecon?: {
2142
+ admit: (clientRef?: string) => void;
2143
+ release: (clientRef?: string) => void;
2144
+ noteAccepted: (
2145
+ correlation: { commandId: string; turnId: string },
2146
+ clientRef?: string,
2147
+ extra?: { skillName?: string },
2148
+ ) => Promise<void>;
2149
+ noteTransition: (
2150
+ correlation: { commandId: string; turnId: string } | undefined,
2151
+ frame: { type: "agent_start" | "agent_end" } | { type: "agent_failed"; error: unknown },
2152
+ ) => Promise<void>;
2153
+ lookup: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown;
2154
+ },
2155
+ ): ControlSurface & {
2156
+ cancelPendingPreflights(): void;
2157
+ cancelPendingPreflightsForConnection(connectionId: string): void;
2158
+ } {
1975
2159
  const unavailable = (operation: string, reason: string) => () => {
1976
2160
  throw Object.assign(new Error(`${operation} is unavailable: ${reason}`), { code: "unavailable" });
1977
2161
  };
@@ -2025,9 +2209,15 @@ function sdkControlSurface(
2025
2209
  return unavailable(operation, "no typed session seam is installed")();
2026
2210
  return ctx.sdkControl(operation, input);
2027
2211
  };
2028
- const pendingPreflightCancellations = new Set<() => void>();
2212
+ const pendingPreflightCancellations = new Map<string, { connectionId?: string; cancel: () => void }>();
2213
+ const preflightKey = (connectionId: string | undefined, correlation: { commandId: string; turnId: string }) =>
2214
+ `${connectionId ?? ""}\0${correlation.commandId}\0${correlation.turnId}`;
2029
2215
  const cancelPendingPreflights = () => {
2030
- for (const cancel of pendingPreflightCancellations) cancel();
2216
+ for (const { cancel } of pendingPreflightCancellations.values()) cancel();
2217
+ };
2218
+ const cancelPendingPreflightsForConnection = (connectionId: string) => {
2219
+ for (const entry of pendingPreflightCancellations.values())
2220
+ if (entry.connectionId === connectionId) entry.cancel();
2031
2221
  };
2032
2222
  const isSessionBusy = () => isBusy() || ctx.isIdle?.() === false;
2033
2223
  const awaitAbortReady = async () => {
@@ -2052,7 +2242,18 @@ function sdkControlSurface(
2052
2242
  throw Object.assign(new Error("clientRef must be a non-empty string of at most 128 characters."), {
2053
2243
  code: "invalid_input",
2054
2244
  });
2055
- if (trackReconciliation) admitPrompt(trimmedClientRef);
2245
+ if (trackReconciliation) {
2246
+ // Restart recovery must be committed before a tracked prompt reserves capacity,
2247
+ // otherwise it can admit against pre-hydration state or a lost clientRef.
2248
+ try {
2249
+ await awaitReconciliationReady();
2250
+ } catch {
2251
+ throw Object.assign(new Error("Prompt reconciliation state is unavailable; retry after restart."), {
2252
+ code: "unavailable",
2253
+ });
2254
+ }
2255
+ admitPrompt(trimmedClientRef);
2256
+ }
2056
2257
  try {
2057
2258
  if (forceFresh && isSessionBusy()) {
2058
2259
  throw Object.assign(
@@ -2100,13 +2301,32 @@ function sdkControlSurface(
2100
2301
  status: "rejected",
2101
2302
  error: Object.assign(new Error("Prompt preflight was cancelled before execution."), { code: "busy" }),
2102
2303
  });
2103
- pendingPreflightCancellations.add(cancelPreflight);
2104
- const onPreflightAccepted = () => {
2304
+ pendingPreflightCancellations.set(preflightKey(requesterConnectionId, correlation), {
2305
+ connectionId: requesterConnectionId,
2306
+ cancel: cancelPreflight,
2307
+ });
2308
+ const settleAccepted = async () => {
2105
2309
  if (preflightSettled) return;
2106
2310
  accepted = true;
2107
- onPromptAccepted(correlation, requesterConnectionId, trimmedClientRef, trackReconciliation);
2311
+ try {
2312
+ await onPromptAccepted(correlation, requesterConnectionId, trimmedClientRef, trackReconciliation);
2313
+ } catch (error) {
2314
+ // Durable acceptance failed, so the prompt was never accepted: reject the
2315
+ // control preflight and rethrow so the awaiting session does not execute it.
2316
+ accepted = false;
2317
+ onPromptAcceptFailed(correlation);
2318
+ settlePreflight({ status: "rejected", error });
2319
+ throw error;
2320
+ }
2108
2321
  settlePreflight({ status: "accepted" });
2109
2322
  };
2323
+ // Durable fence preferred; keep legacy onPreflightAccepted for hosts/tests that only fire the sync hook.
2324
+ const onPreflightAcceptCommit = settleAccepted;
2325
+ const onPreflightAccepted = () => {
2326
+ // Legacy fire-and-forget hook: the rejection is already reported through the
2327
+ // preflight promise, so swallow it here instead of leaking an unhandled rejection.
2328
+ void settleAccepted().catch(() => {});
2329
+ };
2110
2330
  // Do not acknowledge the prompt until AgentSession's async preflight
2111
2331
  // succeeds. The terminal result records correlation before agent_start can fire.
2112
2332
  let submission: Promise<void> | undefined;
@@ -2114,6 +2334,7 @@ function sdkControlSurface(
2114
2334
  submission = Promise.resolve(
2115
2335
  api.sendUserMessage(content, {
2116
2336
  ...(deliverAs ? { deliverAs } : !forceFresh && isBusy() ? { deliverAs: "steer" as const } : {}),
2337
+ onPreflightAcceptCommit,
2117
2338
  onPreflightAccepted,
2118
2339
  }),
2119
2340
  );
@@ -2146,24 +2367,35 @@ function sdkControlSurface(
2146
2367
  if (trackReconciliation) releasePromptAdmission(trimmedClientRef);
2147
2368
  throw error;
2148
2369
  } finally {
2149
- pendingPreflightCancellations.delete(cancelPreflight);
2370
+ pendingPreflightCancellations.delete(preflightKey(requesterConnectionId, correlation));
2150
2371
  }
2151
2372
  };
2152
- const surface: ControlSurface & { cancelPendingPreflights(): void } = {
2373
+ const surface: ControlSurface & {
2374
+ cancelPendingPreflights(): void;
2375
+ cancelPendingPreflightsForConnection(connectionId: string): void;
2376
+ } = {
2153
2377
  prompt: (text, images, clientRef) =>
2154
2378
  submitPrompt(text, images, false, undefined, true, controlRequesterContext.getStore(), clientRef, true),
2155
2379
  steer: text => sendSteer(text),
2156
2380
  followUp: text => submitPrompt(text, undefined, false, "followUp", false, controlRequesterContext.getStore()),
2157
- abort: () => {
2158
- cancelPendingPreflights();
2159
- ctx.abort();
2160
- return { aborted: true };
2381
+ abort: async () => {
2382
+ const requesterConnectionId = controlRequesterContext.getStore();
2383
+ const pendingPreflight = [...pendingPreflightCancellations.values()].some(
2384
+ entry => entry.connectionId === requesterConnectionId,
2385
+ );
2386
+ if (pendingPreflight) {
2387
+ if (requesterConnectionId) cancelPendingPreflightsForConnection(requesterConnectionId);
2388
+ else cancelPendingPreflights();
2389
+ return { aborted: true, disposition: "preflight_cancelled" };
2390
+ }
2391
+ return await abortOwnedPrompt(requesterConnectionId);
2161
2392
  },
2162
2393
  abortAndPrompt: async text => {
2163
2394
  await awaitAbortReady();
2164
2395
  return await submitPrompt(text, undefined, true, undefined, false, controlRequesterContext.getStore());
2165
2396
  },
2166
2397
  cancelPendingPreflights,
2398
+ cancelPendingPreflightsForConnection,
2167
2399
  answerAsk: (id, answer) => {
2168
2400
  const pending = pendingInteractive.get(id);
2169
2401
  if (!pending) throw Object.assign(new Error(`Ask ${id} was not found.`), { code: "resource_gone" });
@@ -2324,13 +2556,93 @@ function sdkControlSurface(
2324
2556
  code: "terminal_uncertain",
2325
2557
  });
2326
2558
  },
2327
- invokeSkill: (name, args) => {
2559
+ invokeSkill: async (name, args, clientRef) => {
2328
2560
  if (!bindings.has("invokeSkill") || !ctx.invokeSkill)
2329
2561
  return unavailable("skill.invoke", "no skill invocation seam is installed")();
2330
2562
 
2331
2563
  if (typeof args !== "undefined" && typeof args !== "string")
2332
2564
  throw Object.assign(new Error("skill.invoke args must be a string."), { code: "invalid_input" });
2333
- return ctx.invokeSkill(name, args);
2565
+ const trimmedClientRef =
2566
+ typeof clientRef === "string" ? clientRef.trim() : clientRef === undefined ? undefined : "";
2567
+ if (clientRef !== undefined && (!trimmedClientRef || trimmedClientRef.length > 128))
2568
+ throw Object.assign(new Error("clientRef must be a non-empty string of at most 128 characters."), {
2569
+ code: "invalid_input",
2570
+ });
2571
+ const commandId = crypto.randomUUID();
2572
+ const turnId = crypto.randomUUID();
2573
+ const correlation = { commandId, turnId };
2574
+ if (skillRecon) {
2575
+ try {
2576
+ await awaitReconciliationReady();
2577
+ } catch {
2578
+ throw Object.assign(new Error("Skill reconciliation state is unavailable; retry after restart."), {
2579
+ code: "unavailable",
2580
+ });
2581
+ }
2582
+ skillRecon.admit(trimmedClientRef);
2583
+ }
2584
+ const { promise: acceptedP, resolve, reject } = Promise.withResolvers<Record<string, unknown>>();
2585
+ let phase: "pending" | "accepted" | "rejected" = "pending";
2586
+ const settleAccept = (value: Record<string, unknown>) => {
2587
+ if (phase !== "pending") return;
2588
+ phase = "accepted";
2589
+ resolve(value);
2590
+ };
2591
+ const settleReject = (error: unknown) => {
2592
+ if (phase !== "pending") return;
2593
+ phase = "rejected";
2594
+ reject(error);
2595
+ };
2596
+ let prepared: { name: string; path: string; lineCount?: number; cleanedArgs?: string } | undefined;
2597
+ const run = Promise.resolve(
2598
+ ctx.invokeSkill(name, args as string | undefined, {
2599
+ onSkillPrepared: meta => {
2600
+ prepared = meta;
2601
+ },
2602
+ onPreflightAcceptCommit: async () => {
2603
+ const meta = prepared ?? { name: String(name), path: "" };
2604
+ if (skillRecon)
2605
+ await skillRecon.noteAccepted(correlation, trimmedClientRef, { skillName: meta.name });
2606
+ settleAccept({
2607
+ accepted: true,
2608
+ commandId,
2609
+ turnId,
2610
+ name: meta.name,
2611
+ path: meta.path,
2612
+ ...(meta.lineCount !== undefined ? { lineCount: meta.lineCount } : {}),
2613
+ ...(meta.cleanedArgs !== undefined ? { args: meta.cleanedArgs } : {}),
2614
+ ...(trimmedClientRef ? { clientRef: trimmedClientRef } : {}),
2615
+ });
2616
+ },
2617
+ }),
2618
+ ).then(
2619
+ result => {
2620
+ if (phase === "pending") {
2621
+ // Completed without fence (e.g. queue path) — still surface result.
2622
+ settleAccept({
2623
+ accepted: true,
2624
+ commandId,
2625
+ turnId,
2626
+ ...(typeof result === "object" && result ? (result as object) : {}),
2627
+ ...(trimmedClientRef ? { clientRef: trimmedClientRef } : {}),
2628
+ });
2629
+ } else if (skillRecon) {
2630
+ void skillRecon.noteTransition(correlation, { type: "agent_end" });
2631
+ }
2632
+ return result;
2633
+ },
2634
+ error => {
2635
+ if (phase === "pending") {
2636
+ if (skillRecon) skillRecon.release(trimmedClientRef);
2637
+ settleReject(error);
2638
+ } else if (skillRecon) {
2639
+ void skillRecon.noteTransition(correlation, { type: "agent_failed", error });
2640
+ }
2641
+ throw error;
2642
+ },
2643
+ );
2644
+ void run.catch(() => {});
2645
+ return await acceptedP;
2334
2646
  },
2335
2647
  setPlanMode: async on => {
2336
2648
  if (!bindings.has("setPlanMode") || !ctx.setPlanMode)
@@ -2866,6 +3178,18 @@ export async function ensureConfiguredProviderDaemons(
2866
3178
  if (isSlackConfigured(cfg)) await ensureProviderDaemon("slack", settings);
2867
3179
  }
2868
3180
 
3181
+ /**
3182
+ * Classify whether a session identity event must await notification endpoint startup.
3183
+ * Only an interactive selector resume may defer this ancillary startup; all other
3184
+ * events, including branches and unknown origins, fail closed to awaiting it.
3185
+ */
3186
+ export function shouldAwaitNotificationStartup(event: {
3187
+ type: "session_switch" | "session_branch";
3188
+ transition?: { origin: string };
3189
+ }): boolean {
3190
+ return event.type !== "session_switch" || event.transition?.origin !== INTERACTIVE_SELECTOR_RESUME_ORIGIN;
3191
+ }
3192
+
2869
3193
  export function createNotificationsExtension(
2870
3194
  api: ExtensionAPI,
2871
3195
  options: {
@@ -2905,10 +3229,15 @@ export function createNotificationsExtension(
2905
3229
  const cleanupRetries = new Map<string, SessionRuntime>();
2906
3230
  const sessionStartPromises = new Map<string, Promise<SessionStartResult>>();
2907
3231
  const branchStartupTasks = new Set<Promise<void>>();
3232
+ const sessionLifecycleTasks = new Set<Promise<void>>();
2908
3233
  let activeRuntimeId: string | undefined;
2909
3234
  let identityControlInFlight = false;
2910
3235
  let deferredIdentityRotation:
2911
- | { event: { previousSessionFile?: string }; ctx: ExtensionContext; awaitStartup: boolean }
3236
+ | {
3237
+ event: { previousSessionFile?: string; transition?: { origin: string } };
3238
+ ctx: ExtensionContext;
3239
+ awaitStartup: boolean;
3240
+ }
2912
3241
  | undefined;
2913
3242
  let extensionShuttingDown = false;
2914
3243
 
@@ -2969,6 +3298,7 @@ export function createNotificationsExtension(
2969
3298
  const requestedRuntime = retryRuntime ?? activeRuntime;
2970
3299
  if (expectedRuntime && requestedRuntime !== expectedRuntime) return false;
2971
3300
  if (reason === "session" && requestedRuntime) {
3301
+ requestedRuntime.inboundFenced = true;
2972
3302
  requestedRuntime.stopping = true;
2973
3303
  requestedRuntime.abortEphemeralTurns();
2974
3304
  }
@@ -3180,7 +3510,6 @@ export function createNotificationsExtension(
3180
3510
  if (lifecycleRequired && !lifecycleAgentDir)
3181
3511
  return failLifecycleStartup("failed", "Lifecycle SDK startup requires an agent directory.");
3182
3512
 
3183
- const gateOptions = new Map<string, string[]>();
3184
3513
  const pendingInteractive = new Map<string, PendingInteractiveAsk>();
3185
3514
  const pendingPromptCorrelations: Array<{ commandId: string; turnId: string }> = [];
3186
3515
  const tag = sessionTag(id);
@@ -3218,32 +3547,86 @@ export function createNotificationsExtension(
3218
3547
 
3219
3548
  const revisions = new RevisionStore(id, Date.now, { storageDir: stateRoot });
3220
3549
  let host: SessionSdkHost | undefined;
3550
+ let disposeUiAnswerSource: (() => void) | undefined;
3221
3551
  const installProviderDefinitions = (capability: string, definitions: unknown) => {
3222
3552
  validateProviderDefinitions(capability, definitions);
3223
- if (capability !== "permission") return;
3224
- ctx.setSdkPermissionProvider?.(async (toolCall, permissionOptions, signal) => {
3225
- const result = await host!.reverse.request("permission", "request", {
3226
- toolCall,
3227
- options: permissionOptions,
3228
- aborted: signal?.aborted === true,
3229
- });
3230
- if (!result || typeof result !== "object")
3553
+ if (capability === "permission") {
3554
+ ctx.setSdkPermissionProvider?.(async (toolCall, permissionOptions, signal) => {
3555
+ const result = await host!.reverse.request(
3556
+ "permission",
3557
+ "request",
3558
+ {
3559
+ toolCall,
3560
+ options: permissionOptions,
3561
+ },
3562
+ signal,
3563
+ );
3564
+ if (!result || typeof result !== "object")
3565
+ throw new Error("permission provider returned an invalid response");
3566
+ const response = result as { outcome?: unknown; optionId?: unknown; kind?: unknown };
3567
+ if (response.outcome === "cancelled") return { outcome: "cancelled" };
3568
+ if (response.outcome === "selected" && typeof response.optionId === "string")
3569
+ return {
3570
+ outcome: "selected",
3571
+ optionId: response.optionId,
3572
+ ...(typeof response.kind === "string"
3573
+ ? { kind: response.kind as "allow_once" | "allow_always" | "reject_once" | "reject_always" }
3574
+ : {}),
3575
+ };
3231
3576
  throw new Error("permission provider returned an invalid response");
3232
- const response = result as { outcome?: unknown; optionId?: unknown; kind?: unknown };
3233
- if (response.outcome === "cancelled") return { outcome: "cancelled" };
3234
- if (response.outcome === "selected" && typeof response.optionId === "string")
3235
- return {
3236
- outcome: "selected",
3237
- optionId: response.optionId,
3238
- ...(typeof response.kind === "string"
3239
- ? { kind: response.kind as "allow_once" | "allow_always" | "reject_once" | "reject_always" }
3240
- : {}),
3241
- };
3242
- throw new Error("permission provider returned an invalid response");
3243
- });
3577
+ });
3578
+ return;
3579
+ }
3580
+ if (capability === "ui") {
3581
+ disposeUiAnswerSource?.();
3582
+ disposeUiAnswerSource = registerAskAnswerSource(
3583
+ id,
3584
+ createSdkUiAskAnswerSource(
3585
+ async (params, signal) => await host!.reverse.request("ui", "ui.elicit", params, signal),
3586
+ ),
3587
+ );
3588
+ return;
3589
+ }
3590
+ if (capability !== "fs") return;
3591
+ // Only advertise the methods the client actually declared; a read-only client
3592
+ // must keep using the local write path instead of failing against the bridge.
3593
+ const names = new Set(
3594
+ (Array.isArray(definitions) ? definitions : [])
3595
+ .map(definition =>
3596
+ definition && typeof definition === "object" ? (definition as { name?: unknown }).name : undefined,
3597
+ )
3598
+ .filter((name): name is string => typeof name === "string"),
3599
+ );
3600
+ const canRead = names.size === 0 || names.has("fs.readTextFile");
3601
+ const canWrite = names.size === 0 || names.has("fs.writeTextFile");
3602
+ const bridge: ClientBridge = {
3603
+ capabilities: { readTextFile: canRead, writeTextFile: canWrite },
3604
+ deferAgentInitiatedTurns: true,
3605
+ async readTextFile(params) {
3606
+ const result = await host!.reverse.request("fs", "fs.readTextFile", params);
3607
+ if (
3608
+ !result ||
3609
+ typeof result !== "object" ||
3610
+ typeof (result as { content?: unknown }).content !== "string"
3611
+ )
3612
+ throw new Error("fs provider returned an invalid read response");
3613
+ return (result as { content: string }).content;
3614
+ },
3615
+ async writeTextFile(params) {
3616
+ await host!.reverse.request("fs", "fs.writeTextFile", params);
3617
+ },
3618
+ };
3619
+ if (!canRead) delete bridge.readTextFile;
3620
+ if (!canWrite) delete bridge.writeTextFile;
3621
+ ctx.setSdkClientBridge?.(bridge);
3244
3622
  };
3245
3623
  const removeProviderDefinitions = (capability: string) => {
3246
3624
  if (capability === "permission") ctx.setSdkPermissionProvider?.(undefined);
3625
+ if (capability === "fs") ctx.setSdkClientBridge?.(undefined);
3626
+ if (capability === "ui") {
3627
+ disposeUiAnswerSource?.();
3628
+ disposeUiAnswerSource = undefined;
3629
+ }
3247
3630
  };
3248
3631
 
3249
3632
  const hostCapCache = new Map<string, ReadonlySet<string>>();
@@ -3254,6 +3637,8 @@ export function createNotificationsExtension(
3254
3637
  const PROMPT_SUBMISSION_TTL_MS = 5 * 60_000;
3255
3638
  const PROMPT_TERMINAL_TOMBSTONE_CAPACITY = 256;
3256
3639
  const PROMPT_TERMINAL_TOMBSTONE_TTL_MS = 15 * 60_000;
3640
+ // SDK-owned terminalization grace; injectable in tests, never a user setting.
3641
+ const PROMPT_TERMINALIZATION_GRACE_MS = 10_000;
3257
3642
  const promptSubmissionKey = (correlation: { commandId: string; turnId: string }) =>
3258
3643
  `${correlation.commandId}:${correlation.turnId}`;
3259
3644
  type PromptLifecycleFrame =
@@ -3263,6 +3648,7 @@ export function createNotificationsExtension(
3263
3648
  commandId?: string;
3264
3649
  turnId?: string;
3265
3650
  finalText?: string;
3651
+ outcome?: SdkPromptTerminalOutcome;
3266
3652
  }
3267
3653
  | {
3268
3654
  type: "agent_failed";
@@ -3270,6 +3656,7 @@ export function createNotificationsExtension(
3270
3656
  commandId: string;
3271
3657
  turnId: string;
3272
3658
  error: { code: string; message: string };
3659
+ outcome?: SdkPromptTerminalOutcome;
3273
3660
  };
3274
3661
  type PromptSubmission = {
3275
3662
  acknowledged: boolean;
@@ -3279,50 +3666,95 @@ export function createNotificationsExtension(
3279
3666
  error: unknown;
3280
3667
  terminal: boolean;
3281
3668
  retainCorrelation: boolean;
3669
+ /** Fatal/uncertain closure: transport-level only, never a semantic terminal. */
3670
+ fatal?: boolean;
3282
3671
  createdAt: number;
3672
+ deadlineMs: number;
3673
+ deadlineTimer?: Parameters<typeof clearTimeout>[0];
3674
+ phase: "active" | "outcome_claimed" | "terminalizing" | "publication_closed" | "delivered";
3675
+ outcome?: SdkPromptTerminalOutcome;
3676
+ /** Agent-owned resource run captured at acceptance; cleanup targets only this handle. */
3677
+ executionHandle?: string;
3283
3678
  bufferedFrames: Array<PromptLifecycleFrame | Record<string, unknown>>;
3284
3679
  };
3285
3680
  const promptSubmissions = new Map<string, PromptSubmission>();
3286
- const promptTerminalTombstones = new Map<string, number>();
3681
+ /** Connections fenced by a fatal prompt closure; their later frames are refused. */
3682
+ const fencedConnections = new Set<string>();
3683
+ let cancelPreflightsForConnection: ((connectionId: string) => void) | undefined;
3684
+ const promptTerminalTombstones = new Map<string, { connectionId: string; expiresAt: number }>();
3287
3685
  // Authoritative bounded reconciliation state for Q26 turn.prompt_status
3288
3686
  // (contract documented in ./prompt-reconciliation and ../prompt-status).
3289
3687
  // Active records never age into terminal; documented TTL/capacity
3290
3688
  // eviction is the only removal, after which lookups report `unknown`.
3689
+ const sessionFile =
3690
+ typeof ctx.sessionManager?.getSessionFile === "function" ? ctx.sessionManager.getSessionFile() : null;
3691
+ const reconciliationSessionId =
3692
+ typeof ctx.sessionManager?.getSessionId === "function" ? ctx.sessionManager.getSessionId() : "";
3693
+ const durableStore =
3694
+ sessionFile && reconciliationSessionId
3695
+ ? createReconciliationStore({ sessionFile, sessionId: String(reconciliationSessionId) })
3696
+ : null;
3697
+ const kindReconciliation = createKindAwareReconciliation({ store: durableStore });
3698
+ // Restart recovery must commit before any prompt is admitted; otherwise a new
3699
+ // admission can race the hydrated full-state replacement.
3700
+ const reconciliationReady: Promise<void> = durableStore
3701
+ ? kindReconciliation.hydrateFromStore()
3702
+ : Promise.resolve();
3703
+ // Never let a hydration rejection become an unhandled rejection; tracked prompts
3704
+ // re-await the same promise and fail closed below.
3705
+ void reconciliationReady.catch(() => {});
3706
+ // Backward-compatible process-local prompt reconciler kept for unit-test isolation;
3707
+ // production path uses kindReconciliation for prompt+skill.
3291
3708
  const reconciliation = createPromptReconciliation();
3292
- const admitPromptSubmission = reconciliation.admit;
3293
- const notePromptReconciliationAccepted = reconciliation.noteAccepted;
3294
- const lookupPromptStatus = reconciliation.lookup;
3709
+ const admitPromptSubmission = (clientRef?: string) => kindReconciliation.admit("prompt", clientRef);
3710
+ const notePromptReconciliationAccepted = async (
3711
+ correlation: { commandId: string; turnId: string },
3712
+ clientRef?: string,
3713
+ ) => {
3714
+ await kindReconciliation.noteAccepted("prompt", correlation, clientRef);
3715
+ };
3716
+ const lookupPromptStatus = (selector: { commandId?: string; turnId?: string; clientRef?: string }) =>
3717
+ kindReconciliation.lookup("prompt", selector);
3718
+ const releasePromptAdmission = (clientRef?: string) => kindReconciliation.releaseAdmission("prompt", clientRef);
3295
3719
  const removePendingPromptCorrelation = (correlation: { commandId: string; turnId: string }) => {
3296
3720
  const pendingIndex = pendingPromptCorrelations.findIndex(
3297
3721
  candidate => candidate.commandId === correlation.commandId && candidate.turnId === correlation.turnId,
3298
3722
  );
3299
3723
  if (pendingIndex !== -1) pendingPromptCorrelations.splice(pendingIndex, 1);
3300
3724
  };
3301
- const addTerminalTombstone = (key: string, now = Date.now()) => {
3725
+ const addTerminalTombstone = (key: string, connectionId: string, now = Date.now()) => {
3302
3726
  promptTerminalTombstones.delete(key);
3303
- promptTerminalTombstones.set(key, now + PROMPT_TERMINAL_TOMBSTONE_TTL_MS);
3727
+ promptTerminalTombstones.set(key, { connectionId, expiresAt: now + PROMPT_TERMINAL_TOMBSTONE_TTL_MS });
3304
3728
  while (promptTerminalTombstones.size > PROMPT_TERMINAL_TOMBSTONE_CAPACITY)
3305
3729
  promptTerminalTombstones.delete(promptTerminalTombstones.keys().next().value!);
3306
3730
  };
3307
3731
  const finalizePrompt = (key: string, correlation: { commandId: string; turnId: string }) => {
3732
+ const submission = promptSubmissions.get(key);
3733
+ if (submission?.deadlineTimer) clearTimeout(submission.deadlineTimer);
3308
3734
  promptSubmissions.delete(key);
3309
3735
  removePendingPromptCorrelation(correlation);
3310
- addTerminalTombstone(key);
3736
+ if (submission) addTerminalTombstone(key, submission.connectionId);
3311
3737
  };
3312
3738
  const expirePromptDelivery = (key: string, submission: PromptSubmission) => {
3739
+ if (!submission.terminal) return;
3740
+ if (submission.deadlineTimer) clearTimeout(submission.deadlineTimer);
3313
3741
  promptSubmissions.delete(key);
3314
- if (!submission.terminal && submission.retainCorrelation) return;
3742
+ // A fatal closure is transport-level, not a committed semantic terminal: the
3743
+ // durable record stays authoritative, so it must never leave a tombstone.
3744
+ if (submission.fatal) return;
3315
3745
  const [commandId, turnId] = key.split(":", 2);
3316
3746
  if (!commandId || !turnId) return;
3317
3747
  removePendingPromptCorrelation({ commandId, turnId });
3318
- addTerminalTombstone(key);
3748
+ addTerminalTombstone(key, submission.connectionId);
3319
3749
  };
3320
3750
  const cleanupPromptRecords = (now = Date.now()) => {
3751
+ kindReconciliation.cleanup();
3321
3752
  reconciliation.cleanup();
3322
- for (const [key, expiresAt] of promptTerminalTombstones)
3323
- if (expiresAt <= now) promptTerminalTombstones.delete(key);
3753
+ for (const [key, tombstone] of promptTerminalTombstones)
3754
+ if (tombstone.expiresAt <= now) promptTerminalTombstones.delete(key);
3324
3755
  for (const [key, submission] of promptSubmissions)
3325
- if (submission.createdAt + PROMPT_SUBMISSION_TTL_MS <= now) expirePromptDelivery(key, submission);
3756
+ if (submission.terminal && submission.createdAt + PROMPT_SUBMISSION_TTL_MS <= now)
3757
+ expirePromptDelivery(key, submission);
3326
3758
  };
3327
3759
  const abandonPrompt = (submission: PromptSubmission) => {
3328
3760
  submission.abandoned = true;
@@ -3356,7 +3788,10 @@ export function createNotificationsExtension(
3356
3788
  logger.warn(`sdk: correlated lifecycle delivery failed: ${String(error)}`);
3357
3789
  abandonPrompt(submission);
3358
3790
  }
3359
- if (submission.terminal) finalizePrompt(key, correlation);
3791
+ if (submission.terminal) {
3792
+ submission.phase = "delivered";
3793
+ finalizePrompt(key, correlation);
3794
+ }
3360
3795
  };
3361
3796
  const emitPromptEvent = (event: AgentSessionEvent) => {
3362
3797
  if (!runtime?.activePromptCorrelation) return;
@@ -3392,11 +3827,12 @@ export function createNotificationsExtension(
3392
3827
  }
3393
3828
  }
3394
3829
  if (submission.terminal) {
3830
+ submission.phase = "delivered";
3395
3831
  const [commandId, turnId] = key.split(":", 2);
3396
3832
  if (commandId && turnId) finalizePrompt(key, { commandId, turnId });
3397
3833
  }
3398
3834
  };
3399
- const recordPromptAccepted = (
3835
+ const recordPromptAccepted = async (
3400
3836
  correlation: { commandId: string; turnId: string },
3401
3837
  requesterConnectionId?: string,
3402
3838
  clientRef?: string,
@@ -3405,18 +3841,23 @@ export function createNotificationsExtension(
3405
3841
  if (!requesterConnectionId) {
3406
3842
  // No delivery owner: tracked prompts cannot be reconciled. Release
3407
3843
  // their admission reservation instead of leaking the active slot.
3408
- if (trackReconciliation) reconciliation.releaseAdmission(clientRef);
3844
+ if (trackReconciliation) releasePromptAdmission(clientRef);
3409
3845
  return;
3410
3846
  }
3411
- if (trackReconciliation) notePromptReconciliationAccepted(correlation, clientRef);
3847
+ // Register delivery ownership synchronously before any await so post-accept
3848
+ // failures that race the durable write still emit correlated terminals.
3412
3849
  cleanupPromptRecords();
3413
3850
  while (promptSubmissions.size >= PROMPT_SUBMISSION_CAPACITY) {
3414
- const oldest = promptSubmissions.entries().next().value as [string, PromptSubmission] | undefined;
3415
- if (!oldest) break;
3416
- expirePromptDelivery(oldest[0], oldest[1]);
3851
+ const oldestTerminal = [...promptSubmissions.entries()].find(([, submission]) => submission.terminal);
3852
+ if (!oldestTerminal)
3853
+ throw Object.assign(
3854
+ new Error("Too many active prompt submissions; reconcile or await terminal state."),
3855
+ { code: "reconciliation_capacity" },
3856
+ );
3857
+ expirePromptDelivery(oldestTerminal[0], oldestTerminal[1]);
3417
3858
  }
3418
3859
  pendingPromptCorrelations.push(correlation);
3419
- promptSubmissions.set(promptSubmissionKey(correlation), {
3860
+ const submission: PromptSubmission = {
3420
3861
  acknowledged: false,
3421
3862
  connectionId: requesterConnectionId,
3422
3863
  abandoned: false,
@@ -3425,30 +3866,217 @@ export function createNotificationsExtension(
3425
3866
  terminal: false,
3426
3867
  retainCorrelation: trackReconciliation,
3427
3868
  createdAt: Date.now(),
3869
+ deadlineMs: settings?.get("sdk.promptDeadlineMs") ?? 1_800_000,
3870
+ phase: "active",
3871
+ // Bound to the Agent run at `agent_start`; acceptance precedes execution.
3872
+ executionHandle: undefined,
3428
3873
  bufferedFrames: [],
3429
- });
3874
+ };
3875
+ promptSubmissions.set(promptSubmissionKey(correlation), submission);
3876
+ if (trackReconciliation) await notePromptReconciliationAccepted(correlation, clientRef);
3877
+ submission.deadlineTimer = setTimeout(() => {
3878
+ void terminalizePrompt(
3879
+ correlation,
3880
+ {
3881
+ kind: "failed",
3882
+ code: "prompt_deadline_exceeded",
3883
+ message: "Prompt deadline exceeded.",
3884
+ provenance: "deadline",
3885
+ },
3886
+ { fence: true },
3887
+ );
3888
+ }, submission.deadlineMs);
3889
+ };
3890
+ /** Roll back process-local registration when durable acceptance failed. */
3891
+ const discardPromptAcceptance = (correlation: { commandId: string; turnId: string }) => {
3892
+ promptSubmissions.delete(promptSubmissionKey(correlation));
3893
+ removePendingPromptCorrelation(correlation);
3430
3894
  };
3431
3895
  const recordPromptTerminal = (correlation: { commandId: string; turnId: string } | undefined) => {
3432
3896
  if (!correlation) return false;
3433
- cleanupPromptRecords();
3434
- const key = promptSubmissionKey(correlation);
3435
- if (promptTerminalTombstones.has(key)) return false;
3436
- const submission = promptSubmissions.get(key);
3897
+ const submission = promptSubmissions.get(promptSubmissionKey(correlation));
3437
3898
  if (!submission || submission.terminal) return false;
3438
3899
  submission.terminal = true;
3900
+ submission.phase = "publication_closed";
3439
3901
  return true;
3440
3902
  };
3903
+ /**
3904
+ * Fail-closed teardown: the durable claim survives for restart recovery, but the
3905
+ * waiting client must still reject exactly once, so an infrastructure failure frame
3906
+ * is delivered before the connection is released.
3907
+ */
3908
+ const failPromptClosed = (
3909
+ correlation: { commandId: string; turnId: string },
3910
+ submission: PromptSubmission,
3911
+ code: string,
3912
+ message: string,
3913
+ ) => {
3914
+ if (submission.deadlineTimer) clearTimeout(submission.deadlineTimer);
3915
+ if (!submission.terminal) {
3916
+ submission.terminal = true;
3917
+ submission.fatal = true;
3918
+ submission.phase = "publication_closed";
3919
+ // Deliver the transport failure directly to the accepted requester instead of
3920
+ // `emitPromptLifecycle`: this must never enter the resumable semantic event
3921
+ // ring, finalize reconciliation, or tombstone the correlation, because the
3922
+ // durable claim stays authoritative for Q26 and restart recovery.
3923
+ // Delivered regardless of acknowledgement: a pre-ack fatal failure must still
3924
+ // settle the waiting request instead of leaving it pending forever.
3925
+ if (runtime)
3926
+ try {
3927
+ runtime.server.sendTo(
3928
+ submission.connectionId,
3929
+ JSON.stringify({
3930
+ type: "agent_failed",
3931
+ sessionId: runtime.id,
3932
+ ...correlation,
3933
+ error: { code, message },
3934
+ }),
3935
+ );
3936
+ } catch (error) {
3937
+ logger.warn(`sdk: fatal prompt closure delivery failed: ${String(error)}`);
3938
+ }
3939
+ }
3940
+ // Fence the endpoint. The native transport exposes no close primitive, so the
3941
+ // connection is marked failed (every later inbound frame from it is refused),
3942
+ // its reverse leases are released, and its deliveries are abandoned. The durable
3943
+ // record stays active for Q26 and restart recovery.
3944
+ cancelPreflightsForConnection?.(submission.connectionId);
3945
+ fencedConnections.add(submission.connectionId);
3946
+ host?.handleDisconnect(submission.connectionId);
3947
+ for (const [key, owned] of promptSubmissions)
3948
+ if (owned.connectionId === submission.connectionId) {
3949
+ abandonPrompt(owned);
3950
+ const [commandId, turnId] = key.split(":", 2);
3951
+ if (commandId && turnId) removePendingPromptCorrelation({ commandId, turnId });
3952
+ }
3953
+ if (
3954
+ runtime?.activePromptCorrelation?.commandId === correlation.commandId &&
3955
+ runtime.activePromptCorrelation.turnId === correlation.turnId
3956
+ )
3957
+ runtime.activePromptCorrelation = undefined;
3958
+ };
3959
+ const bindPromptExecutionHandle = (
3960
+ correlation: { commandId: string; turnId: string },
3961
+ handle: string | undefined,
3962
+ ) => {
3963
+ const submission = promptSubmissions.get(promptSubmissionKey(correlation));
3964
+ if (submission) submission.executionHandle = handle;
3965
+ };
3966
+ const terminalizePrompt = async (
3967
+ correlation: { commandId: string; turnId: string },
3968
+ requestedOutcome: SdkPromptTerminalOutcome,
3969
+ // Cleanup-initiated claims (cancel, deadline, owner disconnect) must abort the
3970
+ // run and prove settlement. A natural `agent_end`/`agent_failed` already unwound,
3971
+ // so aborting there would cancel the next turn instead of fencing this one.
3972
+ options: { fence?: boolean } = {},
3973
+ extra?: { finalText?: string; error?: { code: string; message: string } },
3974
+ ) => {
3975
+ const submission = promptSubmissions.get(promptSubmissionKey(correlation));
3976
+ if (!submission || submission.terminal || submission.phase !== "active") return;
3977
+ submission.phase = "outcome_claimed";
3978
+ let winner: SdkPromptTerminalOutcome;
3979
+ try {
3980
+ winner = await kindReconciliation.claimPendingOutcome(correlation, requestedOutcome);
3981
+ } catch (error) {
3982
+ // The claim is the durability boundary: without it nothing may be published
3983
+ // and the endpoint must fail closed so the client rejects exactly once. The
3984
+ // last durable record stays active for restart recovery.
3985
+ logger.warn(`sdk: prompt claim persistence failed: ${String(error)}`);
3986
+ failPromptClosed(correlation, submission, "terminal_uncertain", "Prompt reconciliation is unavailable.");
3987
+ return;
3988
+ }
3989
+ submission.outcome = winner;
3990
+ submission.phase = "terminalizing";
3991
+ if (options.fence) {
3992
+ const seam = ctx as typeof ctx & {
3993
+ abortPromptAndWait?: (
3994
+ handle: string,
3995
+ options: { graceMs: number },
3996
+ ) => Promise<{ status: "settled" } | { status: "unfenced" }>;
3997
+ };
3998
+ // Only the handle captured for this correlation may be fenced; a later run
3999
+ // must never be aborted by an older prompt's cleanup.
4000
+ if (typeof seam.abortPromptAndWait !== "function" || !submission.executionHandle) {
4001
+ failPromptClosed(
4002
+ correlation,
4003
+ submission,
4004
+ "terminal_uncertain",
4005
+ "Prompt resources could not be fenced with an exact run handle.",
4006
+ );
4007
+ return;
4008
+ }
4009
+ let proof: { status: "settled" } | { status: "unfenced" };
4010
+ try {
4011
+ proof = await seam.abortPromptAndWait(submission.executionHandle, {
4012
+ graceMs: PROMPT_TERMINALIZATION_GRACE_MS,
4013
+ });
4014
+ } catch (error) {
4015
+ logger.warn(`sdk: prompt resource fencing failed: ${String(error)}`);
4016
+ failPromptClosed(
4017
+ correlation,
4018
+ submission,
4019
+ "terminal_uncertain",
4020
+ "Prompt resources could not be settled before terminalization.",
4021
+ );
4022
+ return;
4023
+ }
4024
+ if (proof?.status !== "settled") {
4025
+ // No settlement proof: never publish a normal terminal. The durable
4026
+ // pending claim stays active so restart recovery finalizes it.
4027
+ failPromptClosed(
4028
+ correlation,
4029
+ submission,
4030
+ "terminal_uncertain",
4031
+ "Prompt resources did not settle before the terminalization grace expired.",
4032
+ );
4033
+ return;
4034
+ }
4035
+ }
4036
+ try {
4037
+ await kindReconciliation.finalizePromptOutcome(correlation, winner, extra?.error);
4038
+ } catch (error) {
4039
+ // The durable pending claim survives; publishing an unpersisted terminal
4040
+ // would contradict it, so fail the endpoint closed instead.
4041
+ logger.warn(`sdk: prompt terminal persistence failed: ${String(error)}`);
4042
+ failPromptClosed(correlation, submission, "terminal_uncertain", "Prompt reconciliation is unavailable.");
4043
+ return;
4044
+ }
4045
+ if (submission.deadlineTimer) clearTimeout(submission.deadlineTimer);
4046
+ if (!recordPromptTerminal(correlation) || !runtime) return;
4047
+ if (winner.kind === "failed") {
4048
+ emitPromptLifecycle(correlation, {
4049
+ type: "agent_failed",
4050
+ sessionId: runtime.id,
4051
+ ...correlation,
4052
+ error: extra?.error ?? { code: winner.code, message: winner.message },
4053
+ outcome: winner,
4054
+ });
4055
+ } else {
4056
+ emitPromptLifecycle(correlation, {
4057
+ type: "agent_end",
4058
+ sessionId: runtime.id,
4059
+ ...correlation,
4060
+ ...(extra?.finalText ? { finalText: extra.finalText } : {}),
4061
+ outcome: winner,
4062
+ });
4063
+ }
4064
+ };
3441
4065
  const emitPromptFailure = (correlation: { commandId: string; turnId: string }, error: unknown) => {
3442
4066
  const sanitized = sanitizePromptFailure(error);
3443
- reconciliation.noteTransition(correlation, { type: "agent_failed", error: sanitized });
3444
- const submission = promptSubmissions.get(promptSubmissionKey(correlation));
3445
- if (!submission || !runtime || !recordPromptTerminal(correlation)) return;
3446
- emitPromptLifecycle(correlation, {
3447
- type: "agent_failed",
3448
- sessionId: runtime.id,
3449
- ...correlation,
3450
- error: sanitized,
3451
- });
4067
+ void terminalizePrompt(
4068
+ correlation,
4069
+ {
4070
+ kind: "failed",
4071
+ code: "prompt_failed",
4072
+ message: sanitized.message,
4073
+ provenance: "agent_failed",
4074
+ },
4075
+ {},
4076
+ // The normalized outcome is the ACP contract; the wire error keeps the
4077
+ // existing sanitized discriminator so SDK clients keep branching on it.
4078
+ { error: sanitized },
4079
+ );
3452
4080
  };
3453
4081
  const recordPromptFailure = (correlation: { commandId: string; turnId: string }, error: unknown) => {
3454
4082
  const submission = promptSubmissions.get(promptSubmissionKey(correlation));
@@ -3491,6 +4119,7 @@ export function createNotificationsExtension(
3491
4119
  },
3492
4120
  configOverrides,
3493
4121
  lookupPromptStatus,
4122
+ selector => kindReconciliation.lookup("skill", selector),
3494
4123
  ),
3495
4124
  id,
3496
4125
  revisions,
@@ -3504,14 +4133,39 @@ export function createNotificationsExtension(
3504
4133
  () => runtime?.busy === true || pendingPromptCorrelations.length > 0,
3505
4134
  recordPromptAccepted,
3506
4135
  recordPromptFailure,
4136
+ discardPromptAcceptance,
3507
4137
  () => runtime?.stopping !== true,
3508
4138
  trackGateResolution,
3509
4139
  admitPromptSubmission,
3510
- reconciliation.releaseAdmission,
4140
+ releasePromptAdmission,
4141
+ () => reconciliationReady,
3511
4142
  settings,
3512
4143
  configOverrides,
3513
4144
  configRevision,
4145
+ async connectionId => {
4146
+ const active = [...promptSubmissions.entries()].find(
4147
+ ([, submission]) => submission.connectionId === connectionId && !submission.terminal,
4148
+ );
4149
+ if (!active) return { aborted: true, disposition: "idle" as const };
4150
+ const [commandId, turnId] = active[0].split(":", 2);
4151
+ if (commandId && turnId)
4152
+ await terminalizePrompt(
4153
+ { commandId, turnId },
4154
+ { kind: "stopped", reason: "cancelled", provenance: "client_cancel" },
4155
+ { fence: true },
4156
+ );
4157
+ return { aborted: true, disposition: "cancelled" as const };
4158
+ },
4159
+ {
4160
+ admit: (clientRef?: string) => kindReconciliation.admit("skill", clientRef),
4161
+ release: (clientRef?: string) => kindReconciliation.releaseAdmission("skill", clientRef),
4162
+ noteAccepted: (correlation, clientRef, extra) =>
4163
+ kindReconciliation.noteAccepted("skill", correlation, clientRef, extra),
4164
+ noteTransition: (correlation, frame) => kindReconciliation.noteTransition("skill", correlation, frame),
4165
+ lookup: selector => kindReconciliation.lookup("skill", selector),
4166
+ },
3514
4167
  );
4168
+ cancelPreflightsForConnection = controlSurface.cancelPendingPreflightsForConnection;
3515
4169
  const abandonPromptResponse = (connectionId: string, frame: Record<string, unknown>) => {
3516
4170
  if (
3517
4171
  frame.type !== "control_response" ||
@@ -3576,6 +4230,75 @@ export function createNotificationsExtension(
3576
4230
  };
3577
4231
  },
3578
4232
  onRequest: options.onSdkRequest,
4233
+ beforeControlResponse: async (_connectionId, request, response, sendTerminal) => {
4234
+ if (typeof request.operation !== "string" || !identityControlOperations.has(request.operation)) return;
4235
+ const pending = deferredIdentityRotation;
4236
+ deferredIdentityRotation = undefined;
4237
+ identityControlInFlight = false;
4238
+ if (response.ok !== true || !pending) return;
4239
+
4240
+ const predecessorId = activeRuntimeId ?? sessionIdFromFile(pending.event.previousSessionFile);
4241
+ if (!predecessorId) throw new Error("notifications: identity control predecessor is unknown.");
4242
+ let terminalAttempted = false;
4243
+ let terminalOutcome: TerminalSendOutcome | undefined;
4244
+ try {
4245
+ terminalOutcome = await runIdentityControlSuccessPath({
4246
+ fence: () => {
4247
+ const predecessor = runtimes.get(predecessorId);
4248
+ if (!predecessor || predecessor.stopping || predecessor.serverStopped)
4249
+ throw new Error(`notifications: predecessor runtime ${predecessorId} cannot be fenced.`);
4250
+ predecessor.inboundFenced = true;
4251
+ },
4252
+ ensurePredecessorSendCapable: () => {
4253
+ const predecessor = runtimes.get(predecessorId);
4254
+ if (!predecessor || predecessor.stopping || predecessor.serverStopped)
4255
+ throw new Error(`notifications: predecessor runtime ${predecessorId} is not send-capable.`);
4256
+ },
4257
+ startSuccessor: async () => {
4258
+ const successorId = sessionId(pending.ctx);
4259
+ try {
4260
+ await rotateSessionAuthority(pending.event, pending.ctx, true, {
4261
+ deferPredecessorStop: true,
4262
+ });
4263
+ const successor = runtimes.get(successorId);
4264
+ if (!successor?.host.started || activeRuntimeId !== successorId)
4265
+ throw new Error(`notifications: successor runtime ${successorId} was not ready.`);
4266
+ } catch (error) {
4267
+ (response as Record<string, unknown>).ok = false;
4268
+ delete (response as Record<string, unknown>).result;
4269
+ (response as Record<string, unknown>).error = {
4270
+ code: "unavailable",
4271
+ message: error instanceof Error ? error.message : "Successor session was not ready.",
4272
+ };
4273
+ }
4274
+ },
4275
+ sendTerminal: async () => {
4276
+ terminalAttempted = true;
4277
+ try {
4278
+ await sendTerminal();
4279
+ return "written";
4280
+ } catch {
4281
+ return "write_failed";
4282
+ }
4283
+ },
4284
+ stopPredecessor: async () => {
4285
+ try {
4286
+ await stopSession(predecessorId);
4287
+ } catch (error) {
4288
+ if (!terminalAttempted) throw error;
4289
+ logger.error(`notifications: deferred predecessor cleanup failed: ${String(error)}`);
4290
+ }
4291
+ },
4292
+ requireNativeControlDrain:
4293
+ (request as { requireNativeControlDrain?: unknown }).requireNativeControlDrain === true,
4294
+ });
4295
+ } catch (error) {
4296
+ if (!terminalAttempted) throw error;
4297
+ logger.error(
4298
+ `notifications: identity terminal delivery failed (${terminalOutcome ?? "unknown"}): ${String(error)}`,
4299
+ );
4300
+ }
4301
+ },
3579
4302
  afterControlResponse: async (connectionId, request, response) => {
3580
4303
  if (
3581
4304
  (request.operation === "turn.prompt" ||
@@ -3596,13 +4319,6 @@ export function createNotificationsExtension(
3596
4319
  }
3597
4320
 
3598
4321
  if (request.operation === "session.close" && response.ok === true) ctx.shutdown();
3599
- if (typeof request.operation === "string" && identityControlOperations.has(request.operation)) {
3600
- const pending = deferredIdentityRotation;
3601
- deferredIdentityRotation = undefined;
3602
- identityControlInFlight = false;
3603
- if (response.ok === true && pending)
3604
- await rotateSessionAuthority(pending.event, pending.ctx, pending.awaitStartup);
3605
- }
3606
4322
  },
3607
4323
  control: async (connectionId, frame) => {
3608
4324
  const request = frame as {
@@ -3622,6 +4338,21 @@ export function createNotificationsExtension(
3622
4338
  ok: false,
3623
4339
  error: { code: "conflict", message: "session identity mutation is already active" },
3624
4340
  };
4341
+ const requireNativeControlDrain =
4342
+ (request as { requireNativeControlDrain?: unknown }).requireNativeControlDrain === true ||
4343
+ (!!request.input &&
4344
+ typeof request.input === "object" &&
4345
+ !Array.isArray(request.input) &&
4346
+ (request.input as { requireNativeControlDrain?: unknown }).requireNativeControlDrain === true);
4347
+ if (requireNativeControlDrain && !isNativeControlDrainAvailable())
4348
+ return {
4349
+ id: requestId,
4350
+ ok: false,
4351
+ error: {
4352
+ code: "unavailable",
4353
+ message: "SDK identity control requires the native control-drain lease.",
4354
+ },
4355
+ };
3625
4356
  if (rotatesIdentity) identityControlInFlight = true;
3626
4357
  const response = await controlRequesterContext.run(connectionId, () =>
3627
4358
  dispatchControl(
@@ -3693,6 +4424,7 @@ export function createNotificationsExtension(
3693
4424
  workflowGate: undefined,
3694
4425
  gatePresentations,
3695
4426
  stopping: false,
4427
+ inboundFenced: false,
3696
4428
  abortEphemeralTurns: () => {},
3697
4429
  disableEphemeralTurns: () => {},
3698
4430
  cancelPostmortemCleanup: () => {},
@@ -3707,8 +4439,13 @@ export function createNotificationsExtension(
3707
4439
  busy: false,
3708
4440
  pendingPromptCorrelations,
3709
4441
  activePromptCorrelation: undefined,
4442
+ bindPromptExecutionHandle,
4443
+ peekPromptPendingOutcome: correlation => kindReconciliation.peekPendingOutcome(correlation),
4444
+ terminalizePrompt: (correlation, outcome, extra) => terminalizePrompt(correlation, outcome, {}, extra),
3710
4445
  recordPromptTerminal,
3711
- notePromptReconciliation: reconciliation.noteTransition,
4446
+ notePromptReconciliation: (correlation, frame) => {
4447
+ void kindReconciliation.noteTransition("prompt", correlation, frame);
4448
+ },
3712
4449
  emitPromptFailure,
3713
4450
  emitPromptLifecycle,
3714
4451
  emitPromptEvent,
@@ -3767,6 +4504,23 @@ export function createNotificationsExtension(
3767
4504
  });
3768
4505
  initializedRuntime.abortEphemeralTurns = () => ephemeralTurns.dispose();
3769
4506
  initializedRuntime.disableEphemeralTurns = () => ephemeralTurns.disable();
4507
+ const sendEndpointStale = (connectionId: string, frame: Record<string, unknown>) => {
4508
+ const id = typeof frame.id === "string" ? frame.id : undefined;
4509
+ if (!id) return;
4510
+ const error = { code: "endpoint_stale", message: "session endpoint is stale." };
4511
+ const responseType =
4512
+ frame.type === "control_request"
4513
+ ? "control_response"
4514
+ : frame.type === "query_request"
4515
+ ? "query_response"
4516
+ : frame.type === "global_request"
4517
+ ? "global_response"
4518
+ : undefined;
4519
+ if (!responseType) return;
4520
+ try {
4521
+ server.sendTo(connectionId, JSON.stringify({ type: responseType, id, ok: false, error }));
4522
+ } catch {}
4523
+ };
3770
4524
  try {
3771
4525
  server.onSdkFrame((err, inbound) => {
3772
4526
  if (err || !inbound) return;
@@ -3774,6 +4528,14 @@ export function createNotificationsExtension(
3774
4528
  const frame = JSON.parse(inbound.json) as unknown;
3775
4529
  if (!frame || typeof frame !== "object") return;
3776
4530
  const typedFrame = frame as Record<string, unknown>;
4531
+ if (inbound.connectionId && fencedConnections.has(inbound.connectionId)) {
4532
+ sendEndpointStale(inbound.connectionId, typedFrame);
4533
+ return;
4534
+ }
4535
+ if (initializedRuntime.inboundFenced) {
4536
+ sendEndpointStale(inbound.connectionId, typedFrame);
4537
+ return;
4538
+ }
3777
4539
  if (typedFrame.type === "ephemeral_turn" || typedFrame.type === "ephemeral_turn_cancel") return;
3778
4540
  if (typedFrame.type === "event_replay") {
3779
4541
  const capabilities = Array.isArray(typedFrame.capabilities) ? typedFrame.capabilities : [];
@@ -3800,15 +4562,31 @@ export function createNotificationsExtension(
3800
4562
  });
3801
4563
  server.onConnectionClose((_err, connectionId) => {
3802
4564
  if (!connectionId) return;
4565
+ controlSurface.cancelPendingPreflightsForConnection(connectionId);
3803
4566
  host.handleDisconnect(connectionId);
3804
4567
  hostCapCache.delete(connectionId);
4568
+ // The socket is gone, so its fence has nothing left to refuse. Dropping the
4569
+ // entry keeps the set bounded by live connections instead of growing forever.
4570
+ fencedConnections.delete(connectionId);
4571
+ // Deliberate deviation from the plan's "claim prompt_failed on old-owner
4572
+ // disconnect": that would break the shipped Q26 reconnect contract, where a
4573
+ // client may drop its socket and reconcile the still-running prompt without
4574
+ // duplicate execution (see test/sdk-host-wiring.test.ts "turn.prompt_status
4575
+ // reconciles an accepted prompt across client reconnect"). A closed socket
4576
+ // therefore only abandons delivery; ACP rejects its own local waiter once and
4577
+ // terminal authority stays with the eventual normalized SDK outcome.
3805
4578
  for (const submission of promptSubmissions.values())
3806
4579
  if (submission.connectionId === connectionId) abandonPrompt(submission);
3807
4580
  });
3808
4581
 
3809
4582
  server.onReply((err, reply) => {
3810
4583
  if (err || !reply) return;
3811
- if (runtime?.stopping || runtime?.policySuspended || runtimes.get(id) !== runtime) {
4584
+ if (
4585
+ runtime?.inboundFenced ||
4586
+ runtime?.stopping ||
4587
+ runtime?.policySuspended ||
4588
+ runtimes.get(id) !== runtime
4589
+ ) {
3812
4590
  try {
3813
4591
  server.closeClaimInvalid(reply.replyReceiptId, "session_stopping");
3814
4592
  } catch {}
@@ -3965,7 +4743,7 @@ export function createNotificationsExtension(
3965
4743
  } else if (presentation?.multi && typeof rawAnswer === "string") {
3966
4744
  answer = { selected: presentation.selectedOptions, other: true, custom: rawAnswer };
3967
4745
  } else {
3968
- const mapped = mapAnswerToGate(reply.answerJson, gateOptions.get(gateId) ?? []);
4746
+ const mapped = mapAnswerToGate(reply.answerJson, presentation?.options ?? []);
3969
4747
  if (!mapped.ok) {
3970
4748
  // A numeric selector outside options is invalid (issue #2030): close the
3971
4749
  // exact claim/receipt and reissue the interaction — never a success ack.
@@ -4050,6 +4828,7 @@ export function createNotificationsExtension(
4050
4828
  // thread (forwarded by the daemon over the WS, fail-closed at the daemon).
4051
4829
  server.onInbound((err, inbound) => {
4052
4830
  if (err || !inbound) return;
4831
+ if (initializedRuntime.inboundFenced) return;
4053
4832
  const authenticatedInbound = inbound as typeof inbound & {
4054
4833
  connectionId: string;
4055
4834
  messageId?: number;
@@ -4317,16 +5096,28 @@ export function createNotificationsExtension(
4317
5096
  if (!canDeliverAsync(runtime, generation)) {
4318
5097
  return { ok: false, error: TELEGRAM_FILE_REDACTION_ERROR };
4319
5098
  }
4320
- pushFileAttachment(
4321
- runtime,
4322
- {
4323
- type: "file_attachment",
5099
+ if (file.mime?.startsWith("image/")) {
5100
+ pushSessionFrame(runtime, {
5101
+ type: "image_attachment",
4324
5102
  sessionId: runtime.id,
4325
- name: path.basename(file.path),
5103
+ source: "telegram_send",
5104
+ mime: file.mime,
4326
5105
  caption: file.caption,
4327
- },
4328
- data,
4329
- );
5106
+ data: data.toString("base64"),
5107
+ });
5108
+ } else {
5109
+ pushFileAttachment(
5110
+ runtime,
5111
+ {
5112
+ type: "file_attachment",
5113
+ sessionId: runtime.id,
5114
+ name: path.basename(file.path),
5115
+ mime: file.mime,
5116
+ caption: file.caption,
5117
+ },
5118
+ data,
5119
+ );
5120
+ }
4330
5121
  return { ok: true };
4331
5122
  } catch (e) {
4332
5123
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
@@ -4360,7 +5151,6 @@ export function createNotificationsExtension(
4360
5151
  activeRuntime.disposeGateTerminalController = () => {};
4361
5152
  activeRuntime.disposeAckRecoveryParticipant = () => {};
4362
5153
  activeRuntime.workflowGate = undefined;
4363
- gateOptions.clear();
4364
5154
  if (typeof gate?.onGateEmitted !== "function" || typeof gate.resolveGateFromNotification !== "function") {
4365
5155
  return;
4366
5156
  }
@@ -4385,7 +5175,6 @@ export function createNotificationsExtension(
4385
5175
  const rawGateOptions = g.options ?? [];
4386
5176
  const options = rawGateOptions.map(o => String((o as { label?: unknown }).label ?? ""));
4387
5177
  const recommendedIndex = recommendedIndexFromGateOptions(rawGateOptions);
4388
- gateOptions.set(g.gate_id, options);
4389
5178
  const promptCtx = g.context as { prompt?: unknown; title?: unknown } | undefined;
4390
5179
  const question =
4391
5180
  (typeof promptCtx?.prompt === "string" && promptCtx.prompt) ||
@@ -4642,7 +5431,16 @@ export function createNotificationsExtension(
4642
5431
  };
4643
5432
 
4644
5433
  api.on("session_start", async (_event, ctx) => {
4645
- await startAndReconcileSession(ctx);
5434
+ const task = startAndReconcileSession(ctx);
5435
+ // Track full start+reconcile so settled startups join replacement-token
5436
+ // reconcile before owner release. Pending native startup (/notify on) stays
5437
+ // nonblocking: shutdown only awaits these tasks when sessionStartPromises is clear.
5438
+ sessionLifecycleTasks.add(task);
5439
+ try {
5440
+ await task;
5441
+ } finally {
5442
+ sessionLifecycleTasks.delete(task);
5443
+ }
4646
5444
  });
4647
5445
 
4648
5446
  // A session endpoint's token and generation are authority for exactly one
@@ -4688,10 +5486,27 @@ export function createNotificationsExtension(
4688
5486
  });
4689
5487
  };
4690
5488
 
5489
+ const preparePredecessorForTerminal = async (id: string): Promise<void> => {
5490
+ const predecessor = runtimes.get(id);
5491
+ if (!predecessor || cleanupRetries.has(id))
5492
+ throw new Error(`notifications: predecessor runtime ${id} is not safely send-capable.`);
5493
+ predecessor.inboundFenced = true;
5494
+ // Release broker/host authority while leaving the native server alive for
5495
+ // the one accepted terminal response. stopAndWait is deferred to stopSession.
5496
+ const stopped = await predecessor.host.stop();
5497
+ if (stopped !== "stopped" && predecessor.host.started)
5498
+ throw new Error(`notifications: predecessor runtime ${id} host release was not proven.`);
5499
+ predecessor.hostStopped = true;
5500
+ predecessor.brokerRegistrationReleased = !predecessor.brokerRegistrationActive || predecessor.hostStopped;
5501
+ if (predecessor.brokerRegistrationActive && predecessor.hostStopped) predecessor.brokerRegistrationActive = false;
5502
+ predecessor.host.reverse.dispose();
5503
+ };
5504
+
4691
5505
  const rotateSessionAuthority = async (
4692
5506
  event: { previousSessionFile?: string },
4693
5507
  ctx: ExtensionContext,
4694
5508
  awaitStartup: boolean,
5509
+ options: { deferPredecessorStop?: boolean } = {},
4695
5510
  ): Promise<void> => {
4696
5511
  if (extensionShuttingDown) return;
4697
5512
  const newId = sessionId(ctx);
@@ -4700,7 +5515,11 @@ export function createNotificationsExtension(
4700
5515
  const pendingStartup = sessionStartPromises.get(newId);
4701
5516
  if (pendingStartup) {
4702
5517
  if (awaitStartup) {
4703
- await pendingStartup;
5518
+ const result = await pendingStartup;
5519
+ if (!lifecycleStartupCapability && result.status === "failed")
5520
+ throw new Error(
5521
+ `notifications: SDK startup failed: ${result.failure?.message ?? "Unknown startup failure."}`,
5522
+ );
4704
5523
  if (!extensionShuttingDown && runtimes.has(newId) && activeRuntimeId === newId)
4705
5524
  await controller.reconcileCurrentSession(ctx);
4706
5525
  } else {
@@ -4715,14 +5534,14 @@ export function createNotificationsExtension(
4715
5534
  }
4716
5535
  if (prevId && prevId !== newId) {
4717
5536
  controller.rekeySession(prevId, newId);
4718
- try {
4719
- await stopSession(prevId);
4720
- } catch (error) {
4721
- // A retained owner-release failure keeps the exact runtime in
4722
- // cleanupRetries for an explicit later retry; log it rather than
4723
- // surfacing a red extension error
4724
- // while rotating session authority (/new, fork, resume, branch).
4725
- logger.error(`notifications: SDK notification runtime cleanup failed: ${String(error)}`);
5537
+ if (options.deferPredecessorStop) {
5538
+ await preparePredecessorForTerminal(prevId);
5539
+ } else {
5540
+ const stopped = await stopSession(prevId);
5541
+ if (cleanupRetries.has(prevId) || runtimes.has(prevId) || sessionStartPromises.has(prevId))
5542
+ throw new Error(`notifications: predecessor runtime ${prevId} release is uncertain.`);
5543
+ if (!stopped && activeRuntimeId === prevId)
5544
+ throw new Error(`notifications: predecessor runtime ${prevId} release was not proven.`);
4726
5545
  }
4727
5546
  }
4728
5547
  if (extensionShuttingDown) return;
@@ -4743,18 +5562,19 @@ export function createNotificationsExtension(
4743
5562
  trackBranchStartup(newId, ctx, startup);
4744
5563
  };
4745
5564
  api.on("session_switch", async (event, ctx) => {
5565
+ const awaitStartup = shouldAwaitNotificationStartup(event);
4746
5566
  if (identityControlInFlight) {
4747
- deferredIdentityRotation = { event, ctx, awaitStartup: true };
5567
+ deferredIdentityRotation = { event, ctx, awaitStartup };
4748
5568
  return;
4749
5569
  }
4750
- await rotateSessionAuthority(event, ctx, true);
5570
+ await rotateSessionAuthority(event, ctx, awaitStartup);
4751
5571
  });
4752
5572
  api.on("session_branch", async (event, ctx) => {
4753
5573
  if (identityControlInFlight) {
4754
- deferredIdentityRotation = { event, ctx, awaitStartup: false };
5574
+ deferredIdentityRotation = { event, ctx, awaitStartup: true };
4755
5575
  return;
4756
5576
  }
4757
- await rotateSessionAuthority(event, ctx, false);
5577
+ await rotateSessionAuthority(event, ctx, true);
4758
5578
  });
4759
5579
 
4760
5580
  const terminalizeInFlightTools = (
@@ -4870,6 +5690,7 @@ export function createNotificationsExtension(
4870
5690
  rt.busy = true;
4871
5691
  const correlation = rt.pendingPromptCorrelations.shift();
4872
5692
  rt.activePromptCorrelation = correlation;
5693
+ if (correlation) rt.bindPromptExecutionHandle(correlation, ctx.getActivePromptHandle());
4873
5694
  rt.notePromptReconciliation(correlation, { type: "agent_start" });
4874
5695
  rt.emitPromptLifecycle(correlation, { type: "agent_start", sessionId: id, ...correlation });
4875
5696
  try {
@@ -4915,15 +5736,45 @@ export function createNotificationsExtension(
4915
5736
  rt.busy = false;
4916
5737
  const correlation = rt.activePromptCorrelation;
4917
5738
  if (correlation) {
4918
- const terminalAssistant = (Array.isArray(event.messages) ? [...event.messages].reverse() : []).find(
5739
+ const assistants = (Array.isArray(event.messages) ? [...event.messages].reverse() : []).filter(
5740
+ message => message && typeof message === "object" && (message as { role?: unknown }).role === "assistant",
5741
+ ) as Array<{ stopReason?: unknown; errorKind?: unknown }>;
5742
+ const finalAssistant = assistants[0];
5743
+ const pendingOutcome = rt.peekPromptPendingOutcome(correlation);
5744
+ let outcome: SdkPromptTerminalOutcome;
5745
+ if (pendingOutcome) outcome = pendingOutcome;
5746
+ else if (finalAssistant?.stopReason === "length")
5747
+ outcome = { kind: "stopped", reason: "max_tokens", provenance: "agent" };
5748
+ else if (finalAssistant?.errorKind === "provider_safety_stop")
5749
+ outcome = { kind: "stopped", reason: "refusal", provenance: "agent" };
5750
+ // A missing/normal final assistant with a non-failing loop stop reason is a
5751
+ // normal turn end; only explicit error/aborted assistants or non-completed
5752
+ // loop stop reasons are failures. Text is never parsed.
5753
+ else if (
5754
+ (event.stopReason === undefined || event.stopReason === "completed") &&
5755
+ finalAssistant?.stopReason !== "error" &&
5756
+ finalAssistant?.stopReason !== "aborted"
5757
+ )
5758
+ outcome = { kind: "stopped", reason: "end_turn", provenance: "agent" };
5759
+ else
5760
+ outcome = {
5761
+ kind: "failed",
5762
+ code: "prompt_failed",
5763
+ message: "Prompt submission failed.",
5764
+ provenance: "agent_failed",
5765
+ };
5766
+ const successAssistant = assistants.find(
5767
+ message => (message as { stopReason?: unknown }).stopReason === "stop",
5768
+ );
5769
+ const finalText = successAssistant ? acpFinalTextFromMessage(successAssistant).text : "";
5770
+ // The normalized outcome is the ACP contract; existing SDK clients keep the
5771
+ // legacy failure discriminator on the wire and in the reconciliation record.
5772
+ const terminalAssistant = assistants.find(
4919
5773
  message =>
4920
- message &&
4921
- typeof message === "object" &&
4922
- (message as { role?: unknown }).role === "assistant" &&
4923
- ((message as { stopReason?: unknown }).stopReason === "error" ||
4924
- (message as { stopReason?: unknown }).stopReason === "aborted"),
5774
+ (message as { stopReason?: unknown }).stopReason === "error" ||
5775
+ (message as { stopReason?: unknown }).stopReason === "aborted",
4925
5776
  ) as { stopReason?: "error" | "aborted"; errorMessage?: unknown } | undefined;
4926
- const failureCode =
5777
+ const legacyCode =
4927
5778
  event.stopReason === "cancelled"
4928
5779
  ? "cancelled"
4929
5780
  : terminalAssistant?.stopReason === "error"
@@ -4931,33 +5782,16 @@ export function createNotificationsExtension(
4931
5782
  : terminalAssistant?.stopReason === "aborted"
4932
5783
  ? "aborted"
4933
5784
  : undefined;
4934
- if (failureCode)
4935
- rt.emitPromptFailure(correlation, {
4936
- code: failureCode,
4937
- message:
4938
- typeof terminalAssistant?.errorMessage === "string"
4939
- ? terminalAssistant.errorMessage
4940
- : "Prompt submission failed.",
4941
- });
4942
- else {
4943
- rt.notePromptReconciliation(correlation, { type: "agent_end" });
4944
- if (rt.recordPromptTerminal(correlation)) {
4945
- const finalAssistant = (Array.isArray(event.messages) ? [...event.messages].reverse() : []).find(
4946
- message =>
4947
- message &&
4948
- typeof message === "object" &&
4949
- (message as { role?: unknown }).role === "assistant" &&
4950
- (message as { stopReason?: unknown }).stopReason === "stop",
4951
- );
4952
- const finalText = finalAssistant ? acpFinalTextFromMessage(finalAssistant).text : "";
4953
- rt.emitPromptLifecycle(correlation, {
4954
- type: "agent_end",
4955
- sessionId: id,
4956
- ...correlation,
4957
- ...(finalText ? { finalText } : {}),
4958
- });
4959
- }
4960
- }
5785
+ void rt.terminalizePrompt(correlation, outcome, {
5786
+ ...(finalText ? { finalText } : {}),
5787
+ ...(outcome.kind === "failed" && legacyCode
5788
+ ? {
5789
+ // Only the legacy discriminator is preserved; provider text is never
5790
+ // retained, matching `sanitizePromptFailure`.
5791
+ error: { code: legacyCode, message: "Prompt submission failed." },
5792
+ }
5793
+ : {}),
5794
+ });
4961
5795
  } else {
4962
5796
  rt.emitPromptLifecycle(undefined, { type: "agent_end", sessionId: id });
4963
5797
  }
@@ -5292,8 +6126,12 @@ export function createNotificationsExtension(
5292
6126
  extensionShuttingDown = true;
5293
6127
  identityControlInFlight = false;
5294
6128
  deferredIdentityRotation = undefined;
5295
- await Promise.allSettled([...branchStartupTasks]);
5296
6129
  const id = sessionId(ctx);
6130
+ // Join settled start+reconcile before owner release. Keep pending native
6131
+ // startup (/notify on) nonblocking — do not await sessionLifecycleTasks while
6132
+ // sessionStartPromises still has this id.
6133
+ if (!sessionStartPromises.has(id)) await Promise.allSettled([...sessionLifecycleTasks]);
6134
+ await Promise.allSettled([...branchStartupTasks]);
5297
6135
  const rt = runtimes.get(id);
5298
6136
  if (rt) terminalizeInFlightTools(rt, id, "cancelled");
5299
6137
  // Startup is only genuinely in flight when a `sessionStartPromises` entry