@gajae-code/coding-agent 0.11.0 → 0.11.2

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 (418) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/dist/types/async/job-manager.d.ts +46 -7
  3. package/dist/types/cli/config-cli.d.ts +11 -1
  4. package/dist/types/cli/notify-cli.d.ts +1 -0
  5. package/dist/types/cli/session-picker.d.ts +2 -1
  6. package/dist/types/cli/setup-cli.d.ts +1 -1
  7. package/dist/types/commands/mcp-serve.d.ts +33 -0
  8. package/dist/types/commands/mcp.d.ts +1 -1
  9. package/dist/types/commands/notify.d.ts +0 -2
  10. package/dist/types/commands/sdk.d.ts +1 -1
  11. package/dist/types/config/atomic-yaml-patch.d.ts +18 -0
  12. package/dist/types/config/file-lock.d.ts +14 -4
  13. package/dist/types/config/keybindings.d.ts +42 -1
  14. package/dist/types/config/model-auth.d.ts +3 -0
  15. package/dist/types/config/model-bindings-applier.d.ts +13 -0
  16. package/dist/types/config/model-discovery-manager.d.ts +49 -0
  17. package/dist/types/config/model-equivalence.d.ts +20 -0
  18. package/dist/types/config/model-registry.d.ts +16 -15
  19. package/dist/types/config/model-resolver.d.ts +28 -18
  20. package/dist/types/config/models-config-schema.d.ts +11 -0
  21. package/dist/types/config/settings-schema.d.ts +56 -1
  22. package/dist/types/config/settings.d.ts +14 -11
  23. package/dist/types/coordinator-mcp/policy.d.ts +5 -0
  24. package/dist/types/coordinator-mcp/question-gate-codec.d.ts +129 -0
  25. package/dist/types/coordinator-mcp/question-state.d.ts +356 -0
  26. package/dist/types/coordinator-mcp/server.d.ts +4 -0
  27. package/dist/types/discovery/helpers.d.ts +0 -6
  28. package/dist/types/edit/index.d.ts +3 -0
  29. package/dist/types/eval/py/env.d.ts +9 -0
  30. package/dist/types/extensibility/extensions/loader.d.ts +2 -1
  31. package/dist/types/extensibility/extensions/runner.d.ts +2 -2
  32. package/dist/types/extensibility/extensions/types.d.ts +34 -3
  33. package/dist/types/extensibility/hooks/runner.d.ts +1 -1
  34. package/dist/types/extensibility/shared-events.d.ts +2 -0
  35. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +10 -1
  36. package/dist/types/gjc-runtime/deep-interview-state.d.ts +44 -0
  37. package/dist/types/gjc-runtime/goal-mode-request.d.ts +11 -1
  38. package/dist/types/gjc-runtime/launch-worktree.d.ts +4 -2
  39. package/dist/types/gjc-runtime/linux-proc.d.ts +29 -0
  40. package/dist/types/gjc-runtime/session-resolution.d.ts +2 -2
  41. package/dist/types/gjc-runtime/state-migrations.d.ts +1 -1
  42. package/dist/types/gjc-runtime/state-writer.d.ts +2 -1
  43. package/dist/types/gjc-runtime/team-launch.d.ts +59 -0
  44. package/dist/types/gjc-runtime/team-notify.d.ts +45 -0
  45. package/dist/types/gjc-runtime/team-runtime.d.ts +29 -121
  46. package/dist/types/gjc-runtime/team-store.d.ts +160 -0
  47. package/dist/types/gjc-runtime/team-workers.d.ts +78 -0
  48. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +8 -0
  49. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +13 -0
  50. package/dist/types/gjc-runtime/ultragoal-evidence.d.ts +38 -0
  51. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +7 -0
  52. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +23 -0
  53. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +11 -0
  54. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +55 -33
  55. package/dist/types/gjc-runtime/workflow-cli-common.d.ts +8 -0
  56. package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -0
  57. package/dist/types/goals/continuation-timeout-guard.d.ts +22 -0
  58. package/dist/types/goals/runtime.d.ts +1 -0
  59. package/dist/types/goals/state.d.ts +8 -0
  60. package/dist/types/harness-control-plane/lifecycle-reconciliation.d.ts +17 -0
  61. package/dist/types/harness-control-plane/phase-rollup.d.ts +4 -7
  62. package/dist/types/harness-control-plane/receipt-ingest.d.ts +5 -0
  63. package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
  64. package/dist/types/hindsight/state.d.ts +11 -4
  65. package/dist/types/lsp/lspmux.d.ts +5 -3
  66. package/dist/types/lsp/path-trust.d.ts +1 -0
  67. package/dist/types/main.d.ts +3 -2
  68. package/dist/types/memories/storage.d.ts +1 -0
  69. package/dist/types/modes/action-registry.d.ts +29 -0
  70. package/dist/types/modes/components/chrome.d.ts +14 -0
  71. package/dist/types/modes/components/command-palette.d.ts +27 -0
  72. package/dist/types/modes/components/custom-provider-wizard.d.ts +1 -0
  73. package/dist/types/modes/components/index.d.ts +2 -1
  74. package/dist/types/modes/components/pet-selector.d.ts +1 -1
  75. package/dist/types/modes/components/plan-preview-overlay.d.ts +43 -0
  76. package/dist/types/modes/components/queue-mode-selector.d.ts +1 -1
  77. package/dist/types/modes/components/queue-pane.d.ts +12 -0
  78. package/dist/types/modes/components/read-tool-group.d.ts +6 -0
  79. package/dist/types/modes/components/session-observer-overlay.d.ts +3 -4
  80. package/dist/types/modes/components/sessions-dashboard.d.ts +32 -0
  81. package/dist/types/modes/components/settings-selector.d.ts +1 -1
  82. package/dist/types/modes/components/show-images-selector.d.ts +1 -1
  83. package/dist/types/modes/components/status-line/types.d.ts +1 -1
  84. package/dist/types/modes/components/status-line.d.ts +1 -94
  85. package/dist/types/modes/components/tasks-pane.d.ts +15 -0
  86. package/dist/types/modes/components/theme-selector.d.ts +1 -1
  87. package/dist/types/modes/components/tool-execution.d.ts +15 -0
  88. package/dist/types/modes/components/tool-status-header.d.ts +111 -0
  89. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +42 -0
  90. package/dist/types/modes/controllers/command-controller.d.ts +2 -2
  91. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -0
  92. package/dist/types/modes/controllers/goal-mode-controller.d.ts +62 -0
  93. package/dist/types/modes/controllers/input-controller.d.ts +8 -1
  94. package/dist/types/modes/controllers/mode-gate.d.ts +8 -0
  95. package/dist/types/modes/controllers/plan-mode-controller.d.ts +65 -0
  96. package/dist/types/modes/controllers/selector-controller.d.ts +15 -3
  97. package/dist/types/modes/controllers/stt-controller.d.ts +26 -0
  98. package/dist/types/modes/interactive-mode.d.ts +21 -19
  99. package/dist/types/modes/jobs-observer.d.ts +2 -0
  100. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +4 -11
  101. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +21 -2
  102. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +31 -0
  103. package/dist/types/modes/tasks-aggregator.d.ts +39 -0
  104. package/dist/types/modes/transcript-item-registry.d.ts +60 -0
  105. package/dist/types/modes/types.d.ts +22 -15
  106. package/dist/types/modes/utils/ui-helpers.d.ts +2 -2
  107. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  108. package/dist/types/sdk/broker/broker.d.ts +78 -12
  109. package/dist/types/sdk/broker/ensure.d.ts +9 -0
  110. package/dist/types/sdk/broker/lifecycle-codec.d.ts +4 -0
  111. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +15 -1
  112. package/dist/types/sdk/broker/lifecycle.d.ts +6 -2
  113. package/dist/types/sdk/broker/process-incarnation.d.ts +2 -0
  114. package/dist/types/sdk/broker/session-index.d.ts +0 -1
  115. package/dist/types/sdk/broker/state-version.d.ts +2 -0
  116. package/dist/types/sdk/bus/chat-effect-journal.d.ts +1 -0
  117. package/dist/types/sdk/bus/config-commands.d.ts +1 -1
  118. package/dist/types/sdk/bus/config.d.ts +11 -0
  119. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  120. package/dist/types/sdk/bus/discord-daemon.d.ts +5 -0
  121. package/dist/types/sdk/bus/html-format.d.ts +3 -2
  122. package/dist/types/sdk/bus/index.d.ts +27 -0
  123. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  124. package/dist/types/sdk/bus/notification-service.d.ts +20 -0
  125. package/dist/types/sdk/bus/rate-limit-pool.d.ts +13 -2
  126. package/dist/types/sdk/bus/recent-activity.d.ts +21 -12
  127. package/dist/types/sdk/bus/rich-document.d.ts +7 -0
  128. package/dist/types/sdk/bus/rich-render.d.ts +18 -41
  129. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +9 -4
  130. package/dist/types/sdk/bus/telegram-daemon.d.ts +69 -3
  131. package/dist/types/sdk/bus/threaded-inbound.d.ts +1 -1
  132. package/dist/types/sdk/bus/threaded-render.d.ts +13 -0
  133. package/dist/types/sdk/bus/topic-registry.d.ts +24 -2
  134. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  135. package/dist/types/sdk/host/host.d.ts +2 -0
  136. package/dist/types/sdk/index.d.ts +1 -0
  137. package/dist/types/sdk/session-directory.d.ts +56 -0
  138. package/dist/types/session/agent-session.d.ts +82 -47
  139. package/dist/types/session/agent-storage.d.ts +7 -0
  140. package/dist/types/session/fallback-chain-controller.d.ts +10 -2
  141. package/dist/types/session/internal/managed-session-scope.d.ts +102 -0
  142. package/dist/types/session/internal/managed-session-storage.d.ts +38 -0
  143. package/dist/types/session/messages.d.ts +16 -0
  144. package/dist/types/session/session-manager-internal.d.ts +14 -0
  145. package/dist/types/session/session-manager.d.ts +82 -12
  146. package/dist/types/session/session-storage.d.ts +43 -3
  147. package/dist/types/session/volatile-context-pruning.d.ts +15 -0
  148. package/dist/types/skill-state/{deep-interview-mutation-guard.d.ts → workflow-mutation-guard.d.ts} +24 -8
  149. package/dist/types/skill-state/workflow-state-contract.d.ts +1 -3
  150. package/dist/types/slash-commands/builtin-registry.d.ts +4 -1
  151. package/dist/types/system-prompt.d.ts +2 -11
  152. package/dist/types/task/executor.d.ts +3 -1
  153. package/dist/types/thinking.d.ts +7 -0
  154. package/dist/types/tool-discovery/tool-index.d.ts +1 -50
  155. package/dist/types/tools/ask.d.ts +64 -2
  156. package/dist/types/tools/bash.d.ts +3 -0
  157. package/dist/types/tools/browser/dead-tab-recovery.d.ts +24 -0
  158. package/dist/types/tools/browser/tab-supervisor.d.ts +21 -0
  159. package/dist/types/tools/cron.d.ts +2 -0
  160. package/dist/types/tools/fetch.d.ts +1 -0
  161. package/dist/types/tools/index.d.ts +30 -15
  162. package/dist/types/tools/output-meta.d.ts +1 -0
  163. package/dist/types/tools/read.d.ts +3 -0
  164. package/dist/types/tools/search-tool-bm25.d.ts +3 -4
  165. package/dist/types/tools/subagent-render.d.ts +1 -0
  166. package/dist/types/tools/subagent.d.ts +6 -1
  167. package/dist/types/utils/event-bus.d.ts +1 -1
  168. package/dist/types/web/search/index.d.ts +3 -1
  169. package/package.json +21 -9
  170. package/scripts/build-sdk-package-smoke.ts +39 -1
  171. package/scripts/generate-hotkeys-docs.ts +74 -0
  172. package/scripts/generate-sdk-operation-inventory.ts +64 -20
  173. package/scripts/verify-gjc-sdk-canonicalization.ts +182 -2
  174. package/src/async/job-manager.ts +455 -69
  175. package/src/cli/config-cli.ts +75 -2
  176. package/src/cli/notify-cli.ts +92 -26
  177. package/src/cli/session-picker.ts +20 -3
  178. package/src/cli/setup-cli.ts +1 -1
  179. package/src/cli.ts +1 -1
  180. package/src/commands/config.ts +1 -1
  181. package/src/commands/harness.ts +34 -106
  182. package/src/commands/launch.ts +6 -4
  183. package/src/commands/mcp-serve.ts +85 -8
  184. package/src/commands/mcp.ts +2 -2
  185. package/src/commands/notify.ts +8 -4
  186. package/src/commands/sdk.ts +61 -40
  187. package/src/commands/ultragoal.ts +4 -1
  188. package/src/config/atomic-yaml-patch.ts +80 -13
  189. package/src/config/file-lock.ts +105 -43
  190. package/src/config/keybindings.ts +64 -20
  191. package/src/config/model-auth.ts +9 -0
  192. package/src/config/model-bindings-applier.ts +139 -0
  193. package/src/config/model-discovery-manager.ts +224 -0
  194. package/src/config/model-equivalence.ts +66 -0
  195. package/src/config/model-registry.ts +169 -302
  196. package/src/config/model-resolver.ts +361 -369
  197. package/src/config/models-config-schema.ts +5 -4
  198. package/src/config/settings-schema.ts +159 -2
  199. package/src/config/settings.ts +458 -482
  200. package/src/coordinator-mcp/policy.ts +76 -11
  201. package/src/coordinator-mcp/question-gate-codec.ts +274 -0
  202. package/src/coordinator-mcp/question-state.ts +778 -0
  203. package/src/coordinator-mcp/server.ts +1702 -238
  204. package/src/debug/index.ts +1 -1
  205. package/src/defaults/gjc/skills/deep-interview/SKILL.md +13 -1
  206. package/src/defaults/gjc/skills/team/SKILL.md +16 -0
  207. package/src/discovery/helpers.ts +1 -50
  208. package/src/edit/index.ts +64 -0
  209. package/src/eval/py/env.ts +26 -0
  210. package/src/eval/py/kernel.ts +5 -3
  211. package/src/extensibility/extensions/loader.ts +9 -1
  212. package/src/extensibility/extensions/runner.ts +12 -3
  213. package/src/extensibility/extensions/types.ts +39 -1
  214. package/src/extensibility/gjc-plugins/installer.ts +16 -16
  215. package/src/extensibility/hooks/runner.ts +2 -2
  216. package/src/extensibility/runtime-skill-discovery.ts +33 -13
  217. package/src/extensibility/shared-events.ts +2 -0
  218. package/src/gjc-runtime/deep-interview-recorder.ts +63 -3
  219. package/src/gjc-runtime/deep-interview-runtime.ts +76 -27
  220. package/src/gjc-runtime/deep-interview-state.ts +317 -5
  221. package/src/gjc-runtime/goal-mode-request.ts +55 -8
  222. package/src/gjc-runtime/launch-tmux.ts +2 -5
  223. package/src/gjc-runtime/launch-worktree.ts +5 -5
  224. package/src/gjc-runtime/linux-proc.ts +71 -0
  225. package/src/gjc-runtime/ralplan-runtime.ts +74 -90
  226. package/src/gjc-runtime/session-resolution.ts +18 -3
  227. package/src/gjc-runtime/session-state-sidecar.ts +6 -107
  228. package/src/gjc-runtime/state-migrations.ts +1 -1
  229. package/src/gjc-runtime/state-runtime.ts +320 -249
  230. package/src/gjc-runtime/state-writer.ts +36 -40
  231. package/src/gjc-runtime/team-launch.ts +319 -0
  232. package/src/gjc-runtime/team-notify.ts +326 -0
  233. package/src/gjc-runtime/team-runtime.ts +1099 -1482
  234. package/src/gjc-runtime/team-store.ts +828 -0
  235. package/src/gjc-runtime/team-workers.ts +962 -0
  236. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +2 -11
  237. package/src/gjc-runtime/tmux-owner-isolation.ts +14 -9
  238. package/src/gjc-runtime/tmux-sessions.ts +4 -21
  239. package/src/gjc-runtime/ultragoal-change-set.ts +172 -0
  240. package/src/gjc-runtime/ultragoal-evidence.ts +874 -0
  241. package/src/gjc-runtime/ultragoal-guard.ts +83 -12
  242. package/src/gjc-runtime/ultragoal-pipeline.ts +306 -0
  243. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +102 -1
  244. package/src/gjc-runtime/ultragoal-runtime.ts +156 -1319
  245. package/src/gjc-runtime/workflow-cli-common.ts +30 -0
  246. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  247. package/src/gjc-runtime/workflow-manifest.ts +18 -0
  248. package/src/goals/continuation-timeout-guard.ts +79 -0
  249. package/src/goals/runtime.ts +7 -5
  250. package/src/goals/state.ts +18 -0
  251. package/src/harness-control-plane/lifecycle-reconciliation.ts +64 -0
  252. package/src/harness-control-plane/phase-rollup.ts +4 -7
  253. package/src/harness-control-plane/receipt-ingest.ts +5 -0
  254. package/src/harness-control-plane/receipt-spool.ts +51 -7
  255. package/src/harness-control-plane/session-lease.ts +12 -130
  256. package/src/hindsight/backend.ts +26 -27
  257. package/src/hindsight/state.ts +101 -32
  258. package/src/hooks/skill-state.ts +17 -11
  259. package/src/internal-urls/docs-index.generated.ts +21 -18
  260. package/src/lsp/client.ts +1 -1
  261. package/src/lsp/config.ts +145 -26
  262. package/src/lsp/index.ts +1 -1
  263. package/src/lsp/lspmux.ts +39 -10
  264. package/src/lsp/path-trust.ts +81 -0
  265. package/src/main.ts +28 -8
  266. package/src/memories/index.ts +1 -0
  267. package/src/memories/storage.ts +6 -2
  268. package/src/modes/action-registry.ts +165 -0
  269. package/src/modes/components/chrome.ts +40 -0
  270. package/src/modes/components/command-palette.ts +168 -0
  271. package/src/modes/components/custom-editor.ts +1 -1
  272. package/src/modes/components/custom-provider-wizard.ts +51 -14
  273. package/src/modes/components/index.ts +2 -1
  274. package/src/modes/components/pet-selector.ts +11 -17
  275. package/src/modes/components/plan-preview-overlay.ts +295 -0
  276. package/src/modes/components/queue-mode-selector.ts +10 -27
  277. package/src/modes/components/queue-pane.ts +79 -0
  278. package/src/modes/components/read-tool-group.ts +13 -0
  279. package/src/modes/components/session-observer-overlay.ts +207 -772
  280. package/src/modes/components/sessions-dashboard.ts +181 -0
  281. package/src/modes/components/settings-selector.ts +1 -1
  282. package/src/modes/components/show-images-selector.ts +10 -24
  283. package/src/modes/components/status-line/types.ts +1 -1
  284. package/src/modes/components/status-line.ts +1 -969
  285. package/src/modes/components/tasks-pane.ts +73 -0
  286. package/src/modes/components/theme-selector.ts +11 -31
  287. package/src/modes/components/tool-execution.ts +22 -0
  288. package/src/modes/components/tool-status-header.ts +1056 -0
  289. package/src/modes/components/transcript-viewer-overlay.ts +384 -0
  290. package/src/modes/controllers/btw-controller.ts +1 -0
  291. package/src/modes/controllers/command-controller.ts +9 -15
  292. package/src/modes/controllers/event-controller.ts +3 -2
  293. package/src/modes/controllers/extension-ui-controller.ts +72 -25
  294. package/src/modes/controllers/goal-mode-controller.ts +474 -0
  295. package/src/modes/controllers/input-controller.ts +764 -229
  296. package/src/modes/controllers/mode-gate.ts +24 -0
  297. package/src/modes/controllers/plan-mode-controller.ts +517 -0
  298. package/src/modes/controllers/selector-controller.ts +225 -10
  299. package/src/modes/controllers/stt-controller.ts +125 -0
  300. package/src/modes/interactive-mode.ts +302 -1151
  301. package/src/modes/jobs-observer.ts +3 -0
  302. package/src/modes/runtime-init.ts +8 -0
  303. package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -106
  304. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +78 -19
  305. package/src/modes/shared/agent-wire/workflow-gate-types.ts +177 -0
  306. package/src/modes/tasks-aggregator.ts +199 -0
  307. package/src/modes/transcript-item-registry.ts +229 -0
  308. package/src/modes/types.ts +47 -20
  309. package/src/modes/utils/ui-helpers.ts +17 -9
  310. package/src/prompts/agent-fragments/ralplan-persistence.md +6 -0
  311. package/src/prompts/agent-fragments/restricted-bash.md +1 -0
  312. package/src/prompts/agents/architect.md +2 -8
  313. package/src/prompts/agents/critic.md +2 -8
  314. package/src/prompts/agents/executor.md +3 -0
  315. package/src/prompts/agents/planner.md +4 -12
  316. package/src/prompts/system/custom-system-prompt.md +1 -23
  317. package/src/prompts/system/plan-mode-approved.md +8 -0
  318. package/src/prompts/system/system-prompt.md +17 -96
  319. package/src/prompts/tools/bash.md +4 -4
  320. package/src/prompts/tools/browser.md +15 -52
  321. package/src/prompts/tools/find.md +2 -2
  322. package/src/prompts/tools/hashline.md +13 -55
  323. package/src/prompts/tools/read.md +3 -6
  324. package/src/prompts/tools/search-tool-bm25.md +0 -2
  325. package/src/prompts/tools/search.md +2 -4
  326. package/src/prompts/tools/skill-discovery.md +1 -2
  327. package/src/prompts/tools/task.md +1 -1
  328. package/src/runtime-mcp/manager.ts +1 -1
  329. package/src/sdk/broker/broker.ts +206 -78
  330. package/src/sdk/broker/discovery.ts +21 -2
  331. package/src/sdk/broker/ensure.ts +85 -25
  332. package/src/sdk/broker/lifecycle-codec.ts +11 -0
  333. package/src/sdk/broker/lifecycle-ledger.ts +371 -74
  334. package/src/sdk/broker/lifecycle.ts +1399 -142
  335. package/src/sdk/broker/process-incarnation.ts +5 -13
  336. package/src/sdk/broker/session-index.ts +145 -56
  337. package/src/sdk/broker/state-version.ts +21 -0
  338. package/src/sdk/broker/transport.ts +1 -1
  339. package/src/sdk/bus/chat-daemon-cli.ts +2 -0
  340. package/src/sdk/bus/chat-daemon-control.ts +8 -28
  341. package/src/sdk/bus/chat-effect-journal.ts +14 -4
  342. package/src/sdk/bus/config-commands.ts +5 -4
  343. package/src/sdk/bus/config.ts +98 -0
  344. package/src/sdk/bus/conversation-store.ts +5 -29
  345. package/src/sdk/bus/daemon-paths.ts +2 -0
  346. package/src/sdk/bus/discord-daemon.ts +25 -8
  347. package/src/sdk/bus/html-format.ts +20 -3
  348. package/src/sdk/bus/index.ts +834 -113
  349. package/src/sdk/bus/lifecycle-control-runtime.ts +91 -33
  350. package/src/sdk/bus/native-runtime-compatibility.ts +1 -1
  351. package/src/sdk/bus/natives-augment.d.ts +26 -1
  352. package/src/sdk/bus/notification-service.ts +168 -20
  353. package/src/sdk/bus/rate-limit-pool.ts +58 -14
  354. package/src/sdk/bus/recent-activity.ts +188 -74
  355. package/src/sdk/bus/rich-document.ts +258 -0
  356. package/src/sdk/bus/rich-render.ts +62 -70
  357. package/src/sdk/bus/telegram-daemon-cli.ts +15 -59
  358. package/src/sdk/bus/telegram-daemon-contract.ts +9 -4
  359. package/src/sdk/bus/telegram-daemon.ts +1491 -247
  360. package/src/sdk/bus/threaded-inbound.ts +8 -3
  361. package/src/sdk/bus/threaded-render.ts +57 -1
  362. package/src/sdk/bus/topic-registry.ts +120 -13
  363. package/src/sdk/host/control/dispatch.ts +14 -2
  364. package/src/sdk/host/control/operations.ts +6 -1
  365. package/src/sdk/host/host.ts +31 -1
  366. package/src/sdk/host/query/handlers.ts +20 -22
  367. package/src/sdk/host/query/revision-store.ts +80 -57
  368. package/src/sdk/index.ts +1 -0
  369. package/src/sdk/protocol/operation-inventory.generated.json +943 -1907
  370. package/src/sdk/session-directory.ts +141 -0
  371. package/src/sdk/session.ts +458 -479
  372. package/src/session/agent-session.ts +1901 -637
  373. package/src/session/agent-storage.ts +18 -0
  374. package/src/session/cache-economics.ts +25 -8
  375. package/src/session/fallback-chain-controller.ts +53 -3
  376. package/src/session/internal/managed-session-scope.ts +2226 -0
  377. package/src/session/internal/managed-session-storage.ts +453 -0
  378. package/src/session/messages.ts +91 -2
  379. package/src/session/session-manager-internal.ts +33 -0
  380. package/src/session/session-manager.ts +1112 -333
  381. package/src/session/session-storage.ts +444 -39
  382. package/src/session/volatile-context-pruning.ts +78 -0
  383. package/src/setup/credential-auto-import.ts +2 -1
  384. package/src/setup/hermes/templates/operator-instructions.v1.md +1 -1
  385. package/src/setup/provider-onboarding.ts +36 -61
  386. package/src/setup/provider-presets.json +5 -0
  387. package/src/skill-state/active-state.ts +3 -13
  388. package/src/skill-state/{deep-interview-mutation-guard.ts → workflow-mutation-guard.ts} +47 -29
  389. package/src/skill-state/workflow-state-contract.ts +6 -41
  390. package/src/slash-commands/builtin-registry.ts +46 -11
  391. package/src/system-prompt.ts +37 -44
  392. package/src/task/agents.ts +19 -1
  393. package/src/task/executor.ts +90 -6
  394. package/src/task/index.ts +62 -37
  395. package/src/thinking.ts +21 -6
  396. package/src/tool-discovery/tool-index.ts +2 -134
  397. package/src/tools/ask.ts +157 -21
  398. package/src/tools/ast-edit.ts +1 -1
  399. package/src/tools/bash.ts +38 -0
  400. package/src/tools/browser/dead-tab-recovery.ts +78 -0
  401. package/src/tools/browser/tab-supervisor.ts +282 -36
  402. package/src/tools/computer.ts +10 -3
  403. package/src/tools/cron.ts +30 -16
  404. package/src/tools/eval.ts +1 -2
  405. package/src/tools/fetch.ts +11 -4
  406. package/src/tools/index.ts +76 -24
  407. package/src/tools/output-meta.ts +13 -5
  408. package/src/tools/read.ts +37 -1
  409. package/src/tools/resource-gc.ts +2 -2
  410. package/src/tools/search-tool-bm25.ts +11 -74
  411. package/src/tools/subagent-render.ts +19 -8
  412. package/src/tools/subagent.ts +84 -32
  413. package/src/utils/event-bus.ts +9 -4
  414. package/src/web/search/index.ts +3 -3
  415. package/dist/types/discovery/plugin-dir-roots.d.ts +0 -15
  416. package/dist/types/runtime-mcp/discoverable-tool-metadata.d.ts +0 -7
  417. package/src/discovery/plugin-dir-roots.ts +0 -28
  418. package/src/runtime-mcp/discoverable-tool-metadata.ts +0 -25
@@ -28,9 +28,9 @@ import * as os from "node:os";
28
28
  import * as path from "node:path";
29
29
  import { promisify } from "node:util";
30
30
  import { ThinkingLevel } from "@gajae-code/agent-core";
31
- import type { ImageContent, TextContent } from "@gajae-code/ai";
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 { logger, postmortem, prompt, VERSION } from "@gajae-code/utils";
34
34
  import { Settings } from "../../config/settings";
35
35
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../../extensibility/extensions";
36
36
  import type {
@@ -38,6 +38,7 @@ import type {
38
38
  WorkflowGateTerminalController,
39
39
  WorkflowGateTerminalProof,
40
40
  } from "../../modes/shared/agent-wire/workflow-gate-broker";
41
+ import btwUserPrompt from "../../prompts/system/btw-user.md" with { type: "text" };
41
42
  import { parseThinkingLevel } from "../../thinking";
42
43
  import type {
43
44
  AskAnswerRequest,
@@ -76,10 +77,14 @@ import {
76
77
  sessionTag,
77
78
  } from "./config";
78
79
  import { telegramControlCommandUsage } from "./config-commands";
79
- import { imageAttachmentsFromMessage, notificationActionPayload, summaryFromMessage } from "./helpers";
80
+ import { imageAttachmentsFromMessage, notificationActionPayload, summaryFromMessage, truncate } from "./helpers";
80
81
  import { assertNativeRuntimeCompatibility } from "./native-runtime-compatibility";
81
82
  import { NotificationSessionController, type NotificationSessionRuntime } from "./session-control";
82
- import { type EnsureDaemonResult, ensureTelegramDaemonRunningDetailed } from "./telegram-daemon";
83
+ import {
84
+ type EnsureDaemonResult,
85
+ endpointAuthorityDigest,
86
+ ensureTelegramDaemonRunningDetailed,
87
+ } from "./telegram-daemon";
83
88
 
84
89
  // ===========================================================================
85
90
  // Session lifecycle control protocol (TypeScript mirror of the Rust wire
@@ -868,12 +873,19 @@ interface SessionRuntime {
868
873
  disposeGateTerminalController: () => void;
869
874
  disposeAckRecoveryParticipant: () => void;
870
875
  disposeGateEmitterListener: () => void;
876
+ /** Aborts and fences side turns while notification delivery is disabled. */
877
+ disableEphemeralTurns: () => void;
871
878
  waitForGateResolutionQuiescence: () => Promise<void>;
879
+ trackGateResolution: <T>(resolution: Promise<T>) => Promise<T>;
872
880
  workflowGate?: WorkflowGateEmitter;
873
881
  gatePresentations?: PresentationArbiter;
874
882
  redact: boolean;
875
883
  /** True only after the exact host generation was registered with the broker index. */
876
884
  brokerRegistrationActive: boolean;
885
+ /** Terminal cleanup proof retained across retries; each owner is released at most once after proof. */
886
+ hostStopped: boolean;
887
+ serverStopped: boolean;
888
+ brokerRegistrationReleased: boolean;
877
889
  verbosity: "lean" | "verbose";
878
890
  sessionTag: string;
879
891
  /** Whether the agent loop is currently running (drives the typing indicator). */
@@ -914,10 +926,52 @@ interface SessionRuntime {
914
926
  /** True between turn_end and the next turn_start: drops late async message_update
915
927
  * frames so a stale live edit can never be emitted after the finalized turn. */
916
928
  turnClosed?: boolean;
929
+ /** Started tool calls awaiting a terminal activity frame, keyed by tool call id. */
930
+ inFlightTools: Map<string, { toolName: string; args: unknown }>;
917
931
  /** Cancels the postmortem cleanup that emits `session_closed` on process teardown. */
918
932
  cancelPostmortemCleanup: () => void;
919
- /** Stops optional broker presence heartbeats. */
920
- stopBrokerHeartbeat: () => void;
933
+ /** Disposes side-turn resources when their owning logical session becomes unavailable. */
934
+ abortEphemeralTurns: () => void;
935
+ }
936
+
937
+ const SENSITIVE_MODEL_LABEL =
938
+ /(?:\b(?:https?|wss?):\/\/|\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b|\b(?:api[-_ ]?key|access[-_ ]?token|bearer|secret|password|account(?:\s*id)?|email|exception|stack trace)\b|\b(?:sk|pk|rk)-[A-Za-z0-9_-]{12,}\b)/i;
939
+ const TOOL_SUMMARY_MAX = 280;
940
+ const EMPTY_CAPABILITIES: ReadonlySet<string> = new Set();
941
+
942
+ /** Stable projection of the tool-owned safe-display seam (never the full Tool surface). */
943
+ type SafeSummaryTool = Pick<Tool, "safeSummary" | "safeSummaryFields">;
944
+
945
+ export function projectToolSummary(
946
+ tool: SafeSummaryTool | undefined,
947
+ kind: "args" | "result",
948
+ value: unknown,
949
+ ): string | undefined {
950
+ let summary: string | undefined;
951
+ try {
952
+ if (tool?.safeSummary) {
953
+ summary = tool.safeSummary(kind, value);
954
+ } else {
955
+ const fields = tool?.safeSummaryFields?.[kind];
956
+ if (fields) {
957
+ const source =
958
+ value && typeof value === "object" && !Array.isArray(value)
959
+ ? (value as Record<string, unknown>)
960
+ : undefined;
961
+ if (source) {
962
+ const projected: Record<string, unknown> = {};
963
+ for (const field of fields) if (Object.hasOwn(source, field)) projected[field] = source[field];
964
+ summary = JSON.stringify(projected);
965
+ }
966
+ }
967
+ }
968
+ } catch {
969
+ return undefined;
970
+ }
971
+ if (typeof summary !== "string") return undefined;
972
+ const normalized = summary.replace(/[\u0000-\u001F\u007F-\u009F\u202A-\u202E\u2066-\u2069]/g, " ").trim();
973
+ if (!normalized || SENSITIVE_MODEL_LABEL.test(normalized)) return undefined;
974
+ return truncate(normalized, TOOL_SUMMARY_MAX);
921
975
  }
922
976
 
923
977
  /** Request-local requester authority for stable ControlSurface dispatches. */
@@ -934,9 +988,28 @@ function pushSessionFrame(
934
988
  frame: { type: string; [key: string]: unknown },
935
989
  ): void {
936
990
  runtime.host.emitEvent({ kind: frame.type, payload: frame });
991
+ if (frame.type === "turn_stream") {
992
+ runtime.server.pushTurnStreamUnchecked(
993
+ String(frame.sessionId),
994
+ frame.phase === "live" ? "live" : "finalized",
995
+ String(frame.text),
996
+ typeof frame.finalAnswer === "boolean" ? frame.finalAnswer : undefined,
997
+ typeof frame.messageRef === "string" ? frame.messageRef : undefined,
998
+ );
999
+ return;
1000
+ }
937
1001
  runtime.server.pushFrame(JSON.stringify(frame));
938
1002
  }
939
1003
 
1004
+ function pushFileAttachment(
1005
+ runtime: Pick<SessionRuntime, "server" | "host">,
1006
+ frame: { type: "file_attachment"; sessionId: string; name: string; mime?: string; caption?: string },
1007
+ data: Buffer,
1008
+ ): void {
1009
+ runtime.host.emitEvent({ kind: frame.type, payload: { ...frame, data: data.toString("base64") } });
1010
+ runtime.server.pushFileAttachmentUnchecked(frame.sessionId, frame.name, frame.mime, data, frame.caption);
1011
+ }
1012
+
940
1013
  /** Agent lifecycle is SDK session truth, independent of optional chat delivery. */
941
1014
  function emitAgentLifecycle(
942
1015
  runtime: Pick<SessionRuntime, "server" | "host">,
@@ -977,6 +1050,7 @@ const defaultConfig: NotificationConfig = {
977
1050
  rich: { enabled: true },
978
1051
  richDraft: { enabled: false },
979
1052
  topics: {},
1053
+ btw: { enabled: true },
980
1054
  };
981
1055
 
982
1056
  export function notificationsEnabled(): boolean {
@@ -1624,11 +1698,18 @@ function hasTerminalArbitrationCapability(
1624
1698
  Required<
1625
1699
  Pick<
1626
1700
  WorkflowGateEmitter,
1627
- "resolveGate" | "prepareTerminalization" | "clearPreparedTerminalization" | "registerGateTerminalController"
1701
+ | "resolveGate"
1702
+ | "recoverAcceptedGates"
1703
+ | "lookupCompletedResolution"
1704
+ | "prepareTerminalization"
1705
+ | "clearPreparedTerminalization"
1706
+ | "registerGateTerminalController"
1628
1707
  >
1629
1708
  > {
1630
1709
  return (
1631
1710
  typeof workflowGate?.resolveGate === "function" &&
1711
+ typeof workflowGate.recoverAcceptedGates === "function" &&
1712
+ typeof workflowGate.lookupCompletedResolution === "function" &&
1632
1713
  typeof workflowGate.prepareTerminalization === "function" &&
1633
1714
  typeof workflowGate.clearPreparedTerminalization === "function" &&
1634
1715
  typeof workflowGate.registerGateTerminalController === "function"
@@ -1982,7 +2063,7 @@ function sdkControlSurface(
1982
2063
  pending.completeDirect();
1983
2064
  return { resolved: true };
1984
2065
  },
1985
- answerGate: async (id, response, expectedSessionId) => {
2066
+ answerGate: async (id, response, expectedSessionId, idempotencyKey) => {
1986
2067
  if (!acceptGateResolution())
1987
2068
  throw Object.assign(new Error("Workflow gate is no longer answerable."), { code: "resource_gone" });
1988
2069
  if (expectedSessionId === undefined) auditMissingExpectedSessionId("workflow.gate_answer");
@@ -1995,6 +2076,26 @@ function sdkControlSurface(
1995
2076
  throw Object.assign(new Error("Workflow gates are unavailable for this session."), {
1996
2077
  code: "resource_gone",
1997
2078
  });
2079
+ const workflowGate = ctx.workflowGate;
2080
+ if (!hasTerminalArbitrationCapability(workflowGate))
2081
+ throw Object.assign(new Error("Workflow gates are unavailable for this session."), {
2082
+ code: "resource_gone",
2083
+ });
2084
+ const gateResponse = {
2085
+ gate_id: id,
2086
+ answer: response,
2087
+ idempotency_key: idempotencyKey ?? id,
2088
+ };
2089
+ const completed = workflowGate.lookupCompletedResolution(gateResponse);
2090
+ if (completed.kind === "completed") return completed.resolution;
2091
+ if (completed.kind === "accepted_incomplete") {
2092
+ await trackGateResolution(workflowGate.recoverAcceptedGates());
2093
+ const recovered = workflowGate.lookupCompletedResolution(gateResponse);
2094
+ if (recovered.kind === "completed") return recovered.resolution;
2095
+ throw Object.assign(new Error("Workflow gate resolution outcome is uncertain."), {
2096
+ code: "terminal_uncertain",
2097
+ });
2098
+ }
1998
2099
  const prepared = presentations.prepareDirectControl(id);
1999
2100
  if (!prepared || prepared.status === "stale")
2000
2101
  throw Object.assign(new Error("Workflow gate is no longer answerable."), { code: "resource_gone" });
@@ -2003,31 +2104,22 @@ function sdkControlSurface(
2003
2104
  if (prepared.status !== "queued" && prepared.status !== "retired")
2004
2105
  throw new Error(`Unexpected direct control preparation: ${prepared.status}`);
2005
2106
  if (
2006
- ctx.workflowGate?.prepareTerminalization?.(
2007
- id,
2008
- prepared.status === "queued" ? "not_published" : "retired",
2009
- ) !== true
2107
+ workflowGate.prepareTerminalization(id, prepared.status === "queued" ? "not_published" : "retired") !== true
2010
2108
  ) {
2011
2109
  presentations.finishDirectControl(id, prepared, "rejected");
2012
2110
  throw Object.assign(new Error("Workflow gate lacks a terminalization proof."), { code: "resource_gone" });
2013
2111
  }
2014
2112
  try {
2015
- const resolution = await trackGateResolution(
2016
- ctx.workflowGate?.resolveGate?.({
2017
- gate_id: id,
2018
- answer: response,
2019
- idempotency_key: id,
2020
- }) ?? unavailable("workflow.gate_answer", "workflow gates are unavailable for this session")(),
2021
- );
2113
+ const resolution = await trackGateResolution(workflowGate.resolveGate(gateResponse));
2022
2114
  const status = (resolution as { status?: unknown }).status;
2023
2115
  if (status === "accepted" || status === "rejected") {
2024
- if (status === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
2116
+ if (status === "rejected") workflowGate.clearPreparedTerminalization(id);
2025
2117
  presentations.finishDirectControl(id, prepared, status);
2026
2118
  return resolution;
2027
2119
  }
2028
2120
  } catch (error) {
2029
2121
  const outcome = reconcileDirectControlFailure(id);
2030
- if (outcome === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
2122
+ if (outcome === "rejected") workflowGate.clearPreparedTerminalization(id);
2031
2123
  presentations.finishDirectControl(id, prepared, outcome);
2032
2124
  if (outcome === "unknown")
2033
2125
  throw Object.assign(new Error("Workflow gate resolution outcome is uncertain."), {
@@ -2036,7 +2128,7 @@ function sdkControlSurface(
2036
2128
  throw error;
2037
2129
  }
2038
2130
  const outcome = reconcileDirectControlFailure(id);
2039
- if (outcome === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
2131
+ if (outcome === "rejected") workflowGate.clearPreparedTerminalization(id);
2040
2132
  presentations.finishDirectControl(id, prepared, outcome);
2041
2133
  logger.warn("workflow_gate_direct_control_uncertain_outcome", {
2042
2134
  operation: "workflow.gate_answer",
@@ -2060,6 +2152,22 @@ function sdkControlSurface(
2060
2152
  throw Object.assign(new Error("Workflow gates are unavailable for this session."), {
2061
2153
  code: "resource_gone",
2062
2154
  });
2155
+ const workflowGate = ctx.workflowGate;
2156
+ if (!hasTerminalArbitrationCapability(workflowGate))
2157
+ throw Object.assign(new Error("Workflow gates are unavailable for this session."), {
2158
+ code: "resource_gone",
2159
+ });
2160
+ const gateResponse = { gate_id: id, answer: choice, idempotency_key: id };
2161
+ const completed = workflowGate.lookupCompletedResolution(gateResponse);
2162
+ if (completed.kind === "completed") return completed.resolution;
2163
+ if (completed.kind === "accepted_incomplete") {
2164
+ await trackGateResolution(workflowGate.recoverAcceptedGates());
2165
+ const recovered = workflowGate.lookupCompletedResolution(gateResponse);
2166
+ if (recovered.kind === "completed") return recovered.resolution;
2167
+ throw Object.assign(new Error("Workflow plan resolution outcome is uncertain."), {
2168
+ code: "terminal_uncertain",
2169
+ });
2170
+ }
2063
2171
  const prepared = presentations.prepareDirectControl(id);
2064
2172
  if (!prepared || prepared.status === "stale")
2065
2173
  throw Object.assign(new Error("Workflow plan is no longer answerable."), { code: "resource_gone" });
@@ -2068,31 +2176,22 @@ function sdkControlSurface(
2068
2176
  if (prepared.status !== "queued" && prepared.status !== "retired")
2069
2177
  throw new Error(`Unexpected direct control preparation: ${prepared.status}`);
2070
2178
  if (
2071
- ctx.workflowGate?.prepareTerminalization?.(
2072
- id,
2073
- prepared.status === "queued" ? "not_published" : "retired",
2074
- ) !== true
2179
+ workflowGate.prepareTerminalization(id, prepared.status === "queued" ? "not_published" : "retired") !== true
2075
2180
  ) {
2076
2181
  presentations.finishDirectControl(id, prepared, "rejected");
2077
2182
  throw Object.assign(new Error("Workflow plan lacks a terminalization proof."), { code: "resource_gone" });
2078
2183
  }
2079
2184
  try {
2080
- const resolution = await trackGateResolution(
2081
- ctx.workflowGate?.resolveGate?.({
2082
- gate_id: id,
2083
- answer: choice,
2084
- idempotency_key: id,
2085
- }) ?? unavailable("workflow.plan_approve", "workflow gates are unavailable for this session")(),
2086
- );
2185
+ const resolution = await trackGateResolution(workflowGate.resolveGate(gateResponse));
2087
2186
  const status = (resolution as { status?: unknown }).status;
2088
2187
  if (status === "accepted" || status === "rejected") {
2089
- if (status === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
2188
+ if (status === "rejected") workflowGate.clearPreparedTerminalization(id);
2090
2189
  presentations.finishDirectControl(id, prepared, status);
2091
2190
  return resolution;
2092
2191
  }
2093
2192
  } catch (error) {
2094
2193
  const outcome = reconcileDirectControlFailure(id);
2095
- if (outcome === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
2194
+ if (outcome === "rejected") workflowGate.clearPreparedTerminalization(id);
2096
2195
  presentations.finishDirectControl(id, prepared, outcome);
2097
2196
  if (outcome === "unknown")
2098
2197
  throw Object.assign(new Error("Workflow plan resolution outcome is uncertain."), {
@@ -2101,7 +2200,7 @@ function sdkControlSurface(
2101
2200
  throw error;
2102
2201
  }
2103
2202
  const outcome = reconcileDirectControlFailure(id);
2104
- if (outcome === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
2203
+ if (outcome === "rejected") workflowGate.clearPreparedTerminalization(id);
2105
2204
  presentations.finishDirectControl(id, prepared, outcome);
2106
2205
  logger.warn("workflow_gate_direct_control_uncertain_outcome", {
2107
2206
  operation: "workflow.plan_approve",
@@ -2120,14 +2219,14 @@ function sdkControlSurface(
2120
2219
  throw Object.assign(new Error("skill.invoke args must be a string."), { code: "invalid_input" });
2121
2220
  return ctx.invokeSkill(name, args);
2122
2221
  },
2123
- setPlanMode: on => {
2222
+ setPlanMode: async on => {
2124
2223
  if (!bindings.has("setPlanMode") || !ctx.setPlanMode)
2125
2224
  return unavailable("mode.plan.set", "no plan-mode seam is installed")();
2126
2225
 
2127
2226
  if (typeof on !== "boolean")
2128
2227
  throw Object.assign(new Error("mode.plan.set requires a boolean on value."), { code: "invalid_input" });
2129
2228
 
2130
- return { state: ctx.setPlanMode(on) };
2229
+ return { state: await ctx.setPlanMode(on) };
2131
2230
  },
2132
2231
  operateGoal: (op, objective) => {
2133
2232
  if (!bindings.has("operateGoal") || !ctx.operateGoal)
@@ -2248,6 +2347,367 @@ function sdkControlSurface(
2248
2347
  return surface;
2249
2348
  }
2250
2349
 
2350
+ const EPHEMERAL_TURN_DEADLINE_MS = 120_000;
2351
+ const EPHEMERAL_TURN_TTL_MS = 300_000;
2352
+ const EPHEMERAL_TURN_MAX_RECORDS = 256;
2353
+ const EPHEMERAL_TURN_MAX_ACTIVE_PER_SESSION = 2;
2354
+ const EPHEMERAL_TURN_MAX_RESULT_BYTES = 262_144;
2355
+
2356
+ interface EphemeralTurnTuple {
2357
+ sessionId: string;
2358
+ requestId: string;
2359
+ updateId: number;
2360
+ messageId: number;
2361
+ threadId: string;
2362
+ }
2363
+
2364
+ type EphemeralTurnStatus = "ok" | "busy" | "timeout" | "cancelled" | "session_unavailable" | "failed";
2365
+
2366
+ interface EphemeralTurnAuthority {
2367
+ sessionId: string;
2368
+ endpointDigest: string;
2369
+ eventGeneration: number;
2370
+ }
2371
+
2372
+ interface EphemeralTurnEvent {
2373
+ tuple: EphemeralTurnTuple;
2374
+ authority: EphemeralTurnAuthority;
2375
+ status: EphemeralTurnStatus;
2376
+ text?: string;
2377
+ completedAt: number;
2378
+ expiresAt: number;
2379
+ }
2380
+
2381
+ interface EphemeralTurnTombstone {
2382
+ tuple: EphemeralTurnTuple;
2383
+ authority: EphemeralTurnAuthority;
2384
+ status: EphemeralTurnStatus;
2385
+ completedAt: number;
2386
+ expiresAt: number;
2387
+ }
2388
+
2389
+ interface ActiveEphemeralTurn {
2390
+ tuple: EphemeralTurnTuple;
2391
+ authority: EphemeralTurnAuthority;
2392
+ connectionId: string;
2393
+ staleConnectionIds: Set<string>;
2394
+ controller: AbortController;
2395
+ subscribers: Set<string>;
2396
+ deadline: NodeJS.Timeout;
2397
+ abortListener: () => void;
2398
+ }
2399
+
2400
+ function ephemeralTuple(frame: Record<string, unknown>): EphemeralTurnTuple | undefined {
2401
+ const { sessionId, requestId, updateId, messageId, threadId } = frame;
2402
+ return typeof sessionId === "string" &&
2403
+ typeof requestId === "string" &&
2404
+ typeof updateId === "number" &&
2405
+ Number.isSafeInteger(updateId) &&
2406
+ typeof messageId === "number" &&
2407
+ Number.isSafeInteger(messageId) &&
2408
+ messageId > 0 &&
2409
+ typeof threadId === "string"
2410
+ ? { sessionId, requestId, updateId, messageId, threadId }
2411
+ : undefined;
2412
+ }
2413
+
2414
+ function sameEphemeralTuple(left: EphemeralTurnTuple, right: EphemeralTurnTuple): boolean {
2415
+ return (
2416
+ left.sessionId === right.sessionId &&
2417
+ left.requestId === right.requestId &&
2418
+ left.updateId === right.updateId &&
2419
+ left.messageId === right.messageId &&
2420
+ left.threadId === right.threadId
2421
+ );
2422
+ }
2423
+
2424
+ function ephemeralTupleKey(tuple: EphemeralTurnTuple): string {
2425
+ return JSON.stringify([tuple.sessionId, tuple.requestId, tuple.updateId, tuple.messageId, tuple.threadId]);
2426
+ }
2427
+
2428
+ /** Host-owned, bounded idempotency and cancellation lifecycle for v3 side turns. */
2429
+ export class EphemeralTurnHost {
2430
+ #active = new Map<string, ActiveEphemeralTurn>();
2431
+ #terminalEvents = new Map<string, EphemeralTurnEvent>();
2432
+ #tombstones = new Map<string, EphemeralTurnTombstone>();
2433
+ #expiryTimer: NodeJS.Timeout | undefined;
2434
+ #disposed = false;
2435
+ #enabled = true;
2436
+ #now: () => number;
2437
+ #sendTo: (connectionId: string, frame: Record<string, unknown>) => void;
2438
+ #execute: (question: string, signal: AbortSignal) => Promise<{ replyText: string }>;
2439
+ #authority: EphemeralTurnAuthority | undefined;
2440
+
2441
+ constructor(
2442
+ sendTo: (connectionId: string, frame: Record<string, unknown>) => void,
2443
+ execute: (question: string, signal: AbortSignal) => Promise<{ replyText: string }>,
2444
+ now: () => number = Date.now,
2445
+ ) {
2446
+ this.#sendTo = sendTo;
2447
+ this.#execute = execute;
2448
+ this.#now = now;
2449
+ }
2450
+
2451
+ configureAuthority(authority: EphemeralTurnAuthority): void {
2452
+ if (this.#authority && !this.#sameAuthority(this.#authority, authority))
2453
+ for (const active of [...this.#active.values()]) active.controller.abort("session_unavailable");
2454
+ this.#authority = { ...authority };
2455
+ }
2456
+
2457
+ disable(): void {
2458
+ if (this.#disposed) return;
2459
+ this.#enabled = false;
2460
+ for (const active of this.#active.values()) active.controller.abort("session_unavailable");
2461
+ this.#terminalEvents.clear();
2462
+ this.#tombstones.clear();
2463
+ if (this.#expiryTimer) clearTimeout(this.#expiryTimer);
2464
+ this.#expiryTimer = undefined;
2465
+ }
2466
+
2467
+ enable(): void {
2468
+ if (!this.#disposed) this.#enabled = true;
2469
+ }
2470
+
2471
+ dispose(): void {
2472
+ this.#disposed = true;
2473
+ for (const active of this.#active.values()) active.controller.abort("session_unavailable");
2474
+ if (this.#expiryTimer) clearTimeout(this.#expiryTimer);
2475
+ this.#expiryTimer = undefined;
2476
+ this.#terminalEvents.clear();
2477
+ this.#tombstones.clear();
2478
+ }
2479
+
2480
+ handle(connectionId: string, frame: Record<string, unknown>): boolean {
2481
+ if (!this.#enabled) return frame.type === "ephemeral_turn" || frame.type === "ephemeral_turn_cancel";
2482
+ if (frame.type === "ephemeral_turn") return this.#start(connectionId, frame);
2483
+ if (frame.type === "ephemeral_turn_cancel") return this.#cancel(connectionId, frame);
2484
+ return false;
2485
+ }
2486
+
2487
+ sessionUnavailable(sessionId: string): void {
2488
+ for (const active of [...this.#active.values()])
2489
+ if (active.tuple.sessionId === sessionId) active.controller.abort("session_unavailable");
2490
+ }
2491
+
2492
+ /** Testable event-ring eviction boundary; tombstones remain idempotency authority. */
2493
+ evictTerminalEvents(): void {
2494
+ this.#terminalEvents.clear();
2495
+ }
2496
+
2497
+ #start(connectionId: string, frame: Record<string, unknown>): boolean {
2498
+ const tuple = ephemeralTuple(frame);
2499
+ const question = typeof frame.question === "string" ? frame.question.trim() : "";
2500
+ const authority = this.#authority;
2501
+ if (!tuple || !question || !authority || tuple.sessionId !== authority.sessionId) return true;
2502
+ this.#purge();
2503
+ const key = ephemeralTupleKey(tuple);
2504
+ const active = this.#active.get(key);
2505
+ if (active) {
2506
+ if (!this.#sameAuthority(active.authority, authority)) {
2507
+ active.controller.abort("session_unavailable");
2508
+ return true;
2509
+ }
2510
+ if (active.connectionId === connectionId || active.staleConnectionIds.has(connectionId)) return true;
2511
+ active.staleConnectionIds.add(active.connectionId);
2512
+ active.connectionId = connectionId;
2513
+ active.subscribers = new Set([connectionId]);
2514
+ return true;
2515
+ }
2516
+ const event = this.#terminalEvents.get(key);
2517
+ if (event) {
2518
+ if (this.#sameAuthority(event.authority, authority))
2519
+ this.#send(connectionId, event.tuple, event.status, event.text);
2520
+ return true;
2521
+ }
2522
+ const tombstone = this.#tombstones.get(key);
2523
+ if (tombstone) {
2524
+ if (this.#sameAuthority(tombstone.authority, authority)) this.#send(connectionId, tombstone.tuple, "failed");
2525
+ return true;
2526
+ }
2527
+ for (const candidate of this.#tombstones.values()) {
2528
+ if (candidate.tuple.sessionId === tuple.sessionId && candidate.tuple.requestId === tuple.requestId) {
2529
+ logger.warn("notifications: ephemeral request id conflict", {
2530
+ sessionId: tuple.sessionId,
2531
+ requestId: tuple.requestId,
2532
+ });
2533
+ return true;
2534
+ }
2535
+ }
2536
+ for (const candidate of this.#active.values()) {
2537
+ if (candidate.tuple.sessionId === tuple.sessionId && candidate.tuple.requestId === tuple.requestId) {
2538
+ logger.warn("notifications: ephemeral request id conflict", {
2539
+ sessionId: tuple.sessionId,
2540
+ requestId: tuple.requestId,
2541
+ });
2542
+ return true;
2543
+ }
2544
+ }
2545
+ const activeForSession = [...this.#active.values()].filter(
2546
+ candidate => candidate.tuple.sessionId === tuple.sessionId,
2547
+ ).length;
2548
+ if (activeForSession >= EPHEMERAL_TURN_MAX_ACTIVE_PER_SESSION) {
2549
+ const completedAt = this.#now();
2550
+ this.#finish(key, {
2551
+ tuple,
2552
+ authority: { ...authority },
2553
+ status: "busy",
2554
+ completedAt,
2555
+ expiresAt: completedAt + EPHEMERAL_TURN_TTL_MS,
2556
+ });
2557
+ this.#send(connectionId, tuple, "busy");
2558
+ return true;
2559
+ }
2560
+ const controller = new AbortController();
2561
+ const abortListener = () => this.#complete(key, this.#abortStatus(controller.signal));
2562
+ const record: ActiveEphemeralTurn = {
2563
+ tuple,
2564
+ authority: { ...authority },
2565
+ connectionId,
2566
+ controller,
2567
+ subscribers: new Set([connectionId]),
2568
+ staleConnectionIds: new Set(),
2569
+ deadline: setTimeout(() => controller.abort("timeout"), EPHEMERAL_TURN_DEADLINE_MS),
2570
+ abortListener,
2571
+ };
2572
+ this.#active.set(key, record);
2573
+ controller.signal.addEventListener("abort", abortListener, { once: true });
2574
+ void this.#execute(question, controller.signal).then(
2575
+ result =>
2576
+ this.#complete(
2577
+ key,
2578
+ controller.signal.aborted ? this.#abortStatus(controller.signal) : "ok",
2579
+ result.replyText,
2580
+ ),
2581
+ () => this.#complete(key, controller.signal.aborted ? this.#abortStatus(controller.signal) : "failed"),
2582
+ );
2583
+ return true;
2584
+ }
2585
+
2586
+ #cancel(connectionId: string, frame: Record<string, unknown>): boolean {
2587
+ const tuple = ephemeralTuple(frame);
2588
+ const authority = this.#authority;
2589
+ if (!tuple || frame.reason !== "daemon_shutdown" || !authority || tuple.sessionId !== authority.sessionId)
2590
+ return true;
2591
+ const active = this.#active.get(ephemeralTupleKey(tuple));
2592
+ if (
2593
+ !active ||
2594
+ !sameEphemeralTuple(active.tuple, tuple) ||
2595
+ active.connectionId !== connectionId ||
2596
+ !this.#sameAuthority(active.authority, authority)
2597
+ )
2598
+ return true;
2599
+ active.controller.abort("cancelled");
2600
+ return true;
2601
+ }
2602
+
2603
+ #abortStatus(signal: AbortSignal): EphemeralTurnStatus {
2604
+ return signal.reason === "timeout"
2605
+ ? "timeout"
2606
+ : signal.reason === "session_unavailable"
2607
+ ? "session_unavailable"
2608
+ : "cancelled";
2609
+ }
2610
+
2611
+ #sameAuthority(left: EphemeralTurnAuthority, right: EphemeralTurnAuthority): boolean {
2612
+ return (
2613
+ left.sessionId === right.sessionId &&
2614
+ left.endpointDigest === right.endpointDigest &&
2615
+ left.eventGeneration === right.eventGeneration
2616
+ );
2617
+ }
2618
+
2619
+ #complete(key: string, status: EphemeralTurnStatus, text?: string): void {
2620
+ const active = this.#active.get(key);
2621
+ if (!active) return;
2622
+ clearTimeout(active.deadline);
2623
+ active.controller.signal.removeEventListener("abort", active.abortListener);
2624
+ this.#active.delete(key);
2625
+ if (this.#disposed || !this.#enabled) return;
2626
+ const terminalTextIsValid =
2627
+ typeof text === "string" &&
2628
+ text.trim().length > 0 &&
2629
+ Buffer.byteLength(text, "utf8") <= EPHEMERAL_TURN_MAX_RESULT_BYTES;
2630
+ const terminalStatus = status === "ok" && !terminalTextIsValid ? "failed" : status;
2631
+ const completedAt = this.#now();
2632
+ const terminal: EphemeralTurnEvent = {
2633
+ tuple: active.tuple,
2634
+ authority: active.authority,
2635
+ status: terminalStatus,
2636
+ ...(terminalStatus === "ok" ? { text: text ?? "" } : {}),
2637
+ completedAt,
2638
+ expiresAt: completedAt + EPHEMERAL_TURN_TTL_MS,
2639
+ };
2640
+ this.#finish(key, terminal);
2641
+ for (const connectionId of active.subscribers) {
2642
+ try {
2643
+ this.#send(connectionId, terminal.tuple, terminal.status, terminal.text);
2644
+ } catch {
2645
+ // Directed SDK delivery has already logged the disconnected route.
2646
+ }
2647
+ }
2648
+ }
2649
+
2650
+ #finish(key: string, terminal: EphemeralTurnEvent): void {
2651
+ this.#terminalEvents.set(key, terminal);
2652
+ this.#tombstones.set(key, {
2653
+ tuple: terminal.tuple,
2654
+ authority: terminal.authority,
2655
+ status: terminal.status,
2656
+ completedAt: terminal.completedAt,
2657
+ expiresAt: terminal.expiresAt,
2658
+ });
2659
+ this.#purge();
2660
+ while (this.#terminalEvents.size > EPHEMERAL_TURN_MAX_RECORDS)
2661
+ this.#terminalEvents.delete(this.#terminalEvents.keys().next().value!);
2662
+ while (this.#tombstones.size > EPHEMERAL_TURN_MAX_RECORDS) {
2663
+ const oldestKey = this.#tombstones.keys().next().value!;
2664
+ this.#tombstones.delete(oldestKey);
2665
+ this.#terminalEvents.delete(oldestKey);
2666
+ }
2667
+ this.#scheduleExpiry();
2668
+ }
2669
+
2670
+ #send(connectionId: string, tuple: EphemeralTurnTuple, status: EphemeralTurnStatus, text?: string): void {
2671
+ this.#sendTo(connectionId, {
2672
+ type: "ephemeral_turn_result",
2673
+ ...tuple,
2674
+ status,
2675
+ ...(status === "ok" ? { text: text ?? "" } : {}),
2676
+ });
2677
+ }
2678
+
2679
+ #purge(): void {
2680
+ const now = this.#now();
2681
+ for (const [key, tombstone] of this.#tombstones) {
2682
+ if (tombstone.expiresAt > now) continue;
2683
+ this.#tombstones.delete(key);
2684
+ this.#terminalEvents.delete(key);
2685
+ }
2686
+ }
2687
+
2688
+ #scheduleExpiry(): void {
2689
+ if (this.#disposed) return;
2690
+ if (this.#expiryTimer) clearTimeout(this.#expiryTimer);
2691
+ const nextExpiry = [...this.#tombstones.values()].reduce(
2692
+ (earliest, tombstone) => Math.min(earliest, tombstone.expiresAt),
2693
+ Number.POSITIVE_INFINITY,
2694
+ );
2695
+ if (!Number.isFinite(nextExpiry)) {
2696
+ this.#expiryTimer = undefined;
2697
+ return;
2698
+ }
2699
+ this.#expiryTimer = setTimeout(
2700
+ () => {
2701
+ this.#expiryTimer = undefined;
2702
+ if (this.#disposed) return;
2703
+ this.#purge();
2704
+ this.#scheduleExpiry();
2705
+ },
2706
+ Math.max(0, nextExpiry - this.#now()),
2707
+ );
2708
+ this.#expiryTimer.unref();
2709
+ }
2710
+ }
2251
2711
  /** Parse only v3 frames carried through the existing control-command seam. */
2252
2712
  function sdkInboundFrame(commandJson: string | undefined): Record<string, unknown> | undefined {
2253
2713
  if (!commandJson) return undefined;
@@ -2283,6 +2743,7 @@ export function createNotificationsExtension(
2283
2743
  controller?: NotificationSessionController;
2284
2744
 
2285
2745
  onSdkRequest?: (kind: "control" | "query", connectionId: string, frame: Record<string, unknown>) => void;
2746
+ runEphemeralTurn?: (promptText: string, signal: AbortSignal) => Promise<{ replyText: string }>;
2286
2747
  } = {},
2287
2748
  ): void {
2288
2749
  const lifecycleStartupCapability = lifecycleStartupCapabilityForApi(api);
@@ -2294,10 +2755,14 @@ export function createNotificationsExtension(
2294
2755
  getConfig: () => resolveSettings(options.settings).cfg,
2295
2756
  });
2296
2757
 
2758
+ // Failed terminal teardown remains fenced from normal runtime lookup while the
2759
+ // exact runtime object retains authority for an explicit idempotent retry.
2760
+ const cleanupRetries = new Map<string, SessionRuntime>();
2297
2761
  const sessionStartPromises = new Map<string, Promise<SessionStartResult>>();
2298
2762
  let activeRuntimeId: string | undefined;
2299
2763
  let identityControlInFlight = false;
2300
2764
  let deferredIdentityRotation: { event: { previousSessionFile?: string }; ctx: ExtensionContext } | undefined;
2765
+ let extensionShuttingDown = false;
2301
2766
 
2302
2767
  async function ensureTelegramOwner(
2303
2768
  settings: Settings,
@@ -2341,13 +2806,18 @@ export function createNotificationsExtension(
2341
2806
  reason: "session" | "notifications" = "session",
2342
2807
  expectedRuntime?: SessionRuntime,
2343
2808
  ): Promise<boolean> {
2344
- const requestedRuntime = runtimes.get(id);
2809
+ const retryRuntime = cleanupRetries.get(id);
2810
+ const activeRuntime = runtimes.get(id);
2811
+ const requestedRuntime = retryRuntime ?? activeRuntime;
2345
2812
  if (expectedRuntime && requestedRuntime !== expectedRuntime) return false;
2346
- if (reason === "session" && requestedRuntime) requestedRuntime.stopping = true;
2813
+ if (reason === "session" && requestedRuntime) {
2814
+ requestedRuntime.stopping = true;
2815
+ requestedRuntime.abortEphemeralTurns();
2816
+ }
2347
2817
  if (reason === "session" && requestedRuntime) {
2348
2818
  // Fence the exact runtime before awaiting its startup promise: a late start
2349
2819
  // must observe removal and clean itself up rather than becoming reachable.
2350
- runtimes.delete(id);
2820
+ if (runtimes.get(id) === requestedRuntime) runtimes.delete(id);
2351
2821
  if (activeRuntimeId === id) activeRuntimeId = undefined;
2352
2822
  }
2353
2823
  const pendingStart = sessionStartPromises.get(id);
@@ -2355,7 +2825,13 @@ export function createNotificationsExtension(
2355
2825
  void pendingStart
2356
2826
  .catch(() => {})
2357
2827
  .then(() => {
2358
- if (runtimes.get(id) === requestedRuntime) void stopSession(id, reason, requestedRuntime);
2828
+ if (runtimes.get(id) === requestedRuntime || cleanupRetries.get(id) === requestedRuntime)
2829
+ void stopSession(id, reason, requestedRuntime).catch(error =>
2830
+ // A retained owner-release failure keeps the exact runtime in
2831
+ // cleanupRetries for a later retry; log it rather than letting a
2832
+ // fire-and-forget rejection become a fatal unhandled rejection.
2833
+ logger.error(`notifications: SDK notification runtime cleanup failed: ${String(error)}`),
2834
+ );
2359
2835
  });
2360
2836
  const rt = requestedRuntime;
2361
2837
 
@@ -2367,6 +2843,7 @@ export function createNotificationsExtension(
2367
2843
  }
2368
2844
  if (reason === "notifications" && rt.host.started) {
2369
2845
  rt.notificationsActive = false;
2846
+ rt.disableEphemeralTurns();
2370
2847
  try {
2371
2848
  rt.disposeAnswerSource();
2372
2849
  } catch {}
@@ -2378,13 +2855,13 @@ export function createNotificationsExtension(
2378
2855
  rt.pendingInteractive.clear();
2379
2856
  return true;
2380
2857
  }
2858
+ // Keep this exact object authoritative for the full terminal release, including
2859
+ // the interval before a failed owner can be recorded for a later retry.
2860
+ cleanupRetries.set(id, rt);
2381
2861
 
2382
2862
  try {
2383
2863
  rt.cancelPostmortemCleanup();
2384
2864
  } catch {}
2385
- try {
2386
- rt.stopBrokerHeartbeat();
2387
- } catch {}
2388
2865
  try {
2389
2866
  rt.disposeAnswerSource();
2390
2867
  } catch {}
@@ -2394,6 +2871,9 @@ export function createNotificationsExtension(
2394
2871
  try {
2395
2872
  rt.disposeGateListener();
2396
2873
  } catch {}
2874
+ try {
2875
+ rt.workflowGate?.setRuntimeTurnProvider?.(null);
2876
+ } catch {}
2397
2877
  await rt.waitForGateResolutionQuiescence();
2398
2878
  try {
2399
2879
  rt.disposeAckRecoveryParticipant();
@@ -2405,16 +2885,24 @@ export function createNotificationsExtension(
2405
2885
  try {
2406
2886
  rt.disposeGateTerminalController();
2407
2887
  } catch {}
2408
- let hostStopped = false;
2409
- let brokerRegistrationReleased = false;
2888
+ let hostStopped = rt.hostStopped;
2889
+ let brokerRegistrationReleased = rt.brokerRegistrationReleased;
2890
+ const ownerReleaseFailures: unknown[] = [];
2410
2891
 
2411
- try {
2412
- const stopped = await rt.host.stop();
2413
- hostStopped = stopped === "stopped";
2414
- brokerRegistrationReleased = !rt.brokerRegistrationActive || stopped === "stopped";
2415
- if (rt.brokerRegistrationActive && stopped === "stopped") rt.brokerRegistrationActive = false;
2416
- } catch (e) {
2417
- logger.warn(`sdk host: stop failed: ${String(e)}`);
2892
+ if (!hostStopped) {
2893
+ try {
2894
+ const stopped = await rt.host.stop();
2895
+ hostStopped = stopped === "stopped";
2896
+ brokerRegistrationReleased = !rt.brokerRegistrationActive || hostStopped;
2897
+ if (rt.brokerRegistrationActive && hostStopped) rt.brokerRegistrationActive = false;
2898
+ if (hostStopped) {
2899
+ rt.hostStopped = true;
2900
+ rt.brokerRegistrationReleased = brokerRegistrationReleased;
2901
+ }
2902
+ } catch (e) {
2903
+ ownerReleaseFailures.push(e);
2904
+ logger.warn(`sdk host: stop failed: ${String(e)}`);
2905
+ }
2418
2906
  }
2419
2907
  rt.host.reverse.dispose();
2420
2908
  // Resolve any still-pending interactive asks so the ask tool is not left hanging.
@@ -2424,6 +2912,7 @@ export function createNotificationsExtension(
2424
2912
  rt.cursors.close();
2425
2913
  await rt.revisions.close();
2426
2914
  } catch (e) {
2915
+ ownerReleaseFailures.push(e);
2427
2916
  logger.warn(`sdk query snapshots: close failed: ${String(e)}`);
2428
2917
  }
2429
2918
  let closeFrameSent = false;
@@ -2434,18 +2923,27 @@ export function createNotificationsExtension(
2434
2923
  logger.warn(`notifications: session_closed failed: ${String(e)}`);
2435
2924
  }
2436
2925
  if (closeFrameSent) await sleep(100);
2437
- let serverStopped = false;
2438
- try {
2439
- rt.server.stop();
2440
- serverStopped = true;
2441
- } catch (e) {
2442
- logger.warn(`notifications: stop failed: ${String(e)}`);
2926
+ let serverStopped = rt.serverStopped;
2927
+ if (!serverStopped) {
2928
+ try {
2929
+ await rt.server.stopAndWait();
2930
+ serverStopped = true;
2931
+ rt.serverStopped = true;
2932
+ } catch (e) {
2933
+ ownerReleaseFailures.push(e);
2934
+ logger.warn(`notifications: stop failed: ${String(e)}`);
2935
+ }
2443
2936
  }
2444
2937
  lifecycleStartupCapability?.rollback?.recordStop(rt.host.generation, {
2445
2938
  runtimeRemoved: true,
2446
- hostStopped: hostStopped && serverStopped,
2447
- brokerRegistrationReleased,
2939
+ hostStopped: rt.hostStopped && rt.serverStopped,
2940
+ brokerRegistrationReleased: rt.brokerRegistrationReleased,
2448
2941
  });
2942
+ if (ownerReleaseFailures.length > 0) {
2943
+ cleanupRetries.set(id, rt);
2944
+ throw new AggregateError(ownerReleaseFailures, `SDK notification runtime ${id} owner release failed.`);
2945
+ }
2946
+ if (cleanupRetries.get(id) === rt) cleanupRetries.delete(id);
2449
2947
  return true;
2450
2948
  }
2451
2949
 
@@ -2484,6 +2982,8 @@ export function createNotificationsExtension(
2484
2982
  if (lifecycleRequired && !isNotificationEligibleContext(ctx)) return failLifecycleStartup("ineligible");
2485
2983
  const pendingStart = sessionStartPromises.get(id);
2486
2984
  if (pendingStart) return pendingStart;
2985
+ const retainedCleanup = cleanupRetries.get(id);
2986
+ if (retainedCleanup) return failLifecycleStartup("failed", "SDK notification runtime cleanup is still pending.");
2487
2987
  const existingRuntime = runtimes.get(id);
2488
2988
  if (existingRuntime) {
2489
2989
  activeRuntimeId = id;
@@ -2526,7 +3026,6 @@ export function createNotificationsExtension(
2526
3026
  }
2527
3027
  const gatePresentations = new PresentationArbiter(server, () => runtime?.redact ?? redact, tag);
2528
3028
  let inboundSdkFrame: ((connectionId: string, frame: Record<string, unknown>) => void) | undefined;
2529
- let stopBrokerHeartbeat = () => {};
2530
3029
  const inFlightGateResolutions = new Set<Promise<void>>();
2531
3030
  const trackGateResolution = <T>(resolution: Promise<T>): Promise<T> => {
2532
3031
  const quiesced = resolution.then(
@@ -2568,6 +3067,8 @@ export function createNotificationsExtension(
2568
3067
  if (capability === "permission") ctx.setSdkPermissionProvider?.(undefined);
2569
3068
  };
2570
3069
 
3070
+ const hostCapCache = new Map<string, ReadonlySet<string>>();
3071
+
2571
3072
  const configOverrides = new Map<string, unknown>();
2572
3073
  const configRevision = { current: 0 };
2573
3074
  const PROMPT_SUBMISSION_CAPACITY = 128;
@@ -2726,6 +3227,11 @@ export function createNotificationsExtension(
2726
3227
  submission.failed = true;
2727
3228
  submission.error = error;
2728
3229
  removePendingPromptCorrelation(correlation);
3230
+ if (
3231
+ runtime?.activePromptCorrelation?.commandId === correlation.commandId &&
3232
+ runtime.activePromptCorrelation.turnId === correlation.turnId
3233
+ )
3234
+ runtime.activePromptCorrelation = undefined;
2729
3235
  emitPromptFailure(correlation, error);
2730
3236
  };
2731
3237
  const acknowledgePrompt = (connectionId: string, correlation: { commandId: string; turnId: string }) => {
@@ -2793,6 +3299,10 @@ export function createNotificationsExtension(
2793
3299
  };
2794
3300
 
2795
3301
  const sendSdkFrame = (connectionId: string, frame: Record<string, unknown>) => {
3302
+ if (extensionShuttingDown || runtime?.stopping || runtimes.get(id) !== runtime) {
3303
+ abandonPromptResponse(connectionId, frame);
3304
+ return;
3305
+ }
2796
3306
  const json = JSON.stringify(frame);
2797
3307
  if (connectionId.startsWith("seam:")) {
2798
3308
  try {
@@ -2824,6 +3334,7 @@ export function createNotificationsExtension(
2824
3334
  stateRoot,
2825
3335
  token,
2826
3336
  sendFrame: (connectionId, frame) => sendSdkFrame(connectionId, frame),
3337
+ connectionCapabilities: connectionId => hostCapCache.get(connectionId) ?? EMPTY_CAPABILITIES,
2827
3338
  installProviderDefinitions,
2828
3339
  onProviderDefinitionsRemoved: removeProviderDefinitions,
2829
3340
  onFrame: handler => {
@@ -2930,6 +3441,9 @@ export function createNotificationsExtension(
2930
3441
  idleSeq: 0,
2931
3442
  pendingInteractive,
2932
3443
  brokerRegistrationActive: false,
3444
+ hostStopped: false,
3445
+ serverStopped: false,
3446
+ brokerRegistrationReleased: false,
2933
3447
  disposeAnswerSource: () => {},
2934
3448
  disposeFileSink: () => {},
2935
3449
  disposeGateListener: () => {},
@@ -2938,14 +3452,16 @@ export function createNotificationsExtension(
2938
3452
  disposeGateTerminalController: () => {},
2939
3453
  disposeAckRecoveryParticipant: () => {},
2940
3454
  disposeGateEmitterListener: () => {},
3455
+ trackGateResolution,
2941
3456
  waitForGateResolutionQuiescence: async () => {
2942
3457
  await Promise.allSettled(inFlightGateResolutions);
2943
3458
  },
2944
3459
  workflowGate: undefined,
2945
3460
  gatePresentations,
2946
3461
  stopping: false,
3462
+ abortEphemeralTurns: () => {},
3463
+ disableEphemeralTurns: () => {},
2947
3464
  cancelPostmortemCleanup: () => {},
2948
- stopBrokerHeartbeat,
2949
3465
 
2950
3466
  redact,
2951
3467
  verbosity,
@@ -2957,8 +3473,10 @@ export function createNotificationsExtension(
2957
3473
  recordPromptTerminal,
2958
3474
  emitPromptLifecycle,
2959
3475
  pendingInbound: new Set<number>(),
3476
+ inFlightTools: new Map<string, { toolName: string; args: unknown }>(),
2960
3477
  };
2961
- runtimes.set(id, runtime);
3478
+ const initializedRuntime = runtime;
3479
+ runtimes.set(id, initializedRuntime);
2962
3480
  activeRuntimeId = id;
2963
3481
  const startSettled = Promise.withResolvers<SessionStartResult>();
2964
3482
  sessionStartPromises.set(id, startSettled.promise);
@@ -2980,7 +3498,7 @@ export function createNotificationsExtension(
2980
3498
  };
2981
3499
  const cleanupAbandonedStartup = async (): Promise<void> => {
2982
3500
  try {
2983
- server.stop();
3501
+ await server.stopAndWait();
2984
3502
  } catch {}
2985
3503
  try {
2986
3504
  await host.stop();
@@ -2992,16 +3510,40 @@ export function createNotificationsExtension(
2992
3510
  } catch {}
2993
3511
  };
2994
3512
 
3513
+ const ephemeralTurns = new EphemeralTurnHost(sendSdkFrame, async (question, signal) => {
3514
+ if (!options.runEphemeralTurn) throw new Error("Ephemeral turns are unavailable.");
3515
+ return await options.runEphemeralTurn(prompt.render(btwUserPrompt, { question }), signal);
3516
+ });
3517
+ initializedRuntime.abortEphemeralTurns = () => ephemeralTurns.dispose();
3518
+ initializedRuntime.disableEphemeralTurns = () => ephemeralTurns.disable();
2995
3519
  try {
2996
3520
  server.onSdkFrame((err, inbound) => {
2997
3521
  if (err || !inbound) return;
2998
3522
  try {
2999
- inboundSdkFrame?.(inbound.connectionId, JSON.parse(inbound.json) as Record<string, unknown>);
3523
+ const frame = JSON.parse(inbound.json) as unknown;
3524
+ if (!frame || typeof frame !== "object") return;
3525
+ const typedFrame = frame as Record<string, unknown>;
3526
+ if (typedFrame.type === "ephemeral_turn" || typedFrame.type === "ephemeral_turn_cancel") return;
3527
+ inboundSdkFrame?.(inbound.connectionId, typedFrame);
3000
3528
  } catch {}
3001
3529
  });
3530
+ // Required: the negotiated-capability callback is how the TS host learns
3531
+ // each connection's caps for replay-frame gating. If the linked
3532
+ // @gajae-code/natives binary predates it (linked/deduped installs where the
3533
+ // version did not change), fail loudly with an actionable message instead of
3534
+ // silently shipping a half-wired capability bridge.
3535
+ if (typeof server.onNegotiatedCapabilities !== "function") {
3536
+ throw new Error(
3537
+ "@gajae-code/natives is out of date: missing onNegotiatedCapabilities. Rebuild the native addon (bun --cwd=packages/natives run build).",
3538
+ );
3539
+ }
3540
+ server.onNegotiatedCapabilities((_err, connectionId, capabilities) => {
3541
+ if (connectionId) hostCapCache.set(connectionId, new Set(capabilities));
3542
+ });
3002
3543
  server.onConnectionClose((_err, connectionId) => {
3003
3544
  if (!connectionId) return;
3004
3545
  host.handleDisconnect(connectionId);
3546
+ hostCapCache.delete(connectionId);
3005
3547
  for (const submission of promptSubmissions.values())
3006
3548
  if (submission.connectionId === connectionId) abandonPrompt(submission);
3007
3549
  });
@@ -3223,6 +3765,11 @@ export function createNotificationsExtension(
3223
3765
  // thread (forwarded by the daemon over the WS, fail-closed at the daemon).
3224
3766
  server.onInbound((err, inbound) => {
3225
3767
  if (err || !inbound) return;
3768
+ const authenticatedInbound = inbound as typeof inbound & {
3769
+ connectionId: string;
3770
+ messageId?: number;
3771
+ reason?: string;
3772
+ };
3226
3773
  if (inbound.kind === "control_command") {
3227
3774
  const frame = sdkInboundFrame(inbound.commandJson);
3228
3775
  if (frame) {
@@ -3230,6 +3777,25 @@ export function createNotificationsExtension(
3230
3777
  return;
3231
3778
  }
3232
3779
  }
3780
+ if (
3781
+ (inbound.kind === "ephemeral_turn" || inbound.kind === "ephemeral_turn_cancel") &&
3782
+ !runtime?.notificationsActive
3783
+ )
3784
+ return;
3785
+ if (inbound.kind === "ephemeral_turn" || inbound.kind === "ephemeral_turn_cancel") {
3786
+ ephemeralTurns.handle(authenticatedInbound.connectionId, {
3787
+ type: authenticatedInbound.kind,
3788
+ sessionId: authenticatedInbound.sessionId,
3789
+ requestId: authenticatedInbound.requestId,
3790
+ updateId: authenticatedInbound.updateId,
3791
+ messageId: authenticatedInbound.messageId,
3792
+ threadId: authenticatedInbound.threadId,
3793
+ ...(authenticatedInbound.kind === "ephemeral_turn"
3794
+ ? { question: authenticatedInbound.text }
3795
+ : { reason: authenticatedInbound.reason }),
3796
+ });
3797
+ return;
3798
+ }
3233
3799
 
3234
3800
  if (inbound.kind === "user_message") {
3235
3801
  // Inject as a user turn (steers/continues the agent; the resulting
@@ -3277,6 +3843,12 @@ export function createNotificationsExtension(
3277
3843
  update.verbosity = inbound.verbosity;
3278
3844
  }
3279
3845
  if (typeof inbound.redact === "boolean") {
3846
+ // Redact turning ON: terminalize any already-visible in-flight tool
3847
+ // bubbles (while redact is still off so the helper emits) so they never
3848
+ // strand permanently in "started"; subsequent detail is then suppressed.
3849
+ if (inbound.redact && !runtime.redact) {
3850
+ terminalizeInFlightTools(runtime, runtime.id, "unknown");
3851
+ }
3280
3852
  runtime.redact = inbound.redact;
3281
3853
  update.redact = inbound.redact;
3282
3854
  }
@@ -3358,6 +3930,11 @@ export function createNotificationsExtension(
3358
3930
  };
3359
3931
  host.emitEvent({ kind: identityHeader.type, payload: identityHeader });
3360
3932
  const endpoint = await server.start();
3933
+ ephemeralTurns.configureAuthority({
3934
+ sessionId: id,
3935
+ endpointDigest: endpointAuthorityDigest(endpoint.url, token),
3936
+ eventGeneration: host.generation,
3937
+ });
3361
3938
  throwIfLifecycleStopped();
3362
3939
  if (runtimes.get(id) !== runtime) {
3363
3940
  finishStartup({ status: "failed" });
@@ -3401,30 +3978,20 @@ export function createNotificationsExtension(
3401
3978
  },
3402
3979
  });
3403
3980
  throwIfLifecycleStopped();
3404
- runtime.brokerRegistrationActive = true;
3405
- const timer = setInterval(() => {
3406
- void index
3407
- .append({
3408
- type: "host_heartbeat",
3409
- sessionId: id,
3410
- locator,
3411
- endpointGeneration: host.generation,
3412
- pid: process.pid,
3413
- })
3414
- .catch(error => logger.warn(`sdk broker heartbeat failed: ${String(error)}`));
3415
- }, 5_000);
3416
- stopBrokerHeartbeat = () => clearInterval(timer);
3981
+ initializedRuntime.brokerRegistrationActive = true;
3982
+ // Host liveness is derived from alive(pid) when the index is read; heartbeats
3983
+ // are deliberately not appended to the durable session index.
3417
3984
  } catch (brokerError) {
3418
3985
  if (lifecycleRequired) throw brokerError;
3419
3986
  logger.warn(`sdk broker registration skipped: ${String(brokerError)}`);
3420
3987
  }
3421
3988
  }
3422
3989
 
3423
- runtime.stopBrokerHeartbeat = stopBrokerHeartbeat;
3424
- const startedRuntime = runtime;
3425
- runtime.enableNotifications = () => {
3990
+ const startedRuntime = initializedRuntime;
3991
+ initializedRuntime.enableNotifications = () => {
3426
3992
  const runtime = startedRuntime;
3427
3993
  if (runtime.notificationsActive) return;
3994
+ ephemeralTurns.enable();
3428
3995
  runtime.notificationsActive = true;
3429
3996
  runtime.disposeAnswerSource = registerInteractiveAnswerSource(
3430
3997
  runtime.id,
@@ -3435,35 +4002,42 @@ export function createNotificationsExtension(
3435
4002
  if (runtime.redact) return { ok: false, error: TELEGRAM_FILE_REDACTION_ERROR };
3436
4003
  try {
3437
4004
  const data = await fs.promises.readFile(file.path);
3438
- pushSessionFrame(runtime, {
3439
- type: "file_attachment",
3440
- sessionId: runtime.id,
3441
- name: path.basename(file.path),
3442
- data: data.toString("base64"),
3443
- caption: file.caption,
3444
- });
4005
+ pushFileAttachment(
4006
+ runtime,
4007
+ {
4008
+ type: "file_attachment",
4009
+ sessionId: runtime.id,
4010
+ name: path.basename(file.path),
4011
+ caption: file.caption,
4012
+ },
4013
+ data,
4014
+ );
3445
4015
  return { ok: true };
3446
4016
  } catch (e) {
3447
4017
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
3448
4018
  }
3449
4019
  });
3450
4020
  };
3451
- const activeRuntime = runtime;
4021
+ const activeRuntime = initializedRuntime;
3452
4022
  // A native terminal close (SIGHUP), SIGTERM, Ctrl+C exit, or fatal error
3453
4023
  // skips AgentSession.dispose(), so the `session_shutdown` extension event
3454
4024
  // never fires and the daemon-side topic would be orphaned. postmortem
3455
4025
  // awaits registered cleanups on those paths, so send the graceful
3456
4026
  // `session_closed` frame from there too. stopSession() cancels this
3457
4027
  // registration on every other teardown path, so it never double-fires.
3458
- runtime.cancelPostmortemCleanup = postmortem.register(`notifications-session-closed:${id}`, async () => {
3459
- await stopSession(runtime!.id);
3460
- });
4028
+ initializedRuntime.cancelPostmortemCleanup = postmortem.register(
4029
+ `notifications-session-closed:${id}`,
4030
+ async () => {
4031
+ await stopSession(initializedRuntime.id);
4032
+ },
4033
+ );
3461
4034
  logger.info(`notifications: serving session ${id} at ${endpoint.url}`);
3462
4035
  // A workflow-gate emitter can be installed after session startup.
3463
4036
  // Attach dynamically so the SDK bus presents every durable gate.
3464
4037
  const attachWorkflowGate = (gate: WorkflowGateEmitter | undefined): void => {
3465
4038
  if (activeRuntime.workflowGate === gate) return;
3466
4039
  activeRuntime.disposeGateListener();
4040
+ activeRuntime.workflowGate?.setRuntimeTurnProvider?.(null);
3467
4041
  activeRuntime.disposeAckRecoveryParticipant();
3468
4042
  gatePresentations.dispose();
3469
4043
  activeRuntime.disposeGateTerminalController();
@@ -3476,6 +4050,7 @@ export function createNotificationsExtension(
3476
4050
  return;
3477
4051
  }
3478
4052
  activeRuntime.workflowGate = gate;
4053
+ gate.setRuntimeTurnProvider?.(() => activeRuntime.activePromptCorrelation?.turnId);
3479
4054
  if (hasTerminalArbitrationCapability(gate)) {
3480
4055
  const controller: WorkflowGateTerminalController = {
3481
4056
  completeGateInteractions: gateId => gatePresentations.complete(gateId),
@@ -3529,18 +4104,30 @@ export function createNotificationsExtension(
3529
4104
  });
3530
4105
  activeRuntime.disposeAckRecoveryParticipant = () => gate.setAckRecoveryParticipant?.(null);
3531
4106
  }
3532
- void gate.recoverAcceptedGates?.();
4107
+ void (typeof gate.recoverAcceptedGates === "function"
4108
+ ? trackGateResolution(gate.recoverAcceptedGates()).catch(() => {})
4109
+ : Promise.resolve());
3533
4110
  };
3534
4111
  activeRuntime.disposeGateEmitterListener = registerWorkflowGateEmitterListener(id, attachWorkflowGate);
3535
4112
  if (ctx.workflowGate) attachWorkflowGate(ctx.workflowGate);
3536
- if (notificationsEnabledForSession) runtime.enableNotifications();
3537
- finishStartup({ status: "started", runtime });
3538
- return { status: "started", runtime };
4113
+ if (notificationsEnabledForSession) initializedRuntime.enableNotifications();
4114
+ finishStartup({ status: "started", runtime: initializedRuntime });
4115
+ return { status: "started", runtime: initializedRuntime };
3539
4116
  } catch (e) {
3540
4117
  logger.warn(`notifications: failed to start server: ${String(e)}`);
3541
4118
  const result = failLifecycleStartup("failed", e);
3542
4119
  finishStartup(result);
3543
- if (!(await stopSession(id, "session", runtime))) await cleanupAbandonedStartup();
4120
+ let stopped = false;
4121
+ try {
4122
+ stopped = await stopSession(id, "session", runtime);
4123
+ } catch (error) {
4124
+ // A secondary owner-release failure during abandoned-startup cleanup is
4125
+ // retained for an explicit later retry via cleanupRetries; log it rather
4126
+ // than letting it escape startSession and surface a red extension error
4127
+ // through session_start / session_switch / session_branch.
4128
+ logger.error(`notifications: SDK notification runtime cleanup failed: ${String(error)}`);
4129
+ }
4130
+ if (!stopped) await cleanupAbandonedStartup();
3544
4131
  return { ...result, runtime };
3545
4132
  }
3546
4133
  }
@@ -3665,13 +4252,27 @@ export function createNotificationsExtension(
3665
4252
  event: { previousSessionFile?: string },
3666
4253
  ctx: ExtensionContext,
3667
4254
  ): Promise<void> => {
4255
+ if (extensionShuttingDown) return;
3668
4256
  const newId = sessionId(ctx);
3669
4257
  const prevId = activeRuntimeId ?? sessionIdFromFile(event.previousSessionFile);
3670
4258
  if (prevId && prevId !== newId) {
3671
4259
  controller.rekeySession(prevId, newId);
3672
- await stopSession(prevId);
4260
+ try {
4261
+ await stopSession(prevId);
4262
+ } catch (error) {
4263
+ // A retained owner-release failure keeps the exact runtime in
4264
+ // cleanupRetries for an explicit later retry; log it rather than
4265
+ // surfacing a red extension error
4266
+ // while rotating session authority (/new, fork, resume, branch).
4267
+ logger.error(`notifications: SDK notification runtime cleanup failed: ${String(error)}`);
4268
+ }
3673
4269
  }
4270
+ if (extensionShuttingDown) return;
3674
4271
  await startSession(ctx);
4272
+ if (extensionShuttingDown) {
4273
+ await stopSession(newId);
4274
+ return;
4275
+ }
3675
4276
  await controller.reconcileCurrentSession(ctx);
3676
4277
  };
3677
4278
  api.on("session_switch", async (event, ctx) => {
@@ -3689,6 +4290,19 @@ export function createNotificationsExtension(
3689
4290
  await rotateSessionAuthority(event, ctx);
3690
4291
  });
3691
4292
 
4293
+ const terminalizeInFlightTools = (rt: SessionRuntime, id: string, phase: "cancelled" | "unknown"): void => {
4294
+ if (rt.notificationsActive && !rt.redact) {
4295
+ for (const [toolCallId, { toolName }] of rt.inFlightTools) {
4296
+ try {
4297
+ pushSessionFrame(rt, { type: "tool_activity", sessionId: id, toolCallId, toolName, phase });
4298
+ } catch (e) {
4299
+ logger.warn(`notifications: synthetic tool_activity failed: ${String(e)}`);
4300
+ }
4301
+ }
4302
+ }
4303
+ rt.inFlightTools.clear();
4304
+ };
4305
+
3692
4306
  // Drive the live typing indicator: mark busy when the agent loop starts so
3693
4307
  // the daemon shows "typing…" in the thread while the agent is thinking,
3694
4308
  // before any turn output exists. Cleared on `agent_end` below.
@@ -3717,7 +4331,9 @@ export function createNotificationsExtension(
3717
4331
  api.on("turn_start", (_event, ctx) => {
3718
4332
  const id = sessionId(ctx);
3719
4333
  const rt = runtimes.get(id);
3720
- if (!rt?.notificationsActive) return;
4334
+ if (!rt) return;
4335
+ rt.turnSeq = (rt.turnSeq ?? 0) + 1;
4336
+ if (!rt.notificationsActive) return;
3721
4337
  // A new turn is live: re-open the live-stream window (see turnClosed).
3722
4338
  rt.turnClosed = false;
3723
4339
  if (rt.pendingInbound.size === 0) return;
@@ -3735,7 +4351,7 @@ export function createNotificationsExtension(
3735
4351
  // per `turn_end`. turn_end fires once per turn iteration, so a single
3736
4352
  // user-visible idle previously produced many idle pings (the flood); agent_end
3737
4353
  // fires exactly once per settle, yielding exactly one idle notification.
3738
- api.on("agent_end", (_event, ctx) => {
4354
+ api.on("agent_end", (event, ctx) => {
3739
4355
  const id = sessionId(ctx);
3740
4356
  const rt = runtimes.get(id);
3741
4357
  if (!rt) return;
@@ -3748,13 +4364,17 @@ export function createNotificationsExtension(
3748
4364
  } else {
3749
4365
  rt.emitPromptLifecycle(undefined, { type: "agent_end", sessionId: id });
3750
4366
  }
4367
+ rt.activePromptCorrelation = undefined;
4368
+ terminalizeInFlightTools(rt, id, event.stopReason === "cancelled" ? "cancelled" : "unknown");
3751
4369
  try {
3752
4370
  pushSessionFrame(rt, { type: "activity", sessionId: id, state: "idle" });
3753
4371
  } catch (e) {
3754
4372
  logger.warn(`notifications: activity (idle) failed: ${String(e)}`);
3755
4373
  }
3756
4374
  if (!rt.notificationsActive) return;
3757
- void rt.workflowGate?.recoverAcceptedGates?.();
4375
+ void (typeof rt.workflowGate?.recoverAcceptedGates === "function"
4376
+ ? rt.trackGateResolution(rt.workflowGate.recoverAcceptedGates()).catch(() => {})
4377
+ : Promise.resolve());
3758
4378
  const seq = rt.idleSeq++;
3759
4379
  // Re-assert the identity header so the daemon renames the topic once the
3760
4380
  // session title has been auto-generated ("{repo}/{branch} - {title}"). The
@@ -3826,11 +4446,10 @@ export function createNotificationsExtension(
3826
4446
  rt.preAskFlushedText = text;
3827
4447
  // Decision A: a stream-enabled turn must finalize as an in-place edit of ONE
3828
4448
  // live message, never a fresh (rich-promotable) send. If live frames were
3829
- // async-queued and none landed before this flush, allocate the per-turn ref
3830
- // now so the finalized frame always carries a messageRef → the daemon keeps it
3831
- // editable (HTML edit) and never rich-promotes a streamed final.
3832
- if (finalAnswer && rt.stream && rt.liveRef === undefined) {
3833
- rt.turnSeq = (rt.turnSeq ?? 0) + 1;
4449
+ // async-queued and none landed before this flush, reuse the per-turn ref
4450
+ // assigned at turn_start so the finalized frame remains editable (HTML edit)
4451
+ // and never rich-promotes a streamed final.
4452
+ if (finalAnswer && rt.stream && rt.liveRef === undefined && rt.turnSeq !== undefined) {
3834
4453
  rt.liveRef = String(rt.turnSeq);
3835
4454
  }
3836
4455
  try {
@@ -3854,11 +4473,99 @@ export function createNotificationsExtension(
3854
4473
  // path and ordered before it — unlike message_update, which is queued async),
3855
4474
  // then flushed here before the ask tool's execute calls registerAsk.
3856
4475
  api.on("tool_execution_start", (event, ctx) => {
3857
- if (event.toolName !== "ask") return;
4476
+ if (event.toolName === "ask") {
4477
+ const id = sessionId(ctx);
4478
+ const rt = runtimes.get(id);
4479
+ if (!rt?.notificationsActive || rt.redact) return;
4480
+ flushTurnText(rt, id, rt.currentTurnText, false);
4481
+ }
3858
4482
  const id = sessionId(ctx);
3859
4483
  const rt = runtimes.get(id);
3860
4484
  if (!rt?.notificationsActive || rt.redact) return;
3861
- flushTurnText(rt, id, rt.currentTurnText, false);
4485
+ rt.inFlightTools.set(event.toolCallId, { toolName: event.toolName, args: event.args });
4486
+ try {
4487
+ pushSessionFrame(rt, {
4488
+ type: "tool_activity",
4489
+ sessionId: id,
4490
+ toolCallId: event.toolCallId,
4491
+ toolName: event.toolName,
4492
+ phase: "started",
4493
+ });
4494
+ } catch (e) {
4495
+ logger.warn(`notifications: tool_activity start failed: ${String(e)}`);
4496
+ }
4497
+ });
4498
+
4499
+ api.on("tool_execution_end", (event, ctx) => {
4500
+ const id = sessionId(ctx);
4501
+ const rt = runtimes.get(id);
4502
+ if (!rt) return;
4503
+ const inFlight = rt.inFlightTools.get(event.toolCallId);
4504
+ if (!rt.notificationsActive || rt.redact) {
4505
+ rt.inFlightTools.delete(event.toolCallId);
4506
+ return;
4507
+ }
4508
+ try {
4509
+ const frame: {
4510
+ type: "tool_activity";
4511
+ sessionId: string;
4512
+ toolCallId: string;
4513
+ toolName: string;
4514
+ phase: "completed" | "failed";
4515
+ isError: boolean;
4516
+ argsSummary?: string;
4517
+ resultSummary?: string;
4518
+ } = {
4519
+ type: "tool_activity",
4520
+ sessionId: id,
4521
+ toolCallId: event.toolCallId,
4522
+ toolName: event.toolName,
4523
+ phase: event.isError ? "failed" : "completed",
4524
+ isError: event.isError,
4525
+ };
4526
+ if (rt.verbosity === "verbose") {
4527
+ const tool = ctx.resolveTool(event.toolName);
4528
+ const argsSummary = projectToolSummary(tool, "args", inFlight?.args);
4529
+ const resultSummary = projectToolSummary(tool, "result", event.result);
4530
+ if (argsSummary !== undefined) frame.argsSummary = argsSummary;
4531
+ if (resultSummary !== undefined) frame.resultSummary = resultSummary;
4532
+ }
4533
+ pushSessionFrame(rt, frame);
4534
+ } catch (e) {
4535
+ logger.warn(`notifications: tool_activity end failed: ${String(e)}`);
4536
+ } finally {
4537
+ rt.inFlightTools.delete(event.toolCallId);
4538
+ }
4539
+ });
4540
+
4541
+ api.on("reasoning_summary_end", (event, ctx) => {
4542
+ const id = sessionId(ctx);
4543
+ const rt = runtimes.get(id);
4544
+ if (!rt?.notificationsActive || rt.redact || rt.verbosity !== "verbose") return;
4545
+ if (!event.message || typeof event.message !== "object" || !("content" in event.message)) return;
4546
+ const content = event.message.content;
4547
+ if (!Array.isArray(content)) return;
4548
+ const block = content[event.contentIndex];
4549
+ if (block?.type !== "thinking" || (block.provenance !== "summary" && block.provenance !== "mixed")) return;
4550
+ // CoT boundary: emit ONLY the canonical provider-marked summaryText. Never
4551
+ // fall back to the event payload, which could carry inconsistent/mutated text.
4552
+ const text = block.summaryText;
4553
+ if (typeof text !== "string" || text === "") return;
4554
+ try {
4555
+ pushSessionFrame(rt, {
4556
+ type: "reasoning_summary",
4557
+ sessionId: id,
4558
+ text,
4559
+ // Coalesce on the reasoning block's stable itemId carried on the event, NOT
4560
+ // the mutable rt.turnSeq: a streamed reasoning_summary_end is queued async and
4561
+ // turn_start for the next iteration advances turnSeq synchronously first, so
4562
+ // reading turnSeq here could bind turn N's summary to turn N+1. Absent an
4563
+ // itemId, omit turnRef (threaded-render sends a fresh non-editable message).
4564
+ ...((block as { itemId?: string }).itemId ? { turnRef: (block as { itemId?: string }).itemId } : {}),
4565
+ });
4566
+ } catch (e) {
4567
+ logger.warn(`notifications: reasoning_summary failed: ${String(e)}`);
4568
+ }
3862
4569
  });
3863
4570
 
3864
4571
  api.on("turn_end", (event, ctx) => {
@@ -3888,8 +4595,7 @@ export function createNotificationsExtension(
3888
4595
  const rt = runtimes.get(id);
3889
4596
  if (!rt?.notificationsActive || !rt.stream || rt.redact || rt.turnClosed) return;
3890
4597
  if ((event.message as { role?: unknown }).role !== "assistant") return;
3891
- if (rt.liveRef === undefined) {
3892
- rt.turnSeq = (rt.turnSeq ?? 0) + 1;
4598
+ if (rt.liveRef === undefined && rt.turnSeq !== undefined) {
3893
4599
  rt.liveRef = String(rt.turnSeq);
3894
4600
  }
3895
4601
  const now = Date.now();
@@ -3935,9 +4641,24 @@ export function createNotificationsExtension(
3935
4641
  });
3936
4642
 
3937
4643
  api.on("session_shutdown", async (_event, ctx) => {
4644
+ extensionShuttingDown = true;
4645
+ identityControlInFlight = false;
4646
+ deferredIdentityRotation = undefined;
4647
+ const id = sessionId(ctx);
4648
+ const rt = runtimes.get(id);
4649
+ if (rt) terminalizeInFlightTools(rt, id, "unknown");
3938
4650
  const controllerStop =
3939
4651
  typeof ctx.sessionManager.getCwd === "function" ? controller.stopCurrentSession(ctx) : Promise.resolve(false);
3940
4652
  void controllerStop.catch(error => logger.warn(`notifications: controller shutdown failed: ${String(error)}`));
3941
- await stopSession(sessionId(ctx));
4653
+ try {
4654
+ await stopSession(id);
4655
+ } catch (error) {
4656
+ // A retained owner-release failure keeps the exact runtime in
4657
+ // cleanupRetries for an explicit later retry; log it rather than
4658
+ // surfacing a red extension error at
4659
+ // shutdown. On terminal quit there is no later retry cycle, so log at
4660
+ // error severity (matching the postmortem cleanup precedent).
4661
+ logger.error(`notifications: SDK notification runtime cleanup failed: ${String(error)}`);
4662
+ }
3942
4663
  });
3943
4664
  }