@gajae-code/coding-agent 0.11.1 → 0.11.3

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 (413) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/types/cli/config-cli.d.ts +11 -1
  3. package/dist/types/cli/launch-disposition.d.ts +23 -0
  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 +141 -11
  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-runtime.d.ts +55 -33
  54. package/dist/types/gjc-runtime/workflow-cli-common.d.ts +8 -0
  55. package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -0
  56. package/dist/types/goals/continuation-timeout-guard.d.ts +22 -0
  57. package/dist/types/goals/runtime.d.ts +1 -0
  58. package/dist/types/goals/state.d.ts +8 -0
  59. package/dist/types/harness-control-plane/lifecycle-reconciliation.d.ts +17 -0
  60. package/dist/types/harness-control-plane/phase-rollup.d.ts +4 -7
  61. package/dist/types/harness-control-plane/receipt-ingest.d.ts +5 -0
  62. package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
  63. package/dist/types/hindsight/state.d.ts +11 -4
  64. package/dist/types/lsp/lspmux.d.ts +5 -3
  65. package/dist/types/lsp/path-trust.d.ts +1 -0
  66. package/dist/types/main.d.ts +4 -2
  67. package/dist/types/memories/storage.d.ts +1 -0
  68. package/dist/types/modes/action-registry.d.ts +29 -0
  69. package/dist/types/modes/components/chrome.d.ts +14 -0
  70. package/dist/types/modes/components/command-palette.d.ts +27 -0
  71. package/dist/types/modes/components/custom-provider-wizard.d.ts +1 -0
  72. package/dist/types/modes/components/index.d.ts +2 -1
  73. package/dist/types/modes/components/pet-selector.d.ts +1 -1
  74. package/dist/types/modes/components/plan-preview-overlay.d.ts +43 -0
  75. package/dist/types/modes/components/queue-mode-selector.d.ts +1 -1
  76. package/dist/types/modes/components/queue-pane.d.ts +12 -0
  77. package/dist/types/modes/components/read-tool-group.d.ts +6 -0
  78. package/dist/types/modes/components/session-observer-overlay.d.ts +3 -4
  79. package/dist/types/modes/components/sessions-dashboard.d.ts +32 -0
  80. package/dist/types/modes/components/settings-selector.d.ts +1 -1
  81. package/dist/types/modes/components/show-images-selector.d.ts +1 -1
  82. package/dist/types/modes/components/status-line/types.d.ts +1 -1
  83. package/dist/types/modes/components/status-line.d.ts +1 -94
  84. package/dist/types/modes/components/tasks-pane.d.ts +15 -0
  85. package/dist/types/modes/components/theme-selector.d.ts +1 -1
  86. package/dist/types/modes/components/tool-execution.d.ts +15 -0
  87. package/dist/types/modes/components/tool-status-header.d.ts +111 -0
  88. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +42 -0
  89. package/dist/types/modes/controllers/goal-mode-controller.d.ts +62 -0
  90. package/dist/types/modes/controllers/input-controller.d.ts +8 -1
  91. package/dist/types/modes/controllers/mode-gate.d.ts +8 -0
  92. package/dist/types/modes/controllers/plan-mode-controller.d.ts +65 -0
  93. package/dist/types/modes/controllers/selector-controller.d.ts +15 -3
  94. package/dist/types/modes/controllers/stt-controller.d.ts +26 -0
  95. package/dist/types/modes/interactive-mode.d.ts +19 -17
  96. package/dist/types/modes/jobs-observer.d.ts +2 -0
  97. package/dist/types/modes/prompt-action-autocomplete.d.ts +4 -0
  98. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +4 -11
  99. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +20 -2
  100. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +31 -0
  101. package/dist/types/modes/tasks-aggregator.d.ts +39 -0
  102. package/dist/types/modes/transcript-item-registry.d.ts +60 -0
  103. package/dist/types/modes/types.d.ts +20 -13
  104. package/dist/types/modes/utils/ui-helpers.d.ts +2 -2
  105. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  106. package/dist/types/sdk/broker/broker.d.ts +78 -12
  107. package/dist/types/sdk/broker/lifecycle-codec.d.ts +4 -0
  108. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +15 -1
  109. package/dist/types/sdk/broker/lifecycle.d.ts +6 -2
  110. package/dist/types/sdk/broker/process-incarnation.d.ts +2 -0
  111. package/dist/types/sdk/broker/session-index.d.ts +0 -1
  112. package/dist/types/sdk/broker/state-version.d.ts +2 -0
  113. package/dist/types/sdk/bus/chat-effect-journal.d.ts +1 -0
  114. package/dist/types/sdk/bus/config-commands.d.ts +1 -1
  115. package/dist/types/sdk/bus/config.d.ts +11 -0
  116. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  117. package/dist/types/sdk/bus/html-format.d.ts +3 -2
  118. package/dist/types/sdk/bus/index.d.ts +29 -0
  119. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  120. package/dist/types/sdk/bus/notification-service.d.ts +20 -0
  121. package/dist/types/sdk/bus/rate-limit-pool.d.ts +13 -2
  122. package/dist/types/sdk/bus/recent-activity.d.ts +21 -12
  123. package/dist/types/sdk/bus/rich-document.d.ts +7 -0
  124. package/dist/types/sdk/bus/rich-render.d.ts +18 -41
  125. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +9 -4
  126. package/dist/types/sdk/bus/telegram-daemon.d.ts +46 -3
  127. package/dist/types/sdk/bus/threaded-inbound.d.ts +1 -1
  128. package/dist/types/sdk/bus/threaded-render.d.ts +13 -0
  129. package/dist/types/sdk/bus/topic-registry.d.ts +24 -2
  130. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  131. package/dist/types/sdk/host/host.d.ts +2 -0
  132. package/dist/types/sdk/index.d.ts +1 -0
  133. package/dist/types/sdk/session-directory.d.ts +56 -0
  134. package/dist/types/sdk/session.d.ts +2 -0
  135. package/dist/types/session/agent-session.d.ts +82 -47
  136. package/dist/types/session/agent-storage.d.ts +7 -0
  137. package/dist/types/session/fallback-chain-controller.d.ts +10 -2
  138. package/dist/types/session/internal/managed-session-scope.d.ts +102 -0
  139. package/dist/types/session/internal/managed-session-storage.d.ts +38 -0
  140. package/dist/types/session/messages.d.ts +16 -0
  141. package/dist/types/session/session-manager-internal.d.ts +14 -0
  142. package/dist/types/session/session-manager.d.ts +82 -12
  143. package/dist/types/session/session-storage.d.ts +43 -3
  144. package/dist/types/session/volatile-context-pruning.d.ts +15 -0
  145. package/dist/types/skill-state/{deep-interview-mutation-guard.d.ts → workflow-mutation-guard.d.ts} +24 -8
  146. package/dist/types/skill-state/workflow-state-contract.d.ts +1 -3
  147. package/dist/types/slash-commands/builtin-registry.d.ts +4 -1
  148. package/dist/types/system-prompt.d.ts +2 -11
  149. package/dist/types/task/executor.d.ts +3 -1
  150. package/dist/types/thinking.d.ts +7 -0
  151. package/dist/types/tool-discovery/tool-index.d.ts +1 -50
  152. package/dist/types/tools/ask.d.ts +64 -2
  153. package/dist/types/tools/bash.d.ts +3 -0
  154. package/dist/types/tools/browser/dead-tab-recovery.d.ts +24 -0
  155. package/dist/types/tools/browser/tab-supervisor.d.ts +21 -0
  156. package/dist/types/tools/cron.d.ts +2 -0
  157. package/dist/types/tools/fetch.d.ts +1 -0
  158. package/dist/types/tools/index.d.ts +30 -15
  159. package/dist/types/tools/output-meta.d.ts +1 -0
  160. package/dist/types/tools/read.d.ts +5 -0
  161. package/dist/types/tools/search-tool-bm25.d.ts +3 -4
  162. package/dist/types/tools/subagent.d.ts +1 -0
  163. package/dist/types/utils/event-bus.d.ts +1 -1
  164. package/dist/types/utils/file-mentions.d.ts +3 -3
  165. package/dist/types/web/search/index.d.ts +3 -1
  166. package/package.json +21 -9
  167. package/scripts/build-sdk-package-smoke.ts +34 -1
  168. package/scripts/generate-hotkeys-docs.ts +74 -0
  169. package/scripts/generate-sdk-operation-inventory.ts +64 -20
  170. package/scripts/verify-gjc-sdk-canonicalization.ts +182 -2
  171. package/src/async/job-manager.ts +49 -6
  172. package/src/cli/config-cli.ts +75 -2
  173. package/src/cli/launch-disposition.ts +42 -0
  174. package/src/cli/notify-cli.ts +92 -26
  175. package/src/cli/session-picker.ts +20 -3
  176. package/src/cli/setup-cli.ts +1 -1
  177. package/src/cli.ts +1 -1
  178. package/src/commands/config.ts +1 -1
  179. package/src/commands/harness.ts +34 -106
  180. package/src/commands/launch.ts +6 -4
  181. package/src/commands/mcp-serve.ts +85 -8
  182. package/src/commands/mcp.ts +2 -2
  183. package/src/commands/notify.ts +8 -4
  184. package/src/commands/sdk.ts +61 -40
  185. package/src/commands/ultragoal.ts +4 -1
  186. package/src/config/atomic-yaml-patch.ts +80 -13
  187. package/src/config/file-lock.ts +105 -43
  188. package/src/config/keybindings.ts +64 -20
  189. package/src/config/model-auth.ts +9 -0
  190. package/src/config/model-bindings-applier.ts +139 -0
  191. package/src/config/model-discovery-manager.ts +224 -0
  192. package/src/config/model-equivalence.ts +66 -0
  193. package/src/config/model-registry.ts +169 -302
  194. package/src/config/model-resolver.ts +361 -369
  195. package/src/config/models-config-schema.ts +5 -4
  196. package/src/config/settings-schema.ts +214 -12
  197. package/src/config/settings.ts +458 -482
  198. package/src/coordinator-mcp/policy.ts +76 -11
  199. package/src/coordinator-mcp/question-gate-codec.ts +274 -0
  200. package/src/coordinator-mcp/question-state.ts +778 -0
  201. package/src/coordinator-mcp/server.ts +1702 -238
  202. package/src/debug/index.ts +1 -1
  203. package/src/defaults/gjc/skills/deep-interview/SKILL.md +13 -1
  204. package/src/defaults/gjc/skills/team/SKILL.md +16 -0
  205. package/src/discovery/helpers.ts +1 -50
  206. package/src/edit/index.ts +64 -0
  207. package/src/eval/py/env.ts +26 -0
  208. package/src/eval/py/kernel.ts +5 -3
  209. package/src/extensibility/extensions/loader.ts +9 -1
  210. package/src/extensibility/extensions/runner.ts +12 -3
  211. package/src/extensibility/extensions/types.ts +39 -1
  212. package/src/extensibility/gjc-plugins/installer.ts +16 -16
  213. package/src/extensibility/hooks/runner.ts +2 -2
  214. package/src/extensibility/runtime-skill-discovery.ts +33 -13
  215. package/src/extensibility/shared-events.ts +2 -0
  216. package/src/gjc-runtime/deep-interview-recorder.ts +63 -3
  217. package/src/gjc-runtime/deep-interview-runtime.ts +76 -27
  218. package/src/gjc-runtime/deep-interview-state.ts +317 -5
  219. package/src/gjc-runtime/goal-mode-request.ts +55 -8
  220. package/src/gjc-runtime/launch-tmux.ts +2 -5
  221. package/src/gjc-runtime/launch-worktree.ts +5 -5
  222. package/src/gjc-runtime/linux-proc.ts +71 -0
  223. package/src/gjc-runtime/ralplan-runtime.ts +74 -90
  224. package/src/gjc-runtime/session-resolution.ts +18 -3
  225. package/src/gjc-runtime/session-state-sidecar.ts +6 -107
  226. package/src/gjc-runtime/state-migrations.ts +1 -1
  227. package/src/gjc-runtime/state-runtime.ts +327 -249
  228. package/src/gjc-runtime/state-writer.ts +36 -40
  229. package/src/gjc-runtime/team-launch.ts +319 -0
  230. package/src/gjc-runtime/team-notify.ts +326 -0
  231. package/src/gjc-runtime/team-runtime.ts +1099 -1482
  232. package/src/gjc-runtime/team-store.ts +828 -0
  233. package/src/gjc-runtime/team-workers.ts +962 -0
  234. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +2 -11
  235. package/src/gjc-runtime/tmux-owner-isolation.ts +14 -9
  236. package/src/gjc-runtime/tmux-sessions.ts +4 -21
  237. package/src/gjc-runtime/ultragoal-change-set.ts +172 -0
  238. package/src/gjc-runtime/ultragoal-evidence.ts +874 -0
  239. package/src/gjc-runtime/ultragoal-guard.ts +29 -5
  240. package/src/gjc-runtime/ultragoal-pipeline.ts +306 -0
  241. package/src/gjc-runtime/ultragoal-runtime.ts +84 -1311
  242. package/src/gjc-runtime/workflow-cli-common.ts +30 -0
  243. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  244. package/src/gjc-runtime/workflow-manifest.ts +18 -0
  245. package/src/goals/continuation-timeout-guard.ts +79 -0
  246. package/src/goals/runtime.ts +7 -5
  247. package/src/goals/state.ts +18 -0
  248. package/src/harness-control-plane/lifecycle-reconciliation.ts +64 -0
  249. package/src/harness-control-plane/phase-rollup.ts +4 -7
  250. package/src/harness-control-plane/receipt-ingest.ts +5 -0
  251. package/src/harness-control-plane/receipt-spool.ts +51 -7
  252. package/src/harness-control-plane/session-lease.ts +12 -130
  253. package/src/hindsight/backend.ts +26 -27
  254. package/src/hindsight/state.ts +101 -32
  255. package/src/hooks/skill-state.ts +17 -11
  256. package/src/internal-urls/docs-index.generated.ts +21 -18
  257. package/src/lsp/client.ts +1 -1
  258. package/src/lsp/config.ts +145 -26
  259. package/src/lsp/index.ts +1 -1
  260. package/src/lsp/lspmux.ts +39 -10
  261. package/src/lsp/path-trust.ts +81 -0
  262. package/src/main.ts +127 -30
  263. package/src/memories/index.ts +1 -0
  264. package/src/memories/storage.ts +6 -2
  265. package/src/modes/action-registry.ts +165 -0
  266. package/src/modes/components/chrome.ts +40 -0
  267. package/src/modes/components/command-palette.ts +168 -0
  268. package/src/modes/components/custom-editor.ts +1 -1
  269. package/src/modes/components/custom-provider-wizard.ts +51 -14
  270. package/src/modes/components/index.ts +2 -1
  271. package/src/modes/components/pet-selector.ts +11 -17
  272. package/src/modes/components/plan-preview-overlay.ts +295 -0
  273. package/src/modes/components/queue-mode-selector.ts +10 -27
  274. package/src/modes/components/queue-pane.ts +79 -0
  275. package/src/modes/components/read-tool-group.ts +13 -0
  276. package/src/modes/components/session-observer-overlay.ts +207 -772
  277. package/src/modes/components/sessions-dashboard.ts +181 -0
  278. package/src/modes/components/settings-selector.ts +1 -1
  279. package/src/modes/components/show-images-selector.ts +10 -24
  280. package/src/modes/components/status-line/types.ts +1 -1
  281. package/src/modes/components/status-line.ts +1 -969
  282. package/src/modes/components/tasks-pane.ts +73 -0
  283. package/src/modes/components/theme-selector.ts +11 -31
  284. package/src/modes/components/tool-execution.ts +22 -0
  285. package/src/modes/components/tool-status-header.ts +1056 -0
  286. package/src/modes/components/transcript-viewer-overlay.ts +384 -0
  287. package/src/modes/controllers/btw-controller.ts +1 -0
  288. package/src/modes/controllers/event-controller.ts +3 -2
  289. package/src/modes/controllers/extension-ui-controller.ts +24 -1
  290. package/src/modes/controllers/goal-mode-controller.ts +474 -0
  291. package/src/modes/controllers/input-controller.ts +764 -229
  292. package/src/modes/controllers/mode-gate.ts +24 -0
  293. package/src/modes/controllers/plan-mode-controller.ts +517 -0
  294. package/src/modes/controllers/selector-controller.ts +225 -10
  295. package/src/modes/controllers/stt-controller.ts +125 -0
  296. package/src/modes/interactive-mode.ts +289 -1150
  297. package/src/modes/jobs-observer.ts +3 -0
  298. package/src/modes/prompt-action-autocomplete.ts +7 -0
  299. package/src/modes/runtime-init.ts +8 -0
  300. package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -106
  301. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +71 -16
  302. package/src/modes/shared/agent-wire/workflow-gate-types.ts +177 -0
  303. package/src/modes/tasks-aggregator.ts +199 -0
  304. package/src/modes/transcript-item-registry.ts +229 -0
  305. package/src/modes/types.ts +45 -18
  306. package/src/modes/utils/ui-helpers.ts +17 -9
  307. package/src/prompts/agent-fragments/ralplan-persistence.md +6 -0
  308. package/src/prompts/agent-fragments/restricted-bash.md +1 -0
  309. package/src/prompts/agents/architect.md +2 -8
  310. package/src/prompts/agents/critic.md +2 -8
  311. package/src/prompts/agents/executor.md +3 -0
  312. package/src/prompts/agents/planner.md +4 -12
  313. package/src/prompts/system/custom-system-prompt.md +1 -23
  314. package/src/prompts/system/plan-mode-approved.md +8 -0
  315. package/src/prompts/system/system-prompt.md +17 -96
  316. package/src/prompts/tools/bash.md +4 -4
  317. package/src/prompts/tools/browser.md +15 -52
  318. package/src/prompts/tools/find.md +2 -2
  319. package/src/prompts/tools/hashline.md +13 -55
  320. package/src/prompts/tools/read.md +3 -6
  321. package/src/prompts/tools/search-tool-bm25.md +0 -2
  322. package/src/prompts/tools/search.md +2 -4
  323. package/src/prompts/tools/skill-discovery.md +1 -2
  324. package/src/prompts/tools/task.md +1 -1
  325. package/src/runtime-mcp/manager.ts +1 -1
  326. package/src/sdk/broker/broker.ts +206 -78
  327. package/src/sdk/broker/discovery.ts +1 -1
  328. package/src/sdk/broker/ensure.ts +3 -1
  329. package/src/sdk/broker/lifecycle-codec.ts +11 -0
  330. package/src/sdk/broker/lifecycle-ledger.ts +371 -74
  331. package/src/sdk/broker/lifecycle.ts +1399 -142
  332. package/src/sdk/broker/process-incarnation.ts +5 -13
  333. package/src/sdk/broker/session-index.ts +145 -56
  334. package/src/sdk/broker/state-version.ts +21 -0
  335. package/src/sdk/broker/transport.ts +1 -1
  336. package/src/sdk/bus/chat-daemon-cli.ts +2 -0
  337. package/src/sdk/bus/chat-daemon-control.ts +8 -28
  338. package/src/sdk/bus/chat-effect-journal.ts +10 -2
  339. package/src/sdk/bus/config-commands.ts +5 -4
  340. package/src/sdk/bus/config.ts +98 -0
  341. package/src/sdk/bus/conversation-store.ts +5 -29
  342. package/src/sdk/bus/daemon-paths.ts +2 -0
  343. package/src/sdk/bus/html-format.ts +20 -3
  344. package/src/sdk/bus/index.ts +782 -94
  345. package/src/sdk/bus/lifecycle-control-runtime.ts +91 -33
  346. package/src/sdk/bus/natives-augment.d.ts +26 -1
  347. package/src/sdk/bus/notification-service.ts +168 -20
  348. package/src/sdk/bus/rate-limit-pool.ts +58 -14
  349. package/src/sdk/bus/recent-activity.ts +188 -74
  350. package/src/sdk/bus/rich-document.ts +258 -0
  351. package/src/sdk/bus/rich-render.ts +62 -70
  352. package/src/sdk/bus/telegram-daemon-cli.ts +15 -59
  353. package/src/sdk/bus/telegram-daemon-contract.ts +9 -4
  354. package/src/sdk/bus/telegram-daemon.ts +1389 -237
  355. package/src/sdk/bus/threaded-inbound.ts +8 -3
  356. package/src/sdk/bus/threaded-render.ts +57 -1
  357. package/src/sdk/bus/topic-registry.ts +120 -13
  358. package/src/sdk/host/control/dispatch.ts +14 -2
  359. package/src/sdk/host/control/operations.ts +6 -1
  360. package/src/sdk/host/host.ts +44 -2
  361. package/src/sdk/host/query/handlers.ts +20 -22
  362. package/src/sdk/host/query/revision-store.ts +80 -57
  363. package/src/sdk/index.ts +1 -0
  364. package/src/sdk/protocol/operation-inventory.generated.json +943 -1907
  365. package/src/sdk/session-directory.ts +141 -0
  366. package/src/sdk/session.ts +462 -480
  367. package/src/session/agent-session.ts +1700 -583
  368. package/src/session/agent-storage.ts +18 -0
  369. package/src/session/cache-economics.ts +25 -8
  370. package/src/session/fallback-chain-controller.ts +53 -3
  371. package/src/session/internal/managed-session-scope.ts +2226 -0
  372. package/src/session/internal/managed-session-storage.ts +453 -0
  373. package/src/session/messages.ts +91 -2
  374. package/src/session/session-manager-internal.ts +33 -0
  375. package/src/session/session-manager.ts +1112 -333
  376. package/src/session/session-storage.ts +444 -39
  377. package/src/session/volatile-context-pruning.ts +78 -0
  378. package/src/setup/credential-auto-import.ts +2 -1
  379. package/src/setup/hermes/templates/operator-instructions.v1.md +1 -1
  380. package/src/setup/provider-onboarding.ts +36 -61
  381. package/src/setup/provider-presets.json +5 -0
  382. package/src/skill-state/active-state.ts +3 -13
  383. package/src/skill-state/{deep-interview-mutation-guard.ts → workflow-mutation-guard.ts} +47 -29
  384. package/src/skill-state/workflow-state-contract.ts +6 -41
  385. package/src/slash-commands/builtin-registry.ts +46 -11
  386. package/src/system-prompt.ts +37 -44
  387. package/src/task/agents.ts +19 -1
  388. package/src/task/executor.ts +90 -6
  389. package/src/task/index.ts +36 -21
  390. package/src/thinking.ts +21 -6
  391. package/src/tool-discovery/tool-index.ts +2 -134
  392. package/src/tools/ask.ts +157 -21
  393. package/src/tools/ast-edit.ts +1 -1
  394. package/src/tools/bash.ts +38 -0
  395. package/src/tools/browser/dead-tab-recovery.ts +78 -0
  396. package/src/tools/browser/tab-supervisor.ts +282 -36
  397. package/src/tools/computer.ts +10 -3
  398. package/src/tools/cron.ts +30 -16
  399. package/src/tools/eval.ts +1 -2
  400. package/src/tools/fetch.ts +11 -4
  401. package/src/tools/index.ts +76 -24
  402. package/src/tools/output-meta.ts +19 -7
  403. package/src/tools/read.ts +219 -64
  404. package/src/tools/resource-gc.ts +2 -2
  405. package/src/tools/search-tool-bm25.ts +11 -74
  406. package/src/tools/subagent.ts +43 -2
  407. package/src/utils/event-bus.ts +9 -4
  408. package/src/utils/file-mentions.ts +4 -4
  409. package/src/web/search/index.ts +3 -3
  410. package/dist/types/discovery/plugin-dir-roots.d.ts +0 -15
  411. package/dist/types/runtime-mcp/discoverable-tool-metadata.d.ts +0 -7
  412. package/src/discovery/plugin-dir-roots.ts +0 -28
  413. 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,7 +873,10 @@ 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;
@@ -918,10 +926,52 @@ interface SessionRuntime {
918
926
  /** True between turn_end and the next turn_start: drops late async message_update
919
927
  * frames so a stale live edit can never be emitted after the finalized turn. */
920
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 }>;
921
931
  /** Cancels the postmortem cleanup that emits `session_closed` on process teardown. */
922
932
  cancelPostmortemCleanup: () => void;
923
- /** Stops optional broker presence heartbeats. */
924
- 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);
925
975
  }
926
976
 
927
977
  /** Request-local requester authority for stable ControlSurface dispatches. */
@@ -938,9 +988,28 @@ function pushSessionFrame(
938
988
  frame: { type: string; [key: string]: unknown },
939
989
  ): void {
940
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
+ }
941
1001
  runtime.server.pushFrame(JSON.stringify(frame));
942
1002
  }
943
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
+
944
1013
  /** Agent lifecycle is SDK session truth, independent of optional chat delivery. */
945
1014
  function emitAgentLifecycle(
946
1015
  runtime: Pick<SessionRuntime, "server" | "host">,
@@ -981,6 +1050,7 @@ const defaultConfig: NotificationConfig = {
981
1050
  rich: { enabled: true },
982
1051
  richDraft: { enabled: false },
983
1052
  topics: {},
1053
+ btw: { enabled: true },
984
1054
  };
985
1055
 
986
1056
  export function notificationsEnabled(): boolean {
@@ -1628,11 +1698,18 @@ function hasTerminalArbitrationCapability(
1628
1698
  Required<
1629
1699
  Pick<
1630
1700
  WorkflowGateEmitter,
1631
- "resolveGate" | "prepareTerminalization" | "clearPreparedTerminalization" | "registerGateTerminalController"
1701
+ | "resolveGate"
1702
+ | "recoverAcceptedGates"
1703
+ | "lookupCompletedResolution"
1704
+ | "prepareTerminalization"
1705
+ | "clearPreparedTerminalization"
1706
+ | "registerGateTerminalController"
1632
1707
  >
1633
1708
  > {
1634
1709
  return (
1635
1710
  typeof workflowGate?.resolveGate === "function" &&
1711
+ typeof workflowGate.recoverAcceptedGates === "function" &&
1712
+ typeof workflowGate.lookupCompletedResolution === "function" &&
1636
1713
  typeof workflowGate.prepareTerminalization === "function" &&
1637
1714
  typeof workflowGate.clearPreparedTerminalization === "function" &&
1638
1715
  typeof workflowGate.registerGateTerminalController === "function"
@@ -1986,7 +2063,7 @@ function sdkControlSurface(
1986
2063
  pending.completeDirect();
1987
2064
  return { resolved: true };
1988
2065
  },
1989
- answerGate: async (id, response, expectedSessionId) => {
2066
+ answerGate: async (id, response, expectedSessionId, idempotencyKey) => {
1990
2067
  if (!acceptGateResolution())
1991
2068
  throw Object.assign(new Error("Workflow gate is no longer answerable."), { code: "resource_gone" });
1992
2069
  if (expectedSessionId === undefined) auditMissingExpectedSessionId("workflow.gate_answer");
@@ -1999,6 +2076,26 @@ function sdkControlSurface(
1999
2076
  throw Object.assign(new Error("Workflow gates are unavailable for this session."), {
2000
2077
  code: "resource_gone",
2001
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
+ }
2002
2099
  const prepared = presentations.prepareDirectControl(id);
2003
2100
  if (!prepared || prepared.status === "stale")
2004
2101
  throw Object.assign(new Error("Workflow gate is no longer answerable."), { code: "resource_gone" });
@@ -2007,31 +2104,22 @@ function sdkControlSurface(
2007
2104
  if (prepared.status !== "queued" && prepared.status !== "retired")
2008
2105
  throw new Error(`Unexpected direct control preparation: ${prepared.status}`);
2009
2106
  if (
2010
- ctx.workflowGate?.prepareTerminalization?.(
2011
- id,
2012
- prepared.status === "queued" ? "not_published" : "retired",
2013
- ) !== true
2107
+ workflowGate.prepareTerminalization(id, prepared.status === "queued" ? "not_published" : "retired") !== true
2014
2108
  ) {
2015
2109
  presentations.finishDirectControl(id, prepared, "rejected");
2016
2110
  throw Object.assign(new Error("Workflow gate lacks a terminalization proof."), { code: "resource_gone" });
2017
2111
  }
2018
2112
  try {
2019
- const resolution = await trackGateResolution(
2020
- ctx.workflowGate?.resolveGate?.({
2021
- gate_id: id,
2022
- answer: response,
2023
- idempotency_key: id,
2024
- }) ?? unavailable("workflow.gate_answer", "workflow gates are unavailable for this session")(),
2025
- );
2113
+ const resolution = await trackGateResolution(workflowGate.resolveGate(gateResponse));
2026
2114
  const status = (resolution as { status?: unknown }).status;
2027
2115
  if (status === "accepted" || status === "rejected") {
2028
- if (status === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
2116
+ if (status === "rejected") workflowGate.clearPreparedTerminalization(id);
2029
2117
  presentations.finishDirectControl(id, prepared, status);
2030
2118
  return resolution;
2031
2119
  }
2032
2120
  } catch (error) {
2033
2121
  const outcome = reconcileDirectControlFailure(id);
2034
- if (outcome === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
2122
+ if (outcome === "rejected") workflowGate.clearPreparedTerminalization(id);
2035
2123
  presentations.finishDirectControl(id, prepared, outcome);
2036
2124
  if (outcome === "unknown")
2037
2125
  throw Object.assign(new Error("Workflow gate resolution outcome is uncertain."), {
@@ -2040,7 +2128,7 @@ function sdkControlSurface(
2040
2128
  throw error;
2041
2129
  }
2042
2130
  const outcome = reconcileDirectControlFailure(id);
2043
- if (outcome === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
2131
+ if (outcome === "rejected") workflowGate.clearPreparedTerminalization(id);
2044
2132
  presentations.finishDirectControl(id, prepared, outcome);
2045
2133
  logger.warn("workflow_gate_direct_control_uncertain_outcome", {
2046
2134
  operation: "workflow.gate_answer",
@@ -2064,6 +2152,22 @@ function sdkControlSurface(
2064
2152
  throw Object.assign(new Error("Workflow gates are unavailable for this session."), {
2065
2153
  code: "resource_gone",
2066
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
+ }
2067
2171
  const prepared = presentations.prepareDirectControl(id);
2068
2172
  if (!prepared || prepared.status === "stale")
2069
2173
  throw Object.assign(new Error("Workflow plan is no longer answerable."), { code: "resource_gone" });
@@ -2072,31 +2176,22 @@ function sdkControlSurface(
2072
2176
  if (prepared.status !== "queued" && prepared.status !== "retired")
2073
2177
  throw new Error(`Unexpected direct control preparation: ${prepared.status}`);
2074
2178
  if (
2075
- ctx.workflowGate?.prepareTerminalization?.(
2076
- id,
2077
- prepared.status === "queued" ? "not_published" : "retired",
2078
- ) !== true
2179
+ workflowGate.prepareTerminalization(id, prepared.status === "queued" ? "not_published" : "retired") !== true
2079
2180
  ) {
2080
2181
  presentations.finishDirectControl(id, prepared, "rejected");
2081
2182
  throw Object.assign(new Error("Workflow plan lacks a terminalization proof."), { code: "resource_gone" });
2082
2183
  }
2083
2184
  try {
2084
- const resolution = await trackGateResolution(
2085
- ctx.workflowGate?.resolveGate?.({
2086
- gate_id: id,
2087
- answer: choice,
2088
- idempotency_key: id,
2089
- }) ?? unavailable("workflow.plan_approve", "workflow gates are unavailable for this session")(),
2090
- );
2185
+ const resolution = await trackGateResolution(workflowGate.resolveGate(gateResponse));
2091
2186
  const status = (resolution as { status?: unknown }).status;
2092
2187
  if (status === "accepted" || status === "rejected") {
2093
- if (status === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
2188
+ if (status === "rejected") workflowGate.clearPreparedTerminalization(id);
2094
2189
  presentations.finishDirectControl(id, prepared, status);
2095
2190
  return resolution;
2096
2191
  }
2097
2192
  } catch (error) {
2098
2193
  const outcome = reconcileDirectControlFailure(id);
2099
- if (outcome === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
2194
+ if (outcome === "rejected") workflowGate.clearPreparedTerminalization(id);
2100
2195
  presentations.finishDirectControl(id, prepared, outcome);
2101
2196
  if (outcome === "unknown")
2102
2197
  throw Object.assign(new Error("Workflow plan resolution outcome is uncertain."), {
@@ -2105,7 +2200,7 @@ function sdkControlSurface(
2105
2200
  throw error;
2106
2201
  }
2107
2202
  const outcome = reconcileDirectControlFailure(id);
2108
- if (outcome === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
2203
+ if (outcome === "rejected") workflowGate.clearPreparedTerminalization(id);
2109
2204
  presentations.finishDirectControl(id, prepared, outcome);
2110
2205
  logger.warn("workflow_gate_direct_control_uncertain_outcome", {
2111
2206
  operation: "workflow.plan_approve",
@@ -2124,14 +2219,14 @@ function sdkControlSurface(
2124
2219
  throw Object.assign(new Error("skill.invoke args must be a string."), { code: "invalid_input" });
2125
2220
  return ctx.invokeSkill(name, args);
2126
2221
  },
2127
- setPlanMode: on => {
2222
+ setPlanMode: async on => {
2128
2223
  if (!bindings.has("setPlanMode") || !ctx.setPlanMode)
2129
2224
  return unavailable("mode.plan.set", "no plan-mode seam is installed")();
2130
2225
 
2131
2226
  if (typeof on !== "boolean")
2132
2227
  throw Object.assign(new Error("mode.plan.set requires a boolean on value."), { code: "invalid_input" });
2133
2228
 
2134
- return { state: ctx.setPlanMode(on) };
2229
+ return { state: await ctx.setPlanMode(on) };
2135
2230
  },
2136
2231
  operateGoal: (op, objective) => {
2137
2232
  if (!bindings.has("operateGoal") || !ctx.operateGoal)
@@ -2252,6 +2347,367 @@ function sdkControlSurface(
2252
2347
  return surface;
2253
2348
  }
2254
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
+ }
2255
2711
  /** Parse only v3 frames carried through the existing control-command seam. */
2256
2712
  function sdkInboundFrame(commandJson: string | undefined): Record<string, unknown> | undefined {
2257
2713
  if (!commandJson) return undefined;
@@ -2285,8 +2741,11 @@ export function createNotificationsExtension(
2285
2741
  /** Suppress auto-delivery for a GJC-spawned child under `sessionScope=primary`. */
2286
2742
  spawnedByGjc?: boolean;
2287
2743
  controller?: NotificationSessionController;
2744
+ /** Whether this host mode can own the root SDK endpoint. Default: true. */
2745
+ sdkHostModeSupported?: boolean;
2288
2746
 
2289
2747
  onSdkRequest?: (kind: "control" | "query", connectionId: string, frame: Record<string, unknown>) => void;
2748
+ runEphemeralTurn?: (promptText: string, signal: AbortSignal) => Promise<{ replyText: string }>;
2290
2749
  } = {},
2291
2750
  ): void {
2292
2751
  const lifecycleStartupCapability = lifecycleStartupCapabilityForApi(api);
@@ -2305,6 +2764,7 @@ export function createNotificationsExtension(
2305
2764
  let activeRuntimeId: string | undefined;
2306
2765
  let identityControlInFlight = false;
2307
2766
  let deferredIdentityRotation: { event: { previousSessionFile?: string }; ctx: ExtensionContext } | undefined;
2767
+ let extensionShuttingDown = false;
2308
2768
 
2309
2769
  async function ensureTelegramOwner(
2310
2770
  settings: Settings,
@@ -2352,7 +2812,10 @@ export function createNotificationsExtension(
2352
2812
  const activeRuntime = runtimes.get(id);
2353
2813
  const requestedRuntime = retryRuntime ?? activeRuntime;
2354
2814
  if (expectedRuntime && requestedRuntime !== expectedRuntime) return false;
2355
- if (reason === "session" && requestedRuntime) requestedRuntime.stopping = true;
2815
+ if (reason === "session" && requestedRuntime) {
2816
+ requestedRuntime.stopping = true;
2817
+ requestedRuntime.abortEphemeralTurns();
2818
+ }
2356
2819
  if (reason === "session" && requestedRuntime) {
2357
2820
  // Fence the exact runtime before awaiting its startup promise: a late start
2358
2821
  // must observe removal and clean itself up rather than becoming reachable.
@@ -2365,7 +2828,12 @@ export function createNotificationsExtension(
2365
2828
  .catch(() => {})
2366
2829
  .then(() => {
2367
2830
  if (runtimes.get(id) === requestedRuntime || cleanupRetries.get(id) === requestedRuntime)
2368
- void stopSession(id, reason, requestedRuntime);
2831
+ void stopSession(id, reason, requestedRuntime).catch(error =>
2832
+ // A retained owner-release failure keeps the exact runtime in
2833
+ // cleanupRetries for a later retry; log it rather than letting a
2834
+ // fire-and-forget rejection become a fatal unhandled rejection.
2835
+ logger.error(`notifications: SDK notification runtime cleanup failed: ${String(error)}`),
2836
+ );
2369
2837
  });
2370
2838
  const rt = requestedRuntime;
2371
2839
 
@@ -2377,6 +2845,7 @@ export function createNotificationsExtension(
2377
2845
  }
2378
2846
  if (reason === "notifications" && rt.host.started) {
2379
2847
  rt.notificationsActive = false;
2848
+ rt.disableEphemeralTurns();
2380
2849
  try {
2381
2850
  rt.disposeAnswerSource();
2382
2851
  } catch {}
@@ -2395,9 +2864,6 @@ export function createNotificationsExtension(
2395
2864
  try {
2396
2865
  rt.cancelPostmortemCleanup();
2397
2866
  } catch {}
2398
- try {
2399
- rt.stopBrokerHeartbeat();
2400
- } catch {}
2401
2867
  try {
2402
2868
  rt.disposeAnswerSource();
2403
2869
  } catch {}
@@ -2407,6 +2873,9 @@ export function createNotificationsExtension(
2407
2873
  try {
2408
2874
  rt.disposeGateListener();
2409
2875
  } catch {}
2876
+ try {
2877
+ rt.workflowGate?.setRuntimeTurnProvider?.(null);
2878
+ } catch {}
2410
2879
  await rt.waitForGateResolutionQuiescence();
2411
2880
  try {
2412
2881
  rt.disposeAckRecoveryParticipant();
@@ -2489,7 +2958,8 @@ export function createNotificationsExtension(
2489
2958
  const lifecycleRequestId = safeLifecycleRequestId(process.env.GJC_LIFECYCLE_REQUEST_ID);
2490
2959
  const { settings, cfg, settingsAvailable } = resolveSettings(options.settings);
2491
2960
  const notificationsEnabledForSession = controller.query(ctx).effectiveEnabled;
2492
- const sdkEnabledForSession = shouldHostSdk(settings, isNotificationEligibleContext(ctx));
2961
+ const sdkEnabledForSession =
2962
+ (options.sdkHostModeSupported ?? true) && shouldHostSdk(settings, isNotificationEligibleContext(ctx));
2493
2963
  const lifecycleRequired = lifecycleStartupCapability !== undefined;
2494
2964
  const failLifecycleStartup = (
2495
2965
  reason: "disabled" | "ineligible" | "failed",
@@ -2559,7 +3029,6 @@ export function createNotificationsExtension(
2559
3029
  }
2560
3030
  const gatePresentations = new PresentationArbiter(server, () => runtime?.redact ?? redact, tag);
2561
3031
  let inboundSdkFrame: ((connectionId: string, frame: Record<string, unknown>) => void) | undefined;
2562
- let stopBrokerHeartbeat = () => {};
2563
3032
  const inFlightGateResolutions = new Set<Promise<void>>();
2564
3033
  const trackGateResolution = <T>(resolution: Promise<T>): Promise<T> => {
2565
3034
  const quiesced = resolution.then(
@@ -2601,6 +3070,8 @@ export function createNotificationsExtension(
2601
3070
  if (capability === "permission") ctx.setSdkPermissionProvider?.(undefined);
2602
3071
  };
2603
3072
 
3073
+ const hostCapCache = new Map<string, ReadonlySet<string>>();
3074
+
2604
3075
  const configOverrides = new Map<string, unknown>();
2605
3076
  const configRevision = { current: 0 };
2606
3077
  const PROMPT_SUBMISSION_CAPACITY = 128;
@@ -2759,6 +3230,11 @@ export function createNotificationsExtension(
2759
3230
  submission.failed = true;
2760
3231
  submission.error = error;
2761
3232
  removePendingPromptCorrelation(correlation);
3233
+ if (
3234
+ runtime?.activePromptCorrelation?.commandId === correlation.commandId &&
3235
+ runtime.activePromptCorrelation.turnId === correlation.turnId
3236
+ )
3237
+ runtime.activePromptCorrelation = undefined;
2762
3238
  emitPromptFailure(correlation, error);
2763
3239
  };
2764
3240
  const acknowledgePrompt = (connectionId: string, correlation: { commandId: string; turnId: string }) => {
@@ -2826,6 +3302,10 @@ export function createNotificationsExtension(
2826
3302
  };
2827
3303
 
2828
3304
  const sendSdkFrame = (connectionId: string, frame: Record<string, unknown>) => {
3305
+ if (extensionShuttingDown || runtime?.stopping || runtimes.get(id) !== runtime) {
3306
+ abandonPromptResponse(connectionId, frame);
3307
+ return;
3308
+ }
2829
3309
  const json = JSON.stringify(frame);
2830
3310
  if (connectionId.startsWith("seam:")) {
2831
3311
  try {
@@ -2857,6 +3337,7 @@ export function createNotificationsExtension(
2857
3337
  stateRoot,
2858
3338
  token,
2859
3339
  sendFrame: (connectionId, frame) => sendSdkFrame(connectionId, frame),
3340
+ connectionCapabilities: connectionId => hostCapCache.get(connectionId) ?? EMPTY_CAPABILITIES,
2860
3341
  installProviderDefinitions,
2861
3342
  onProviderDefinitionsRemoved: removeProviderDefinitions,
2862
3343
  onFrame: handler => {
@@ -2974,14 +3455,16 @@ export function createNotificationsExtension(
2974
3455
  disposeGateTerminalController: () => {},
2975
3456
  disposeAckRecoveryParticipant: () => {},
2976
3457
  disposeGateEmitterListener: () => {},
3458
+ trackGateResolution,
2977
3459
  waitForGateResolutionQuiescence: async () => {
2978
3460
  await Promise.allSettled(inFlightGateResolutions);
2979
3461
  },
2980
3462
  workflowGate: undefined,
2981
3463
  gatePresentations,
2982
3464
  stopping: false,
3465
+ abortEphemeralTurns: () => {},
3466
+ disableEphemeralTurns: () => {},
2983
3467
  cancelPostmortemCleanup: () => {},
2984
- stopBrokerHeartbeat,
2985
3468
 
2986
3469
  redact,
2987
3470
  verbosity,
@@ -2993,8 +3476,10 @@ export function createNotificationsExtension(
2993
3476
  recordPromptTerminal,
2994
3477
  emitPromptLifecycle,
2995
3478
  pendingInbound: new Set<number>(),
3479
+ inFlightTools: new Map<string, { toolName: string; args: unknown }>(),
2996
3480
  };
2997
- runtimes.set(id, runtime);
3481
+ const initializedRuntime = runtime;
3482
+ runtimes.set(id, initializedRuntime);
2998
3483
  activeRuntimeId = id;
2999
3484
  const startSettled = Promise.withResolvers<SessionStartResult>();
3000
3485
  sessionStartPromises.set(id, startSettled.promise);
@@ -3028,16 +3513,40 @@ export function createNotificationsExtension(
3028
3513
  } catch {}
3029
3514
  };
3030
3515
 
3516
+ const ephemeralTurns = new EphemeralTurnHost(sendSdkFrame, async (question, signal) => {
3517
+ if (!options.runEphemeralTurn) throw new Error("Ephemeral turns are unavailable.");
3518
+ return await options.runEphemeralTurn(prompt.render(btwUserPrompt, { question }), signal);
3519
+ });
3520
+ initializedRuntime.abortEphemeralTurns = () => ephemeralTurns.dispose();
3521
+ initializedRuntime.disableEphemeralTurns = () => ephemeralTurns.disable();
3031
3522
  try {
3032
3523
  server.onSdkFrame((err, inbound) => {
3033
3524
  if (err || !inbound) return;
3034
3525
  try {
3035
- inboundSdkFrame?.(inbound.connectionId, JSON.parse(inbound.json) as Record<string, unknown>);
3526
+ const frame = JSON.parse(inbound.json) as unknown;
3527
+ if (!frame || typeof frame !== "object") return;
3528
+ const typedFrame = frame as Record<string, unknown>;
3529
+ if (typedFrame.type === "ephemeral_turn" || typedFrame.type === "ephemeral_turn_cancel") return;
3530
+ inboundSdkFrame?.(inbound.connectionId, typedFrame);
3036
3531
  } catch {}
3037
3532
  });
3533
+ // Required: the negotiated-capability callback is how the TS host learns
3534
+ // each connection's caps for replay-frame gating. If the linked
3535
+ // @gajae-code/natives binary predates it (linked/deduped installs where the
3536
+ // version did not change), fail loudly with an actionable message instead of
3537
+ // silently shipping a half-wired capability bridge.
3538
+ if (typeof server.onNegotiatedCapabilities !== "function") {
3539
+ throw new Error(
3540
+ "@gajae-code/natives is out of date: missing onNegotiatedCapabilities. Rebuild the native addon (bun --cwd=packages/natives run build).",
3541
+ );
3542
+ }
3543
+ server.onNegotiatedCapabilities((_err, connectionId, capabilities) => {
3544
+ if (connectionId) hostCapCache.set(connectionId, new Set(capabilities));
3545
+ });
3038
3546
  server.onConnectionClose((_err, connectionId) => {
3039
3547
  if (!connectionId) return;
3040
3548
  host.handleDisconnect(connectionId);
3549
+ hostCapCache.delete(connectionId);
3041
3550
  for (const submission of promptSubmissions.values())
3042
3551
  if (submission.connectionId === connectionId) abandonPrompt(submission);
3043
3552
  });
@@ -3259,6 +3768,11 @@ export function createNotificationsExtension(
3259
3768
  // thread (forwarded by the daemon over the WS, fail-closed at the daemon).
3260
3769
  server.onInbound((err, inbound) => {
3261
3770
  if (err || !inbound) return;
3771
+ const authenticatedInbound = inbound as typeof inbound & {
3772
+ connectionId: string;
3773
+ messageId?: number;
3774
+ reason?: string;
3775
+ };
3262
3776
  if (inbound.kind === "control_command") {
3263
3777
  const frame = sdkInboundFrame(inbound.commandJson);
3264
3778
  if (frame) {
@@ -3266,6 +3780,25 @@ export function createNotificationsExtension(
3266
3780
  return;
3267
3781
  }
3268
3782
  }
3783
+ if (
3784
+ (inbound.kind === "ephemeral_turn" || inbound.kind === "ephemeral_turn_cancel") &&
3785
+ !runtime?.notificationsActive
3786
+ )
3787
+ return;
3788
+ if (inbound.kind === "ephemeral_turn" || inbound.kind === "ephemeral_turn_cancel") {
3789
+ ephemeralTurns.handle(authenticatedInbound.connectionId, {
3790
+ type: authenticatedInbound.kind,
3791
+ sessionId: authenticatedInbound.sessionId,
3792
+ requestId: authenticatedInbound.requestId,
3793
+ updateId: authenticatedInbound.updateId,
3794
+ messageId: authenticatedInbound.messageId,
3795
+ threadId: authenticatedInbound.threadId,
3796
+ ...(authenticatedInbound.kind === "ephemeral_turn"
3797
+ ? { question: authenticatedInbound.text }
3798
+ : { reason: authenticatedInbound.reason }),
3799
+ });
3800
+ return;
3801
+ }
3269
3802
 
3270
3803
  if (inbound.kind === "user_message") {
3271
3804
  // Inject as a user turn (steers/continues the agent; the resulting
@@ -3313,6 +3846,12 @@ export function createNotificationsExtension(
3313
3846
  update.verbosity = inbound.verbosity;
3314
3847
  }
3315
3848
  if (typeof inbound.redact === "boolean") {
3849
+ // Redact turning ON: terminalize any already-visible in-flight tool
3850
+ // bubbles (while redact is still off so the helper emits) so they never
3851
+ // strand permanently in "started"; subsequent detail is then suppressed.
3852
+ if (inbound.redact && !runtime.redact) {
3853
+ terminalizeInFlightTools(runtime, runtime.id, "unknown");
3854
+ }
3316
3855
  runtime.redact = inbound.redact;
3317
3856
  update.redact = inbound.redact;
3318
3857
  }
@@ -3394,6 +3933,11 @@ export function createNotificationsExtension(
3394
3933
  };
3395
3934
  host.emitEvent({ kind: identityHeader.type, payload: identityHeader });
3396
3935
  const endpoint = await server.start();
3936
+ ephemeralTurns.configureAuthority({
3937
+ sessionId: id,
3938
+ endpointDigest: endpointAuthorityDigest(endpoint.url, token),
3939
+ eventGeneration: host.generation,
3940
+ });
3397
3941
  throwIfLifecycleStopped();
3398
3942
  if (runtimes.get(id) !== runtime) {
3399
3943
  finishStartup({ status: "failed" });
@@ -3437,30 +3981,20 @@ export function createNotificationsExtension(
3437
3981
  },
3438
3982
  });
3439
3983
  throwIfLifecycleStopped();
3440
- runtime.brokerRegistrationActive = true;
3441
- const timer = setInterval(() => {
3442
- void index
3443
- .append({
3444
- type: "host_heartbeat",
3445
- sessionId: id,
3446
- locator,
3447
- endpointGeneration: host.generation,
3448
- pid: process.pid,
3449
- })
3450
- .catch(error => logger.warn(`sdk broker heartbeat failed: ${String(error)}`));
3451
- }, 5_000);
3452
- stopBrokerHeartbeat = () => clearInterval(timer);
3984
+ initializedRuntime.brokerRegistrationActive = true;
3985
+ // Host liveness is derived from alive(pid) when the index is read; heartbeats
3986
+ // are deliberately not appended to the durable session index.
3453
3987
  } catch (brokerError) {
3454
3988
  if (lifecycleRequired) throw brokerError;
3455
3989
  logger.warn(`sdk broker registration skipped: ${String(brokerError)}`);
3456
3990
  }
3457
3991
  }
3458
3992
 
3459
- runtime.stopBrokerHeartbeat = stopBrokerHeartbeat;
3460
- const startedRuntime = runtime;
3461
- runtime.enableNotifications = () => {
3993
+ const startedRuntime = initializedRuntime;
3994
+ initializedRuntime.enableNotifications = () => {
3462
3995
  const runtime = startedRuntime;
3463
3996
  if (runtime.notificationsActive) return;
3997
+ ephemeralTurns.enable();
3464
3998
  runtime.notificationsActive = true;
3465
3999
  runtime.disposeAnswerSource = registerInteractiveAnswerSource(
3466
4000
  runtime.id,
@@ -3471,35 +4005,42 @@ export function createNotificationsExtension(
3471
4005
  if (runtime.redact) return { ok: false, error: TELEGRAM_FILE_REDACTION_ERROR };
3472
4006
  try {
3473
4007
  const data = await fs.promises.readFile(file.path);
3474
- pushSessionFrame(runtime, {
3475
- type: "file_attachment",
3476
- sessionId: runtime.id,
3477
- name: path.basename(file.path),
3478
- data: data.toString("base64"),
3479
- caption: file.caption,
3480
- });
4008
+ pushFileAttachment(
4009
+ runtime,
4010
+ {
4011
+ type: "file_attachment",
4012
+ sessionId: runtime.id,
4013
+ name: path.basename(file.path),
4014
+ caption: file.caption,
4015
+ },
4016
+ data,
4017
+ );
3481
4018
  return { ok: true };
3482
4019
  } catch (e) {
3483
4020
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
3484
4021
  }
3485
4022
  });
3486
4023
  };
3487
- const activeRuntime = runtime;
4024
+ const activeRuntime = initializedRuntime;
3488
4025
  // A native terminal close (SIGHUP), SIGTERM, Ctrl+C exit, or fatal error
3489
4026
  // skips AgentSession.dispose(), so the `session_shutdown` extension event
3490
4027
  // never fires and the daemon-side topic would be orphaned. postmortem
3491
4028
  // awaits registered cleanups on those paths, so send the graceful
3492
4029
  // `session_closed` frame from there too. stopSession() cancels this
3493
4030
  // registration on every other teardown path, so it never double-fires.
3494
- runtime.cancelPostmortemCleanup = postmortem.register(`notifications-session-closed:${id}`, async () => {
3495
- await stopSession(runtime!.id);
3496
- });
4031
+ initializedRuntime.cancelPostmortemCleanup = postmortem.register(
4032
+ `notifications-session-closed:${id}`,
4033
+ async () => {
4034
+ await stopSession(initializedRuntime.id);
4035
+ },
4036
+ );
3497
4037
  logger.info(`notifications: serving session ${id} at ${endpoint.url}`);
3498
4038
  // A workflow-gate emitter can be installed after session startup.
3499
4039
  // Attach dynamically so the SDK bus presents every durable gate.
3500
4040
  const attachWorkflowGate = (gate: WorkflowGateEmitter | undefined): void => {
3501
4041
  if (activeRuntime.workflowGate === gate) return;
3502
4042
  activeRuntime.disposeGateListener();
4043
+ activeRuntime.workflowGate?.setRuntimeTurnProvider?.(null);
3503
4044
  activeRuntime.disposeAckRecoveryParticipant();
3504
4045
  gatePresentations.dispose();
3505
4046
  activeRuntime.disposeGateTerminalController();
@@ -3512,6 +4053,7 @@ export function createNotificationsExtension(
3512
4053
  return;
3513
4054
  }
3514
4055
  activeRuntime.workflowGate = gate;
4056
+ gate.setRuntimeTurnProvider?.(() => activeRuntime.activePromptCorrelation?.turnId);
3515
4057
  if (hasTerminalArbitrationCapability(gate)) {
3516
4058
  const controller: WorkflowGateTerminalController = {
3517
4059
  completeGateInteractions: gateId => gatePresentations.complete(gateId),
@@ -3565,18 +4107,30 @@ export function createNotificationsExtension(
3565
4107
  });
3566
4108
  activeRuntime.disposeAckRecoveryParticipant = () => gate.setAckRecoveryParticipant?.(null);
3567
4109
  }
3568
- void gate.recoverAcceptedGates?.();
4110
+ void (typeof gate.recoverAcceptedGates === "function"
4111
+ ? trackGateResolution(gate.recoverAcceptedGates()).catch(() => {})
4112
+ : Promise.resolve());
3569
4113
  };
3570
4114
  activeRuntime.disposeGateEmitterListener = registerWorkflowGateEmitterListener(id, attachWorkflowGate);
3571
4115
  if (ctx.workflowGate) attachWorkflowGate(ctx.workflowGate);
3572
- if (notificationsEnabledForSession) runtime.enableNotifications();
3573
- finishStartup({ status: "started", runtime });
3574
- return { status: "started", runtime };
4116
+ if (notificationsEnabledForSession) initializedRuntime.enableNotifications();
4117
+ finishStartup({ status: "started", runtime: initializedRuntime });
4118
+ return { status: "started", runtime: initializedRuntime };
3575
4119
  } catch (e) {
3576
4120
  logger.warn(`notifications: failed to start server: ${String(e)}`);
3577
4121
  const result = failLifecycleStartup("failed", e);
3578
4122
  finishStartup(result);
3579
- if (!(await stopSession(id, "session", runtime))) await cleanupAbandonedStartup();
4123
+ let stopped = false;
4124
+ try {
4125
+ stopped = await stopSession(id, "session", runtime);
4126
+ } catch (error) {
4127
+ // A secondary owner-release failure during abandoned-startup cleanup is
4128
+ // retained for an explicit later retry via cleanupRetries; log it rather
4129
+ // than letting it escape startSession and surface a red extension error
4130
+ // through session_start / session_switch / session_branch.
4131
+ logger.error(`notifications: SDK notification runtime cleanup failed: ${String(error)}`);
4132
+ }
4133
+ if (!stopped) await cleanupAbandonedStartup();
3580
4134
  return { ...result, runtime };
3581
4135
  }
3582
4136
  }
@@ -3701,13 +4255,27 @@ export function createNotificationsExtension(
3701
4255
  event: { previousSessionFile?: string },
3702
4256
  ctx: ExtensionContext,
3703
4257
  ): Promise<void> => {
4258
+ if (extensionShuttingDown) return;
3704
4259
  const newId = sessionId(ctx);
3705
4260
  const prevId = activeRuntimeId ?? sessionIdFromFile(event.previousSessionFile);
3706
4261
  if (prevId && prevId !== newId) {
3707
4262
  controller.rekeySession(prevId, newId);
3708
- await stopSession(prevId);
4263
+ try {
4264
+ await stopSession(prevId);
4265
+ } catch (error) {
4266
+ // A retained owner-release failure keeps the exact runtime in
4267
+ // cleanupRetries for an explicit later retry; log it rather than
4268
+ // surfacing a red extension error
4269
+ // while rotating session authority (/new, fork, resume, branch).
4270
+ logger.error(`notifications: SDK notification runtime cleanup failed: ${String(error)}`);
4271
+ }
3709
4272
  }
4273
+ if (extensionShuttingDown) return;
3710
4274
  await startSession(ctx);
4275
+ if (extensionShuttingDown) {
4276
+ await stopSession(newId);
4277
+ return;
4278
+ }
3711
4279
  await controller.reconcileCurrentSession(ctx);
3712
4280
  };
3713
4281
  api.on("session_switch", async (event, ctx) => {
@@ -3725,6 +4293,19 @@ export function createNotificationsExtension(
3725
4293
  await rotateSessionAuthority(event, ctx);
3726
4294
  });
3727
4295
 
4296
+ const terminalizeInFlightTools = (rt: SessionRuntime, id: string, phase: "cancelled" | "unknown"): void => {
4297
+ if (rt.notificationsActive && !rt.redact) {
4298
+ for (const [toolCallId, { toolName }] of rt.inFlightTools) {
4299
+ try {
4300
+ pushSessionFrame(rt, { type: "tool_activity", sessionId: id, toolCallId, toolName, phase });
4301
+ } catch (e) {
4302
+ logger.warn(`notifications: synthetic tool_activity failed: ${String(e)}`);
4303
+ }
4304
+ }
4305
+ }
4306
+ rt.inFlightTools.clear();
4307
+ };
4308
+
3728
4309
  // Drive the live typing indicator: mark busy when the agent loop starts so
3729
4310
  // the daemon shows "typing…" in the thread while the agent is thinking,
3730
4311
  // before any turn output exists. Cleared on `agent_end` below.
@@ -3753,7 +4334,9 @@ export function createNotificationsExtension(
3753
4334
  api.on("turn_start", (_event, ctx) => {
3754
4335
  const id = sessionId(ctx);
3755
4336
  const rt = runtimes.get(id);
3756
- if (!rt?.notificationsActive) return;
4337
+ if (!rt) return;
4338
+ rt.turnSeq = (rt.turnSeq ?? 0) + 1;
4339
+ if (!rt.notificationsActive) return;
3757
4340
  // A new turn is live: re-open the live-stream window (see turnClosed).
3758
4341
  rt.turnClosed = false;
3759
4342
  if (rt.pendingInbound.size === 0) return;
@@ -3771,7 +4354,7 @@ export function createNotificationsExtension(
3771
4354
  // per `turn_end`. turn_end fires once per turn iteration, so a single
3772
4355
  // user-visible idle previously produced many idle pings (the flood); agent_end
3773
4356
  // fires exactly once per settle, yielding exactly one idle notification.
3774
- api.on("agent_end", (_event, ctx) => {
4357
+ api.on("agent_end", (event, ctx) => {
3775
4358
  const id = sessionId(ctx);
3776
4359
  const rt = runtimes.get(id);
3777
4360
  if (!rt) return;
@@ -3784,13 +4367,17 @@ export function createNotificationsExtension(
3784
4367
  } else {
3785
4368
  rt.emitPromptLifecycle(undefined, { type: "agent_end", sessionId: id });
3786
4369
  }
4370
+ rt.activePromptCorrelation = undefined;
4371
+ terminalizeInFlightTools(rt, id, event.stopReason === "cancelled" ? "cancelled" : "unknown");
3787
4372
  try {
3788
4373
  pushSessionFrame(rt, { type: "activity", sessionId: id, state: "idle" });
3789
4374
  } catch (e) {
3790
4375
  logger.warn(`notifications: activity (idle) failed: ${String(e)}`);
3791
4376
  }
3792
4377
  if (!rt.notificationsActive) return;
3793
- void rt.workflowGate?.recoverAcceptedGates?.();
4378
+ void (typeof rt.workflowGate?.recoverAcceptedGates === "function"
4379
+ ? rt.trackGateResolution(rt.workflowGate.recoverAcceptedGates()).catch(() => {})
4380
+ : Promise.resolve());
3794
4381
  const seq = rt.idleSeq++;
3795
4382
  // Re-assert the identity header so the daemon renames the topic once the
3796
4383
  // session title has been auto-generated ("{repo}/{branch} - {title}"). The
@@ -3862,11 +4449,10 @@ export function createNotificationsExtension(
3862
4449
  rt.preAskFlushedText = text;
3863
4450
  // Decision A: a stream-enabled turn must finalize as an in-place edit of ONE
3864
4451
  // live message, never a fresh (rich-promotable) send. If live frames were
3865
- // async-queued and none landed before this flush, allocate the per-turn ref
3866
- // now so the finalized frame always carries a messageRef → the daemon keeps it
3867
- // editable (HTML edit) and never rich-promotes a streamed final.
3868
- if (finalAnswer && rt.stream && rt.liveRef === undefined) {
3869
- rt.turnSeq = (rt.turnSeq ?? 0) + 1;
4452
+ // async-queued and none landed before this flush, reuse the per-turn ref
4453
+ // assigned at turn_start so the finalized frame remains editable (HTML edit)
4454
+ // and never rich-promotes a streamed final.
4455
+ if (finalAnswer && rt.stream && rt.liveRef === undefined && rt.turnSeq !== undefined) {
3870
4456
  rt.liveRef = String(rt.turnSeq);
3871
4457
  }
3872
4458
  try {
@@ -3890,11 +4476,99 @@ export function createNotificationsExtension(
3890
4476
  // path and ordered before it — unlike message_update, which is queued async),
3891
4477
  // then flushed here before the ask tool's execute calls registerAsk.
3892
4478
  api.on("tool_execution_start", (event, ctx) => {
3893
- if (event.toolName !== "ask") return;
4479
+ if (event.toolName === "ask") {
4480
+ const id = sessionId(ctx);
4481
+ const rt = runtimes.get(id);
4482
+ if (!rt?.notificationsActive || rt.redact) return;
4483
+ flushTurnText(rt, id, rt.currentTurnText, false);
4484
+ }
3894
4485
  const id = sessionId(ctx);
3895
4486
  const rt = runtimes.get(id);
3896
4487
  if (!rt?.notificationsActive || rt.redact) return;
3897
- flushTurnText(rt, id, rt.currentTurnText, false);
4488
+ rt.inFlightTools.set(event.toolCallId, { toolName: event.toolName, args: event.args });
4489
+ try {
4490
+ pushSessionFrame(rt, {
4491
+ type: "tool_activity",
4492
+ sessionId: id,
4493
+ toolCallId: event.toolCallId,
4494
+ toolName: event.toolName,
4495
+ phase: "started",
4496
+ });
4497
+ } catch (e) {
4498
+ logger.warn(`notifications: tool_activity start failed: ${String(e)}`);
4499
+ }
4500
+ });
4501
+
4502
+ api.on("tool_execution_end", (event, ctx) => {
4503
+ const id = sessionId(ctx);
4504
+ const rt = runtimes.get(id);
4505
+ if (!rt) return;
4506
+ const inFlight = rt.inFlightTools.get(event.toolCallId);
4507
+ if (!rt.notificationsActive || rt.redact) {
4508
+ rt.inFlightTools.delete(event.toolCallId);
4509
+ return;
4510
+ }
4511
+ try {
4512
+ const frame: {
4513
+ type: "tool_activity";
4514
+ sessionId: string;
4515
+ toolCallId: string;
4516
+ toolName: string;
4517
+ phase: "completed" | "failed";
4518
+ isError: boolean;
4519
+ argsSummary?: string;
4520
+ resultSummary?: string;
4521
+ } = {
4522
+ type: "tool_activity",
4523
+ sessionId: id,
4524
+ toolCallId: event.toolCallId,
4525
+ toolName: event.toolName,
4526
+ phase: event.isError ? "failed" : "completed",
4527
+ isError: event.isError,
4528
+ };
4529
+ if (rt.verbosity === "verbose") {
4530
+ const tool = ctx.resolveTool(event.toolName);
4531
+ const argsSummary = projectToolSummary(tool, "args", inFlight?.args);
4532
+ const resultSummary = projectToolSummary(tool, "result", event.result);
4533
+ if (argsSummary !== undefined) frame.argsSummary = argsSummary;
4534
+ if (resultSummary !== undefined) frame.resultSummary = resultSummary;
4535
+ }
4536
+ pushSessionFrame(rt, frame);
4537
+ } catch (e) {
4538
+ logger.warn(`notifications: tool_activity end failed: ${String(e)}`);
4539
+ } finally {
4540
+ rt.inFlightTools.delete(event.toolCallId);
4541
+ }
4542
+ });
4543
+
4544
+ api.on("reasoning_summary_end", (event, ctx) => {
4545
+ const id = sessionId(ctx);
4546
+ const rt = runtimes.get(id);
4547
+ if (!rt?.notificationsActive || rt.redact || rt.verbosity !== "verbose") return;
4548
+ if (!event.message || typeof event.message !== "object" || !("content" in event.message)) return;
4549
+ const content = event.message.content;
4550
+ if (!Array.isArray(content)) return;
4551
+ const block = content[event.contentIndex];
4552
+ if (block?.type !== "thinking" || (block.provenance !== "summary" && block.provenance !== "mixed")) return;
4553
+ // CoT boundary: emit ONLY the canonical provider-marked summaryText. Never
4554
+ // fall back to the event payload, which could carry inconsistent/mutated text.
4555
+ const text = block.summaryText;
4556
+ if (typeof text !== "string" || text === "") return;
4557
+ try {
4558
+ pushSessionFrame(rt, {
4559
+ type: "reasoning_summary",
4560
+ sessionId: id,
4561
+ text,
4562
+ // Coalesce on the reasoning block's stable itemId carried on the event, NOT
4563
+ // the mutable rt.turnSeq: a streamed reasoning_summary_end is queued async and
4564
+ // turn_start for the next iteration advances turnSeq synchronously first, so
4565
+ // reading turnSeq here could bind turn N's summary to turn N+1. Absent an
4566
+ // itemId, omit turnRef (threaded-render sends a fresh non-editable message).
4567
+ ...((block as { itemId?: string }).itemId ? { turnRef: (block as { itemId?: string }).itemId } : {}),
4568
+ });
4569
+ } catch (e) {
4570
+ logger.warn(`notifications: reasoning_summary failed: ${String(e)}`);
4571
+ }
3898
4572
  });
3899
4573
 
3900
4574
  api.on("turn_end", (event, ctx) => {
@@ -3924,8 +4598,7 @@ export function createNotificationsExtension(
3924
4598
  const rt = runtimes.get(id);
3925
4599
  if (!rt?.notificationsActive || !rt.stream || rt.redact || rt.turnClosed) return;
3926
4600
  if ((event.message as { role?: unknown }).role !== "assistant") return;
3927
- if (rt.liveRef === undefined) {
3928
- rt.turnSeq = (rt.turnSeq ?? 0) + 1;
4601
+ if (rt.liveRef === undefined && rt.turnSeq !== undefined) {
3929
4602
  rt.liveRef = String(rt.turnSeq);
3930
4603
  }
3931
4604
  const now = Date.now();
@@ -3971,9 +4644,24 @@ export function createNotificationsExtension(
3971
4644
  });
3972
4645
 
3973
4646
  api.on("session_shutdown", async (_event, ctx) => {
4647
+ extensionShuttingDown = true;
4648
+ identityControlInFlight = false;
4649
+ deferredIdentityRotation = undefined;
4650
+ const id = sessionId(ctx);
4651
+ const rt = runtimes.get(id);
4652
+ if (rt) terminalizeInFlightTools(rt, id, "unknown");
3974
4653
  const controllerStop =
3975
4654
  typeof ctx.sessionManager.getCwd === "function" ? controller.stopCurrentSession(ctx) : Promise.resolve(false);
3976
4655
  void controllerStop.catch(error => logger.warn(`notifications: controller shutdown failed: ${String(error)}`));
3977
- await stopSession(sessionId(ctx));
4656
+ try {
4657
+ await stopSession(id);
4658
+ } catch (error) {
4659
+ // A retained owner-release failure keeps the exact runtime in
4660
+ // cleanupRetries for an explicit later retry; log it rather than
4661
+ // surfacing a red extension error at
4662
+ // shutdown. On terminal quit there is no later retry cycle, so log at
4663
+ // error severity (matching the postmortem cleanup precedent).
4664
+ logger.error(`notifications: SDK notification runtime cleanup failed: ${String(error)}`);
4665
+ }
3978
4666
  });
3979
4667
  }