@gajae-code/coding-agent 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (474) hide show
  1. package/CHANGELOG.md +70 -2
  2. package/dist/types/append-only-mode.d.ts +1 -1
  3. package/dist/types/cli/args.d.ts +4 -5
  4. package/dist/types/cli/daemon-cli.d.ts +12 -1
  5. package/dist/types/cli/notify-cli.d.ts +21 -1
  6. package/dist/types/cli.d.ts +3 -0
  7. package/dist/types/commands/daemon.d.ts +39 -0
  8. package/dist/types/commands/launch.d.ts +3 -0
  9. package/dist/types/commands/notify.d.ts +28 -1
  10. package/dist/types/commands/sdk.d.ts +33 -0
  11. package/dist/types/config/atomic-yaml-patch.d.ts +85 -0
  12. package/dist/types/config/model-profile-activation.d.ts +35 -15
  13. package/dist/types/config/model-profiles.d.ts +11 -5
  14. package/dist/types/config/model-registry.d.ts +3 -3
  15. package/dist/types/config/model-resolver.d.ts +39 -6
  16. package/dist/types/config/model-selector-value.d.ts +17 -0
  17. package/dist/types/config/models-config-schema.d.ts +10 -6
  18. package/dist/types/config/settings-schema.d.ts +133 -6
  19. package/dist/types/config/settings.d.ts +63 -35
  20. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -6
  21. package/dist/types/coordinator-mcp/server.d.ts +6 -49
  22. package/dist/types/daemon/builtin.d.ts +6 -5
  23. package/dist/types/daemon/control-types.d.ts +1 -1
  24. package/dist/types/discovery/mcp-json.d.ts +12 -1
  25. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  26. package/dist/types/extensibility/extensions/compact-handler.d.ts +4 -2
  27. package/dist/types/extensibility/extensions/get-commands-handler.d.ts +2 -2
  28. package/dist/types/extensibility/extensions/loader.d.ts +10 -1
  29. package/dist/types/extensibility/extensions/runner.d.ts +3 -3
  30. package/dist/types/extensibility/extensions/types.d.ts +141 -16
  31. package/dist/types/extensibility/hooks/types.d.ts +1 -1
  32. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -2
  33. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
  34. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +3 -0
  35. package/dist/types/gjc-runtime/team-runtime.d.ts +3 -4
  36. package/dist/types/gjc-runtime/tmux-owner-isolation-cli.d.ts +13 -0
  37. package/dist/types/gjc-runtime/tmux-sessions.d.ts +4 -1
  38. package/dist/types/gjc-runtime/windows-powershell-command.d.ts +9 -0
  39. package/dist/types/harness-control-plane/operate.d.ts +5 -5
  40. package/dist/types/harness-control-plane/owner.d.ts +18 -4
  41. package/dist/types/harness-control-plane/receipts.d.ts +0 -1
  42. package/dist/types/harness-control-plane/sdk-transport.d.ts +43 -0
  43. package/dist/types/harness-control-plane/session-transport.d.ts +48 -0
  44. package/dist/types/harness-control-plane/types.d.ts +3 -4
  45. package/dist/types/lsp/index.d.ts +1 -1
  46. package/dist/types/main.d.ts +32 -38
  47. package/dist/types/modes/acp/acp-agent.d.ts +224 -53
  48. package/dist/types/modes/acp/acp-client-bridge.d.ts +1 -0
  49. package/dist/types/modes/acp/acp-mode.d.ts +6 -15
  50. package/dist/types/modes/acp/permission-mode.d.ts +4 -0
  51. package/dist/types/modes/acp/startup-options.d.ts +6 -0
  52. package/dist/types/modes/components/agent-dashboard.d.ts +3 -0
  53. package/dist/types/modes/components/gajae-pet-widget.d.ts +1 -0
  54. package/dist/types/modes/components/model-selector.d.ts +1 -0
  55. package/dist/types/modes/components/notifications-settings-editor.d.ts +135 -0
  56. package/dist/types/modes/components/pet-capability.d.ts +37 -0
  57. package/dist/types/modes/components/pet-selector.d.ts +1 -1
  58. package/dist/types/modes/components/settings-selector.d.ts +15 -4
  59. package/dist/types/modes/components/welcome.d.ts +1 -1
  60. package/dist/types/modes/controllers/selector-controller.d.ts +35 -1
  61. package/dist/types/modes/index.d.ts +0 -4
  62. package/dist/types/modes/interactive-mode.d.ts +5 -1
  63. package/dist/types/modes/runtime-init.d.ts +2 -2
  64. package/dist/types/modes/shared/agent-wire/approval-gate.d.ts +6 -9
  65. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +4 -6
  66. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +6 -6
  67. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +4 -9
  68. package/dist/types/modes/shared/agent-wire/event-observation.d.ts +1 -1
  69. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +169 -30
  70. package/dist/types/modes/shared/agent-wire/workflow-gate-schema.d.ts +7 -7
  71. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +105 -0
  72. package/dist/types/modes/types.d.ts +16 -1
  73. package/dist/types/runtime-mcp/client.d.ts +2 -0
  74. package/dist/types/runtime-mcp/config.d.ts +5 -1
  75. package/dist/types/runtime-mcp/manager.d.ts +8 -1
  76. package/dist/types/runtime-mcp/types.d.ts +4 -0
  77. package/dist/types/sdk/acp/adapter.d.ts +67 -0
  78. package/dist/types/sdk/acp/index.d.ts +2 -0
  79. package/dist/types/sdk/broker/broker.d.ts +53 -0
  80. package/dist/types/sdk/broker/discovery.d.ts +30 -0
  81. package/dist/types/sdk/broker/ensure.d.ts +33 -0
  82. package/dist/types/sdk/broker/identity.d.ts +3 -0
  83. package/dist/types/sdk/broker/index.d.ts +8 -0
  84. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +95 -0
  85. package/dist/types/sdk/broker/lifecycle.d.ts +85 -0
  86. package/dist/types/sdk/broker/process-incarnation.d.ts +14 -0
  87. package/dist/types/sdk/broker/runtime.d.ts +33 -0
  88. package/dist/types/sdk/broker/session-index.d.ts +57 -0
  89. package/dist/types/sdk/broker/state-version.d.ts +8 -0
  90. package/dist/types/sdk/broker/transport.d.ts +9 -0
  91. package/dist/types/sdk/bus/chat-command-policy.d.ts +49 -0
  92. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +21 -0
  93. package/dist/types/sdk/bus/chat-daemon-control.d.ts +111 -0
  94. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +77 -0
  95. package/dist/types/sdk/bus/chat-effect-journal.d.ts +84 -0
  96. package/dist/types/{notifications → sdk/bus}/config-commands.d.ts +7 -2
  97. package/dist/types/sdk/bus/config.d.ts +274 -0
  98. package/dist/types/sdk/bus/conversation-store.d.ts +72 -0
  99. package/dist/types/sdk/bus/discord-conversation.d.ts +58 -0
  100. package/dist/types/sdk/bus/discord-daemon.d.ts +45 -0
  101. package/dist/types/sdk/bus/discord-live-provider.d.ts +70 -0
  102. package/dist/types/sdk/bus/discord-provider.d.ts +85 -0
  103. package/dist/types/{notifications → sdk/bus}/html-format.d.ts +2 -2
  104. package/dist/types/{notifications → sdk/bus}/index.d.ts +113 -13
  105. package/dist/types/{notifications → sdk/bus}/lifecycle-control-runtime.d.ts +4 -2
  106. package/dist/types/{notifications → sdk/bus}/lifecycle-orchestrator.d.ts +18 -21
  107. package/dist/types/{notifications → sdk/bus}/managed-daemon.d.ts +1 -1
  108. package/dist/types/sdk/bus/native-runtime-compatibility.d.ts +13 -0
  109. package/dist/types/sdk/bus/notification-orchestration.d.ts +170 -0
  110. package/dist/types/{notifications → sdk/bus}/notification-service.d.ts +7 -1
  111. package/dist/types/sdk/bus/session-control.d.ts +83 -0
  112. package/dist/types/sdk/bus/slack-conversation.d.ts +56 -0
  113. package/dist/types/sdk/bus/slack-daemon.d.ts +60 -0
  114. package/dist/types/sdk/bus/slack-live-provider.d.ts +59 -0
  115. package/dist/types/sdk/bus/slack-provider.d.ts +55 -0
  116. package/dist/types/{notifications → sdk/bus}/telegram-cli.d.ts +4 -4
  117. package/dist/types/{notifications → sdk/bus}/telegram-daemon-cli.d.ts +14 -5
  118. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -0
  119. package/dist/types/{notifications → sdk/bus}/telegram-daemon-control.d.ts +2 -2
  120. package/dist/types/{notifications → sdk/bus}/telegram-daemon.d.ts +55 -17
  121. package/dist/types/{notifications → sdk/bus}/telegram-reference.d.ts +2 -2
  122. package/dist/types/sdk/bus/telegram-setup.d.ts +128 -0
  123. package/dist/types/{notifications → sdk/bus}/topic-registry.d.ts +11 -0
  124. package/dist/types/sdk/cli/index.d.ts +1 -0
  125. package/dist/types/sdk/cli/session-cli.d.ts +19 -0
  126. package/dist/types/sdk/client/client.d.ts +1 -0
  127. package/dist/types/sdk/client/discovery.d.ts +27 -0
  128. package/dist/types/sdk/client/index.d.ts +2 -0
  129. package/dist/types/sdk/host/control/dispatch.d.ts +34 -0
  130. package/dist/types/sdk/host/control/index.d.ts +2 -0
  131. package/dist/types/sdk/host/control/operations.d.ts +65 -0
  132. package/dist/types/sdk/host/events.d.ts +35 -0
  133. package/dist/types/sdk/host/host.d.ts +35 -0
  134. package/dist/types/sdk/host/index.d.ts +4 -0
  135. package/dist/types/sdk/host/query/cursor.d.ts +62 -0
  136. package/dist/types/sdk/host/query/handlers.d.ts +70 -0
  137. package/dist/types/sdk/host/query/index.d.ts +3 -0
  138. package/dist/types/sdk/host/query/revision-store.d.ts +60 -0
  139. package/dist/types/sdk/host/reverse-leases.d.ts +45 -0
  140. package/dist/types/sdk/host/types.d.ts +32 -0
  141. package/dist/types/sdk/index.d.ts +6 -0
  142. package/dist/types/sdk/lifecycle-session.d.ts +14 -0
  143. package/dist/types/sdk/mcp/index.d.ts +1 -0
  144. package/dist/types/sdk/mcp/server.d.ts +37 -0
  145. package/dist/types/sdk/models.d.ts +44 -0
  146. package/dist/types/sdk/protocol/adapter-validation.d.ts +14 -0
  147. package/dist/types/sdk/protocol/operation-registry.d.ts +24 -0
  148. package/dist/types/{sdk.d.ts → sdk/session.d.ts} +44 -38
  149. package/dist/types/sdk/startup-capability.d.ts +50 -0
  150. package/dist/types/session/agent-session.d.ts +114 -21
  151. package/dist/types/session/default-model-selection.d.ts +25 -0
  152. package/dist/types/session/fallback-chain-controller.d.ts +52 -0
  153. package/dist/types/session/session-manager.d.ts +58 -1
  154. package/dist/types/setup/credential-auto-import.d.ts +36 -6
  155. package/dist/types/tools/ask-answer-registry.d.ts +1 -1
  156. package/dist/types/tools/browser/benchmark/detector-report.d.ts +63 -0
  157. package/dist/types/tools/browser/benchmark/run-suite.d.ts +18 -0
  158. package/dist/types/tools/browser/launch.d.ts +22 -0
  159. package/dist/types/tools/browser/profile-discovery.d.ts +31 -0
  160. package/dist/types/tools/browser/profile-posture.d.ts +37 -0
  161. package/dist/types/tools/browser/profile-reuse.d.ts +39 -0
  162. package/dist/types/tools/browser/profile-warmup.d.ts +39 -0
  163. package/dist/types/tools/browser/registry.d.ts +19 -0
  164. package/dist/types/tools/browser/tab-protocol.d.ts +4 -0
  165. package/dist/types/tools/index.d.ts +8 -8
  166. package/package.json +34 -13
  167. package/scripts/build-sdk-package-smoke.ts +94 -0
  168. package/scripts/capture-notifications-settings-showcase.ts +435 -0
  169. package/scripts/compile-args.ts +4 -2
  170. package/scripts/g005-daemon-smoke.ts +4 -5
  171. package/scripts/g011-daemon-path-smoke.ts +5 -5
  172. package/scripts/generate-sdk-adapter-parity-manifest.ts +163 -0
  173. package/scripts/generate-sdk-operation-inventory.ts +867 -0
  174. package/scripts/generate-telegram-baseline-manifest.ts +128 -0
  175. package/scripts/manifest-schema.ts +124 -0
  176. package/scripts/run-test-manifest.ts +153 -0
  177. package/scripts/run-threat-model-check.ts +157 -0
  178. package/scripts/verify-gjc-sdk-canonicalization.ts +3467 -0
  179. package/scripts/verify-gjc-sdk-rename.ts +90 -0
  180. package/src/append-only-mode.ts +1 -1
  181. package/src/cli/args.ts +43 -12
  182. package/src/cli/auth-gateway-cli.ts +2 -2
  183. package/src/cli/config-cli.ts +2 -1
  184. package/src/cli/daemon-cli.ts +63 -14
  185. package/src/cli/list-models.ts +1 -1
  186. package/src/cli/notify-cli.ts +288 -286
  187. package/src/cli.ts +21 -2
  188. package/src/commands/daemon.ts +50 -6
  189. package/src/commands/harness.ts +93 -36
  190. package/src/commands/launch.ts +5 -2
  191. package/src/commands/mcp-serve.ts +19 -12
  192. package/src/commands/notify.ts +36 -3
  193. package/src/commands/sdk.ts +426 -0
  194. package/src/commit/agentic/agent.ts +1 -0
  195. package/src/config/atomic-yaml-patch.ts +423 -0
  196. package/src/config/model-profile-activation.ts +240 -89
  197. package/src/config/model-profiles.ts +37 -34
  198. package/src/config/model-registry.ts +51 -26
  199. package/src/config/model-resolver.ts +138 -51
  200. package/src/config/model-selector-value.ts +50 -0
  201. package/src/config/models-config-schema.ts +14 -21
  202. package/src/config/settings-schema.ts +128 -10
  203. package/src/config/settings.ts +1120 -158
  204. package/src/coordinator-mcp/model-preset.ts +74 -20
  205. package/src/coordinator-mcp/server.ts +1597 -3052
  206. package/src/daemon/builtin.ts +8 -5
  207. package/src/daemon/control-types.ts +1 -1
  208. package/src/defaults/gjc/skills/team/SKILL.md +4 -4
  209. package/src/discovery/mcp-json.ts +327 -20
  210. package/src/extensibility/custom-tools/types.ts +1 -1
  211. package/src/extensibility/extensions/compact-handler.ts +3 -3
  212. package/src/extensibility/extensions/get-commands-handler.ts +2 -2
  213. package/src/extensibility/extensions/loader.ts +69 -5
  214. package/src/extensibility/extensions/runner.ts +112 -4
  215. package/src/extensibility/extensions/types.ts +176 -17
  216. package/src/extensibility/hooks/types.ts +1 -1
  217. package/src/extensibility/slash-commands.ts +3 -3
  218. package/src/gjc-runtime/launch-tmux.ts +170 -86
  219. package/src/gjc-runtime/psmux-detect.ts +82 -4
  220. package/src/gjc-runtime/session-state-sidecar.ts +41 -31
  221. package/src/gjc-runtime/team-runtime.ts +54 -35
  222. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +53 -17
  223. package/src/gjc-runtime/tmux-owner-isolation.ts +5 -2
  224. package/src/gjc-runtime/tmux-sessions.ts +19 -13
  225. package/src/gjc-runtime/ultragoal-guard.ts +53 -6
  226. package/src/gjc-runtime/ultragoal-runtime.ts +155 -3
  227. package/src/gjc-runtime/windows-powershell-command.ts +58 -0
  228. package/src/harness-control-plane/operate.ts +9 -9
  229. package/src/harness-control-plane/owner.ts +283 -39
  230. package/src/harness-control-plane/receipts.ts +0 -1
  231. package/src/harness-control-plane/sdk-transport.ts +395 -0
  232. package/src/harness-control-plane/session-transport.ts +94 -0
  233. package/src/harness-control-plane/types.ts +1 -1
  234. package/src/internal-urls/docs-index.generated.ts +36 -37
  235. package/src/lsp/client.ts +42 -6
  236. package/src/lsp/index.ts +2 -2
  237. package/src/main.ts +299 -204
  238. package/src/modes/DESIGN.md +258 -0
  239. package/src/modes/acp/acp-agent.ts +1228 -2612
  240. package/src/modes/acp/acp-client-bridge.ts +2 -20
  241. package/src/modes/acp/acp-event-mapper.ts +18 -2
  242. package/src/modes/acp/acp-mode.ts +8 -36
  243. package/src/modes/acp/permission-mode.ts +25 -0
  244. package/src/modes/acp/startup-options.ts +6 -0
  245. package/src/modes/components/agent-dashboard.ts +37 -17
  246. package/src/modes/components/custom-editor.ts +1 -0
  247. package/src/modes/components/gajae-pet-widget.ts +169 -20
  248. package/src/modes/components/model-selector.ts +93 -35
  249. package/src/modes/components/notifications-settings-editor.ts +1379 -0
  250. package/src/modes/components/pet-capability.ts +115 -0
  251. package/src/modes/components/pet-selector.ts +4 -6
  252. package/src/modes/components/settings-selector.ts +86 -11
  253. package/src/modes/components/welcome.ts +4 -4
  254. package/src/modes/controllers/command-controller.ts +1 -2
  255. package/src/modes/controllers/event-controller.ts +11 -16
  256. package/src/modes/controllers/extension-ui-controller.ts +357 -12
  257. package/src/modes/controllers/input-controller.ts +43 -13
  258. package/src/modes/controllers/selector-controller.ts +671 -44
  259. package/src/modes/index.ts +0 -22
  260. package/src/modes/interactive-mode.ts +108 -24
  261. package/src/modes/runtime-init.ts +298 -6
  262. package/src/modes/shared/agent-wire/approval-gate.ts +9 -11
  263. package/src/modes/shared/agent-wire/deep-interview-gate.ts +10 -11
  264. package/src/modes/shared/agent-wire/event-contract.ts +7 -8
  265. package/src/modes/shared/agent-wire/event-envelope.ts +9 -17
  266. package/src/modes/shared/agent-wire/event-observation.ts +16 -7
  267. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +1254 -87
  268. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +15 -15
  269. package/src/modes/shared/agent-wire/workflow-gate-types.ts +113 -0
  270. package/src/modes/types.ts +16 -1
  271. package/src/runtime-mcp/client.ts +94 -19
  272. package/src/runtime-mcp/config.ts +27 -15
  273. package/src/runtime-mcp/manager.ts +343 -105
  274. package/src/runtime-mcp/oauth-flow.ts +42 -12
  275. package/src/runtime-mcp/transports/http.ts +49 -19
  276. package/src/runtime-mcp/transports/stdio.ts +47 -28
  277. package/src/runtime-mcp/types.ts +10 -0
  278. package/src/sdk/acp/adapter.ts +418 -0
  279. package/src/sdk/acp/index.ts +2 -0
  280. package/src/sdk/broker/broker.ts +642 -0
  281. package/src/sdk/broker/discovery.ts +94 -0
  282. package/src/sdk/broker/ensure.ts +249 -0
  283. package/src/sdk/broker/identity.ts +42 -0
  284. package/src/sdk/broker/index.ts +8 -0
  285. package/src/sdk/broker/internal-source-marker-2178.txt +1 -0
  286. package/src/sdk/broker/internal-source.bunfig.toml +1 -0
  287. package/src/sdk/broker/lifecycle-ledger.ts +286 -0
  288. package/src/sdk/broker/lifecycle.ts +2157 -0
  289. package/src/sdk/broker/process-incarnation.ts +159 -0
  290. package/src/sdk/broker/runtime.ts +164 -0
  291. package/src/sdk/broker/session-index.ts +318 -0
  292. package/src/sdk/broker/state-version.ts +23 -0
  293. package/src/sdk/broker/transport.ts +125 -0
  294. package/src/sdk/bus/chat-command-policy.ts +107 -0
  295. package/src/sdk/bus/chat-daemon-cli.ts +214 -0
  296. package/src/sdk/bus/chat-daemon-control.ts +713 -0
  297. package/src/sdk/bus/chat-daemon-runtime.ts +526 -0
  298. package/src/sdk/bus/chat-effect-journal.ts +353 -0
  299. package/src/{notifications → sdk/bus}/config-commands.ts +39 -10
  300. package/src/sdk/bus/config.ts +433 -0
  301. package/src/sdk/bus/conversation-store.ts +391 -0
  302. package/src/sdk/bus/discord-conversation.ts +85 -0
  303. package/src/sdk/bus/discord-daemon.ts +2275 -0
  304. package/src/sdk/bus/discord-live-provider.ts +521 -0
  305. package/src/sdk/bus/discord-provider.ts +56 -0
  306. package/src/{notifications → sdk/bus}/html-format.ts +2 -2
  307. package/src/sdk/bus/index.ts +3943 -0
  308. package/src/{notifications → sdk/bus}/lifecycle-commands.ts +2 -0
  309. package/src/{notifications → sdk/bus}/lifecycle-control-runtime.ts +225 -196
  310. package/src/{notifications → sdk/bus}/lifecycle-orchestrator.ts +161 -74
  311. package/src/{notifications → sdk/bus}/managed-daemon.ts +2 -2
  312. package/src/sdk/bus/native-runtime-compatibility.ts +42 -0
  313. package/src/sdk/bus/natives-augment.d.ts +38 -0
  314. package/src/sdk/bus/notification-orchestration.ts +535 -0
  315. package/src/{notifications → sdk/bus}/notification-service.ts +106 -12
  316. package/src/sdk/bus/session-control.ts +333 -0
  317. package/src/sdk/bus/slack-conversation.ts +74 -0
  318. package/src/sdk/bus/slack-daemon.ts +2144 -0
  319. package/src/sdk/bus/slack-live-provider.ts +380 -0
  320. package/src/sdk/bus/slack-provider.ts +76 -0
  321. package/src/{notifications → sdk/bus}/telegram-cli.ts +8 -8
  322. package/src/sdk/bus/telegram-daemon-cli.ts +330 -0
  323. package/src/sdk/bus/telegram-daemon-contract.ts +14 -0
  324. package/src/{notifications → sdk/bus}/telegram-daemon-control.ts +4 -4
  325. package/src/{notifications → sdk/bus}/telegram-daemon.ts +502 -54
  326. package/src/{notifications → sdk/bus}/telegram-reference.ts +2 -2
  327. package/src/sdk/bus/telegram-setup.ts +663 -0
  328. package/src/{notifications → sdk/bus}/topic-registry.ts +32 -0
  329. package/src/sdk/cli/index.ts +1 -0
  330. package/src/sdk/cli/session-cli.ts +275 -0
  331. package/src/sdk/client/client.ts +1 -0
  332. package/src/sdk/client/discovery.ts +108 -0
  333. package/src/sdk/client/index.ts +2 -0
  334. package/src/sdk/host/control/dispatch.ts +367 -0
  335. package/src/sdk/host/control/index.ts +10 -0
  336. package/src/sdk/host/control/operations.ts +63 -0
  337. package/src/sdk/host/events.ts +91 -0
  338. package/src/sdk/host/host.ts +296 -0
  339. package/src/sdk/host/index.ts +4 -0
  340. package/src/sdk/host/query/cursor.ts +189 -0
  341. package/src/sdk/host/query/handlers.ts +533 -0
  342. package/src/sdk/host/query/index.ts +3 -0
  343. package/src/sdk/host/query/revision-store.ts +1126 -0
  344. package/src/sdk/host/reverse-leases.ts +325 -0
  345. package/src/sdk/host/types.ts +29 -0
  346. package/src/sdk/index.ts +13 -0
  347. package/src/sdk/lifecycle-session.ts +39 -0
  348. package/src/sdk/mcp/index.ts +1 -0
  349. package/src/sdk/mcp/server.ts +332 -0
  350. package/src/sdk/models.ts +168 -0
  351. package/src/sdk/protocol/adapter-validation.ts +48 -0
  352. package/src/sdk/protocol/operation-inventory.generated.json +5654 -0
  353. package/src/sdk/protocol/operation-registry.ts +312 -0
  354. package/src/{sdk.ts → sdk/session.ts} +339 -212
  355. package/src/sdk/startup-capability.ts +189 -0
  356. package/src/session/agent-session.ts +2470 -866
  357. package/src/session/default-model-selection.ts +93 -0
  358. package/src/session/fallback-chain-controller.ts +136 -0
  359. package/src/session/session-manager.ts +447 -13
  360. package/src/session/session-storage.ts +8 -1
  361. package/src/setup/credential-auto-import.ts +290 -35
  362. package/src/setup/hermes/templates/operator-instructions.v1.md +3 -5
  363. package/src/slash-commands/builtin-registry.ts +33 -30
  364. package/src/task/executor.ts +62 -13
  365. package/src/tools/ask-answer-registry.ts +1 -1
  366. package/src/tools/ask.ts +13 -8
  367. package/src/tools/browser/STEALTH-THIRD-PARTY-NOTICES.md +26 -0
  368. package/src/tools/browser/benchmark/detector-report.ts +157 -0
  369. package/src/tools/browser/benchmark/run-suite.ts +39 -0
  370. package/src/tools/browser/launch.ts +124 -19
  371. package/src/tools/browser/profile-discovery.ts +63 -0
  372. package/src/tools/browser/profile-posture.ts +59 -0
  373. package/src/tools/browser/profile-reuse.ts +102 -0
  374. package/src/tools/browser/profile-warmup.ts +109 -0
  375. package/src/tools/browser/registry.ts +87 -11
  376. package/src/tools/browser/tab-protocol.ts +1 -0
  377. package/src/tools/browser/tab-supervisor.ts +1 -0
  378. package/src/tools/browser/tab-worker.ts +1 -1
  379. package/src/tools/browser.ts +6 -0
  380. package/src/tools/index.ts +8 -8
  381. package/src/tools/puppeteer/14_stealth_permissions.txt +57 -0
  382. package/src/tools/puppeteer/15_stealth_webrtc.txt +60 -0
  383. package/src/tools/telegram-send.ts +2 -2
  384. package/vendor/insane-search/MANIFEST.json +2 -1
  385. package/vendor/insane-search/engine/bias_check.py +4 -1
  386. package/vendor/insane-search/engine/phase0.py +26 -1
  387. package/dist/types/harness-control-plane/rpc-adapter.d.ts +0 -69
  388. package/dist/types/modes/bridge/auth.d.ts +0 -12
  389. package/dist/types/modes/bridge/bridge-client-bridge.d.ts +0 -9
  390. package/dist/types/modes/bridge/bridge-mode.d.ts +0 -47
  391. package/dist/types/modes/bridge/bridge-ui-context.d.ts +0 -88
  392. package/dist/types/modes/bridge/event-stream.d.ts +0 -8
  393. package/dist/types/modes/rpc/host-tools.d.ts +0 -1
  394. package/dist/types/modes/rpc/host-uris.d.ts +0 -1
  395. package/dist/types/modes/rpc/rpc-client.d.ts +0 -299
  396. package/dist/types/modes/rpc/rpc-mode.d.ts +0 -86
  397. package/dist/types/modes/rpc/rpc-socket-security.d.ts +0 -10
  398. package/dist/types/modes/rpc/rpc-types.d.ts +0 -810
  399. package/dist/types/modes/shared/agent-wire/command-contract.d.ts +0 -18
  400. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +0 -37
  401. package/dist/types/modes/shared/agent-wire/command-validation.d.ts +0 -2
  402. package/dist/types/modes/shared/agent-wire/handshake.d.ts +0 -56
  403. package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +0 -16
  404. package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +0 -17
  405. package/dist/types/modes/shared/agent-wire/protocol.d.ts +0 -25
  406. package/dist/types/modes/shared/agent-wire/responses.d.ts +0 -4
  407. package/dist/types/modes/shared/agent-wire/scopes.d.ts +0 -18
  408. package/dist/types/modes/shared/agent-wire/session-registry.d.ts +0 -25
  409. package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +0 -42
  410. package/dist/types/modes/shared/agent-wire/ui-result.d.ts +0 -27
  411. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +0 -29
  412. package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +0 -68
  413. package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +0 -166
  414. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +0 -127
  415. package/dist/types/notifications/config.d.ts +0 -132
  416. package/src/harness-control-plane/rpc-adapter.ts +0 -290
  417. package/src/modes/bridge/auth.ts +0 -41
  418. package/src/modes/bridge/bridge-client-bridge.ts +0 -47
  419. package/src/modes/bridge/bridge-mode.ts +0 -675
  420. package/src/modes/bridge/bridge-ui-context.ts +0 -200
  421. package/src/modes/bridge/event-stream.ts +0 -70
  422. package/src/modes/rpc/host-tools.ts +0 -1
  423. package/src/modes/rpc/host-uris.ts +0 -1
  424. package/src/modes/rpc/rpc-client.ts +0 -1166
  425. package/src/modes/rpc/rpc-mode.ts +0 -1029
  426. package/src/modes/rpc/rpc-socket-security.ts +0 -115
  427. package/src/modes/rpc/rpc-types.ts +0 -637
  428. package/src/modes/shared/agent-wire/command-contract.ts +0 -18
  429. package/src/modes/shared/agent-wire/command-dispatch.ts +0 -498
  430. package/src/modes/shared/agent-wire/command-validation.ts +0 -181
  431. package/src/modes/shared/agent-wire/handshake.ts +0 -157
  432. package/src/modes/shared/agent-wire/host-tool-bridge.ts +0 -197
  433. package/src/modes/shared/agent-wire/host-uri-bridge.ts +0 -236
  434. package/src/modes/shared/agent-wire/protocol.ts +0 -46
  435. package/src/modes/shared/agent-wire/responses.ts +0 -17
  436. package/src/modes/shared/agent-wire/scopes.ts +0 -94
  437. package/src/modes/shared/agent-wire/session-registry.ts +0 -109
  438. package/src/modes/shared/agent-wire/ui-request-broker.ts +0 -150
  439. package/src/modes/shared/agent-wire/ui-result.ts +0 -48
  440. package/src/modes/shared/agent-wire/unattended-action-policy.ts +0 -365
  441. package/src/modes/shared/agent-wire/unattended-audit.ts +0 -176
  442. package/src/modes/shared/agent-wire/unattended-run-controller.ts +0 -426
  443. package/src/modes/shared/agent-wire/unattended-session.ts +0 -528
  444. package/src/notifications/config.ts +0 -219
  445. package/src/notifications/index.ts +0 -1835
  446. package/src/notifications/telegram-daemon-cli.ts +0 -243
  447. package/src/tools/puppeteer/09_stealth_locale.txt +0 -46
  448. /package/dist/types/{notifications → sdk/bus}/attachment-registry.d.ts +0 -0
  449. /package/dist/types/{notifications → sdk/bus}/chat-adapters.d.ts +0 -0
  450. /package/dist/types/{notifications → sdk/bus}/daemon-paths.d.ts +0 -0
  451. /package/dist/types/{notifications → sdk/bus}/engine.d.ts +0 -0
  452. /package/dist/types/{notifications → sdk/bus}/helpers.d.ts +0 -0
  453. /package/dist/types/{notifications → sdk/bus}/lifecycle-commands.d.ts +0 -0
  454. /package/dist/types/{notifications → sdk/bus}/operator-runtime.d.ts +0 -0
  455. /package/dist/types/{notifications → sdk/bus}/rate-limit-pool.d.ts +0 -0
  456. /package/dist/types/{notifications → sdk/bus}/recent-activity.d.ts +0 -0
  457. /package/dist/types/{notifications → sdk/bus}/reply-sent-store.d.ts +0 -0
  458. /package/dist/types/{notifications → sdk/bus}/rich-draft.d.ts +0 -0
  459. /package/dist/types/{notifications → sdk/bus}/rich-render.d.ts +0 -0
  460. /package/dist/types/{notifications → sdk/bus}/threaded-inbound.d.ts +0 -0
  461. /package/dist/types/{notifications → sdk/bus}/threaded-render.d.ts +0 -0
  462. /package/src/{notifications → sdk/bus}/attachment-registry.ts +0 -0
  463. /package/src/{notifications → sdk/bus}/chat-adapters.ts +0 -0
  464. /package/src/{notifications → sdk/bus}/daemon-paths.ts +0 -0
  465. /package/src/{notifications → sdk/bus}/engine.ts +0 -0
  466. /package/src/{notifications → sdk/bus}/helpers.ts +0 -0
  467. /package/src/{notifications → sdk/bus}/operator-runtime.ts +0 -0
  468. /package/src/{notifications → sdk/bus}/rate-limit-pool.ts +0 -0
  469. /package/src/{notifications → sdk/bus}/recent-activity.ts +0 -0
  470. /package/src/{notifications → sdk/bus}/reply-sent-store.ts +0 -0
  471. /package/src/{notifications → sdk/bus}/rich-draft.ts +0 -0
  472. /package/src/{notifications → sdk/bus}/rich-render.ts +0 -0
  473. /package/src/{notifications → sdk/bus}/threaded-inbound.ts +0 -0
  474. /package/src/{notifications → sdk/bus}/threaded-render.ts +0 -0
@@ -0,0 +1,3943 @@
1
+ /// <reference path="./natives-augment.d.ts" />
2
+ /**
3
+ * Notifications extension.
4
+ *
5
+ * Hosts a per-session loopback WebSocket notification server (the Rust core via
6
+ * N-API) and bridges GJC session events + the `ask` tool to it so a remote client
7
+ * (e.g. a Telegram bot) can both see action-needed signals and answer them
8
+ * through SDK-native session capabilities:
9
+ *
10
+ * - `ask` (interactive): registers an {@link AskAnswerSource}; the ask tool races
11
+ * the local UI against a remote reply. First valid answer wins; a local answer
12
+ * aborts the remote wait (and broadcasts `action_resolved` resolvedBy=local).
13
+ * - `ask` (workflow gate): observes emitted workflow gates and resolves the real
14
+ * gate on a remote reply via `ctx.workflowGate`.
15
+ * - `turn_end` -> `action_needed` (kind `idle`, deduped per turn).
16
+ * - `session_shutdown` -> `session_closed` frame, stop server, deregister answer source.
17
+ *
18
+ * Enable with Settings notifications config, `GJC_NOTIFICATIONS=1` (a token is
19
+ * generated), or `GJC_NOTIFICATIONS_TOKEN`.
20
+ */
21
+
22
+ import { AsyncLocalStorage } from "node:async_hooks";
23
+
24
+ import { execFile } from "node:child_process";
25
+ import * as crypto from "node:crypto";
26
+ import * as fs from "node:fs";
27
+ import * as os from "node:os";
28
+ import * as path from "node:path";
29
+ import { promisify } from "node:util";
30
+ import { ThinkingLevel } from "@gajae-code/agent-core";
31
+ import type { ImageContent, TextContent } from "@gajae-code/ai";
32
+ import { NotificationServer, nativeBuildInfo } from "@gajae-code/natives";
33
+ import { logger, postmortem, VERSION } from "@gajae-code/utils";
34
+ import { Settings } from "../../config/settings";
35
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../../extensibility/extensions";
36
+ import type {
37
+ WorkflowGateEmitter,
38
+ WorkflowGateTerminalController,
39
+ WorkflowGateTerminalProof,
40
+ } from "../../modes/shared/agent-wire/workflow-gate-broker";
41
+ import { parseThinkingLevel } from "../../thinking";
42
+ import type {
43
+ AskAnswerRequest,
44
+ AskAnswerSourceResult,
45
+ AskRemoteControl,
46
+ AskRemoteInteraction,
47
+ AskRemoteReceipt,
48
+ AskSelectedAckOutcome,
49
+ AskSettlement,
50
+ AskSettlementResult,
51
+ } from "../../tools";
52
+ import { registerAskAnswerSource, registerWorkflowGateEmitterListener } from "../../tools/ask-answer-registry";
53
+ import { ensureBroker } from "../broker/ensure";
54
+ import { SessionIndex } from "../broker/session-index";
55
+ import { SessionSdkHost, shouldHostSdk } from "../host";
56
+ import { type ControlSurface, dispatchControl } from "../host/control";
57
+ import { CursorRegistry, QueryHandlers, RevisionStore, type SessionSurface } from "../host/query";
58
+ import { projectQ10Models } from "../models.js";
59
+ import { OPERATIONS } from "../protocol/operation-registry";
60
+ import {
61
+ lifecycleStartupCapabilityForApi,
62
+ normalizeSdkStartupFailure,
63
+ type SdkStartupFailure,
64
+ } from "../startup-capability";
65
+
66
+ import { registerTelegramFileSink } from "./attachment-registry";
67
+ import { ensureDiscordDaemon, ensureSlackDaemon } from "./chat-daemon-control";
68
+ import {
69
+ getNotificationConfig,
70
+ isDiscordConfigured,
71
+ isSessionNotificationsEnabled,
72
+ isSlackConfigured,
73
+ isTelegramConfigured,
74
+ type NotificationConfig,
75
+ type NotificationSettingsReader,
76
+ sessionTag,
77
+ } from "./config";
78
+ import { telegramControlCommandUsage } from "./config-commands";
79
+ import { imageAttachmentsFromMessage, notificationActionPayload, summaryFromMessage } from "./helpers";
80
+ import { assertNativeRuntimeCompatibility } from "./native-runtime-compatibility";
81
+ import { NotificationSessionController, type NotificationSessionRuntime } from "./session-control";
82
+ import { type EnsureDaemonResult, ensureTelegramDaemonRunningDetailed } from "./telegram-daemon";
83
+
84
+ // ===========================================================================
85
+ // Session lifecycle control protocol (TypeScript mirror of the Rust wire
86
+ // contract in `crates/gjc-sdk/src/lifecycle.rs`).
87
+ //
88
+ // These describe the frames exchanged over the daemon-owned, session-independent
89
+ // control endpoint for remote session create / close / resume. Field names are
90
+ // camelCase on the wire; `type`/`kind` discriminators are snake_case. The Rust
91
+ // ingress authenticates and forwards; the daemon (TypeScript) owns all policy,
92
+ // spawn orchestration, idempotency, rate limiting, audit, and UX.
93
+ // ===========================================================================
94
+
95
+ /** Where a `session_create` should run. Discriminated by `kind`. */
96
+ export type SessionCreateTarget =
97
+ | { kind: "existing_path"; path: string }
98
+ | { kind: "worktree"; repo: string; branch: string }
99
+ | { kind: "plain_dir"; path: string };
100
+
101
+ /** Identifies the session a `session_close` targets. */
102
+ export interface SessionCloseTarget {
103
+ sessionId: string;
104
+ /** Expected GJC-managed tmux session name (defense-in-depth match). */
105
+ tmuxSession?: string;
106
+ /** Expected `@gjc-session-state-file` tag (defense-in-depth match). */
107
+ sessionStateFile?: string;
108
+ }
109
+
110
+ /** Identifies the session a `session_resume` targets. */
111
+ export interface SessionResumeTarget {
112
+ sessionIdOrPrefix: string;
113
+ /** Optional repo/working-dir hint to disambiguate matches. */
114
+ path?: string;
115
+ }
116
+
117
+ /** Create a new session. */
118
+ export interface SessionCreateFrame {
119
+ type: "session_create";
120
+ requestId: string;
121
+ /** Deterministic lifecycle marker preallocated by the daemon before spawn. */
122
+ lifecycleRequestId: string;
123
+ /** Session id the daemon preallocated and propagates to the child. */
124
+ intendedSessionId: string;
125
+ /** Telegram update id (idempotency key on the daemon side). */
126
+ updateId: number;
127
+ chatId: string;
128
+ /** Control-endpoint token authorizing this frame. */
129
+ token: string;
130
+ target: SessionCreateTarget;
131
+ /** Reserved for a future capability transport; any supplied value is rejected before lifecycle acceptance. */
132
+ startupPromptRef?: string;
133
+ /** Model profile preset to activate for the spawned session (--mpreset). */
134
+ modelPreset?: string;
135
+ }
136
+
137
+ /** Close (hard-kill, history preserved) a session. */
138
+ export interface SessionCloseFrame {
139
+ type: "session_close";
140
+ requestId: string;
141
+ updateId: number;
142
+ chatId: string;
143
+ token: string;
144
+ target: SessionCloseTarget;
145
+ /** Required force-only close flag; false/omitted is rejected by daemon policy. */
146
+ force?: boolean;
147
+ }
148
+
149
+ /** Resume a session (reattach if alive, else cold-restart from history). */
150
+ export interface SessionResumeFrame {
151
+ type: "session_resume";
152
+ requestId: string;
153
+ updateId: number;
154
+ chatId: string;
155
+ token: string;
156
+ target: SessionResumeTarget;
157
+ /** Reserved for a future capability transport; any supplied value is rejected before lifecycle acceptance. */
158
+ startupPromptRef?: string;
159
+ }
160
+
161
+ /** Any client -> ingress lifecycle request frame. */
162
+ export type SessionLifecycleRequest = SessionCreateFrame | SessionCloseFrame | SessionResumeFrame;
163
+
164
+ /** Terminal status of a lifecycle request. */
165
+ export type LifecycleStatus = "ok" | "error";
166
+
167
+ /** A connected session's per-session endpoint, returned to the control client. */
168
+ export interface LifecycleEndpoint {
169
+ url: string;
170
+ token: string;
171
+ }
172
+
173
+ /** The Telegram topic/thread a session is surfaced in. */
174
+ export interface LifecycleTopic {
175
+ chatId: string;
176
+ threadId: string;
177
+ }
178
+
179
+ /** How a create request was correlated to its spawned session. */
180
+ export type MatchedBy = "spawn_marker" | "session_ready";
181
+
182
+ /** Response to a successful `session_create`. */
183
+ export interface SessionCreateResponseFrame {
184
+ type: "session_create_response";
185
+ requestId: string;
186
+ status: LifecycleStatus;
187
+ lifecycleRequestId: string;
188
+ sessionId: string;
189
+ matchedBy: MatchedBy;
190
+ endpoint: LifecycleEndpoint;
191
+ topic: LifecycleTopic;
192
+ target: SessionCreateTarget;
193
+ }
194
+
195
+ /** Response to a successful `session_close`. */
196
+ export interface SessionCloseResponseFrame {
197
+ type: "session_close_response";
198
+ requestId: string;
199
+ status: LifecycleStatus;
200
+ sessionId: string;
201
+ processGone: boolean;
202
+ historyPreserved: boolean;
203
+ endpointStale: boolean;
204
+ }
205
+
206
+ /** Whether a resume reattached to a live session or cold-restarted a dead one. */
207
+ export type ResumeMode = "reattached" | "cold_restarted";
208
+
209
+ /** Response to a successful `session_resume`. */
210
+ export interface SessionResumeResponseFrame {
211
+ type: "session_resume_response";
212
+ requestId: string;
213
+ status: LifecycleStatus;
214
+ sessionId: string;
215
+ mode: ResumeMode;
216
+ endpoint: LifecycleEndpoint;
217
+ topic: LifecycleTopic;
218
+ }
219
+
220
+ /** Machine-readable reason a lifecycle request failed. */
221
+ export type LifecycleErrorReason =
222
+ | "unauthorized"
223
+ | "rate_limited"
224
+ | "duplicate_conflict"
225
+ | "invalid_target"
226
+ | "ambiguous_target"
227
+ | "spawn_failed"
228
+ | "discovery_timeout"
229
+ | "readiness_timeout"
230
+ | "close_refused"
231
+ | "not_found"
232
+ | "terminal_uncertain"
233
+ | "unsupported_platform";
234
+
235
+ /** A candidate returned with an `ambiguous_target` resume error. */
236
+ export interface ResumeCandidate {
237
+ sessionId: string;
238
+ path?: string;
239
+ /** Last-activity epoch-millis (session history file mtime), if known. */
240
+ mtimeMs?: number;
241
+ }
242
+
243
+ /** A structured lifecycle error frame. */
244
+ export interface SessionLifecycleErrorFrame {
245
+ type: "session_lifecycle_error";
246
+ requestId: string;
247
+ status: LifecycleStatus;
248
+ reason: LifecycleErrorReason;
249
+ message: string;
250
+ candidates?: ResumeCandidate[];
251
+ }
252
+
253
+ /** Any ingress -> client lifecycle response frame. */
254
+ export type SessionLifecycleResponse =
255
+ | SessionCreateResponseFrame
256
+ | SessionCloseResponseFrame
257
+ | SessionResumeResponseFrame
258
+ | SessionLifecycleErrorFrame;
259
+
260
+ /**
261
+ * Replayable per-session readiness signal (mirror of the Rust `session_ready`
262
+ * frame). Buffered and replayed to late clients so WS-open alone never implies
263
+ * the session is live and surfaced.
264
+ */
265
+ export interface SessionReadyFrame {
266
+ type: "session_ready";
267
+ sessionId: string;
268
+ lifecycleRequestId?: string;
269
+ startupPromptRef?: string;
270
+ repo?: string;
271
+ branch?: string;
272
+ title?: string;
273
+ }
274
+
275
+ /** Resolve the git dir for `cwd`, handling worktrees where `.git` is a file. */
276
+ function gitDir(cwd: string): string | undefined {
277
+ const dot = path.join(cwd, ".git");
278
+ try {
279
+ if (fs.statSync(dot).isDirectory()) return dot;
280
+ const m = fs
281
+ .readFileSync(dot, "utf8")
282
+ .trim()
283
+ .match(/^gitdir:\s*(.+)$/);
284
+ if (m) return path.resolve(cwd, m[1]);
285
+ } catch {}
286
+ return undefined;
287
+ }
288
+
289
+ /** Best-effort current branch from `.git/HEAD` (no git spawn). */
290
+ function readGitBranch(cwd: string): string | undefined {
291
+ const gd = gitDir(cwd);
292
+ if (!gd) return undefined;
293
+ try {
294
+ const head = fs.readFileSync(path.join(gd, "HEAD"), "utf8").trim();
295
+ const m = head.match(/^ref:\s*refs\/heads\/(.+)$/);
296
+ return m ? m[1] : head.slice(0, 12);
297
+ } catch {
298
+ return undefined;
299
+ }
300
+ }
301
+
302
+ /** Resolve the shared git dir (the main repo's `.git`) for a possibly-linked worktree. */
303
+ function gitCommonDir(gd: string): string {
304
+ try {
305
+ const raw = fs.readFileSync(path.join(gd, "commondir"), "utf8").trim();
306
+ if (raw) return path.resolve(gd, raw);
307
+ } catch {}
308
+ return gd;
309
+ }
310
+
311
+ /**
312
+ * Best-effort real repository name (no git spawn): resolves the main worktree
313
+ * root directory so linked worktrees report the repo (e.g. `gajae-code`)
314
+ * instead of the worktree directory (e.g. `feat-foo-01047f11`).
315
+ */
316
+ export function readGitRepoName(cwd: string): string | undefined {
317
+ const gd = gitDir(cwd);
318
+ if (!gd) return undefined;
319
+ const commonDir = gitCommonDir(gd);
320
+ // Strip the trailing `.git` to land on the main worktree root directory.
321
+ const repoRoot = path.basename(commonDir) === ".git" ? path.dirname(commonDir) : commonDir;
322
+ const name = path.basename(repoRoot);
323
+ return name && name !== ".git" ? name : undefined;
324
+ }
325
+
326
+ /** Build the one-time identity header fields for a session thread. */
327
+ function buildIdentity(
328
+ cwd: string,
329
+ sessionName?: string,
330
+ ): {
331
+ repo: string;
332
+ branch: string;
333
+ machine: string;
334
+ title?: string;
335
+ } {
336
+ const repo = readGitRepoName(cwd) ?? (path.basename(cwd) || cwd);
337
+ const branch = readGitBranch(cwd) ?? "(detached)";
338
+ // Send repo/branch and the raw session title separately; the consumer
339
+ // composes the topic name ("{repo}/{branch}" before the session title is
340
+ // auto-generated, then "{repo}/{branch} - {session title}" once it exists).
341
+ return { repo, branch, machine: os.hostname(), title: sessionName };
342
+ }
343
+
344
+ /** Compact cwd label for remote session identity; never emits the full host path by default. */
345
+ function compactCwd(cwd: string): string | undefined {
346
+ const home = os.homedir();
347
+ const resolved = path.resolve(cwd);
348
+ if (resolved === home) return "~";
349
+ const base = path.basename(resolved);
350
+ return base || path.parse(resolved).root || undefined;
351
+ }
352
+
353
+ const execFileAsync = promisify(execFile);
354
+
355
+ /** Best-effort working-tree diff stat for the context update (no throw). */
356
+ async function readGitDiffStat(cwd: string): Promise<string | undefined> {
357
+ try {
358
+ const { stdout } = await execFileAsync("git", ["-C", cwd, "diff", "--stat", "--no-color"], {
359
+ timeout: 3000,
360
+ maxBuffer: 256 * 1024,
361
+ });
362
+ const trimmed = stdout.trim();
363
+ return trimmed ? trimmed.slice(0, 1500) : undefined;
364
+ } catch {
365
+ return undefined;
366
+ }
367
+ }
368
+
369
+ class DiffQueryError extends Error {
370
+ constructor(
371
+ readonly code: "not_git_repository" | "diff_too_large",
372
+ message: string,
373
+ ) {
374
+ super(message);
375
+ }
376
+ }
377
+
378
+ interface PendingInteractiveAsk {
379
+ resolve: (result: AskAnswerSourceResult) => void;
380
+ options: string[];
381
+ controls: readonly AskRemoteControl[];
382
+ actionId?: string;
383
+ retireForDirectControl: () => RetireStatus;
384
+ reissue: () => boolean;
385
+ complete: (actionId: string) => void;
386
+ completeDirect: () => void;
387
+ fail: (actionId: string) => void;
388
+ }
389
+
390
+ interface UnattendedGatePresentation {
391
+ gateId: string;
392
+ sessionId: string;
393
+ question: string;
394
+ options: string[];
395
+ controls: readonly AskRemoteControl[];
396
+ multi: boolean;
397
+ allowEmpty: boolean;
398
+ navigationLabel?: "Next" | "Done";
399
+ selectedOptions: string[];
400
+ workflowGateId?: string;
401
+ onActivated?: (actionId: string, lease: { actionId: string; registrationEpoch: number }) => void;
402
+ onClosed?: () => void;
403
+ }
404
+
405
+ type RetireStatus = "retired" | "already_terminal" | "claimed" | "stale";
406
+ type DirectControlOutcome = "accepted" | "rejected" | "unknown";
407
+
408
+ function parseRetireStatus(status: string): RetireStatus {
409
+ if (status === "retired" || status === "already_terminal" || status === "claimed" || status === "stale")
410
+ return status;
411
+ throw new Error(`Unexpected native retirement status: ${status}`);
412
+ }
413
+
414
+ function isTerminalProof(status: RetireStatus): status is "retired" | "already_terminal" {
415
+ return status === "retired" || status === "already_terminal";
416
+ }
417
+
418
+ export class PresentationArbiter {
419
+ private readonly presentations = new Map<string, UnattendedGatePresentation>();
420
+ private readonly routes = new Map<string, string>();
421
+ private active: { actionId: string; gateId: string; registrationEpoch: number } | undefined;
422
+ private readonly queue: string[] = [];
423
+ private readonly retries = new Map<string, { attempts: number; exhausted: boolean; nextAt: number }>();
424
+ private readonly retiredProofs = new Map<string, WorkflowGateTerminalProof>();
425
+ private readonly directControls = new Map<string, number>();
426
+ /** Explicit terminal proof for a direct control fenced before native publication. */
427
+ private readonly queuedDirectControls = new Set<string>();
428
+ private retryTimer: ReturnType<typeof setTimeout> | undefined;
429
+ private retryTimerGateId: string | undefined;
430
+ private retryTimerGeneration = 0;
431
+ private readonly terminalCancellationTimers = new Map<string, ReturnType<typeof setTimeout>>();
432
+
433
+ private headGeneration = 0;
434
+ private observedHead: string | undefined;
435
+ static readonly maxRegistrationAttempts = 3;
436
+ static readonly retryBaseDelayMs = 50;
437
+ static readonly retryMaxDelayMs = 1_000;
438
+ /** Bound an unavailable interactive answer source without discarding its head silently. */
439
+ static readonly terminalCancellationDelayMs = 250;
440
+
441
+ #observeHead(): number {
442
+ const head = this.queue[0];
443
+ if (head !== this.observedHead) {
444
+ this.observedHead = head;
445
+ this.headGeneration++;
446
+ if (this.retryTimer) {
447
+ clearTimeout(this.retryTimer);
448
+ this.retryTimer = undefined;
449
+ this.retryTimerGateId = undefined;
450
+ }
451
+ }
452
+ return this.headGeneration;
453
+ }
454
+
455
+ #clearTerminalCancellation(gateId: string): void {
456
+ const timer = this.terminalCancellationTimers.get(gateId);
457
+ if (timer) clearTimeout(timer);
458
+ this.terminalCancellationTimers.delete(gateId);
459
+ }
460
+
461
+ #scheduleTerminalCancellation(gateId: string): void {
462
+ const presentation = this.presentations.get(gateId);
463
+ if (presentation?.workflowGateId || this.terminalCancellationTimers.has(gateId)) return;
464
+ this.terminalCancellationTimers.set(
465
+ gateId,
466
+ setTimeout(() => {
467
+ this.terminalCancellationTimers.delete(gateId);
468
+ if (this.retries.get(gateId)?.exhausted && this.queue[0] === gateId) {
469
+ logger.warn("interactive_presentation_terminally_cancelled", { gateId });
470
+ this.cancel(gateId, "registration_exhausted");
471
+ }
472
+ }, PresentationArbiter.terminalCancellationDelayMs),
473
+ );
474
+ }
475
+
476
+ #promote(): void {
477
+ this.#observeHead();
478
+ const gateId = this.queue[0];
479
+ const retry = gateId ? this.retries.get(gateId) : undefined;
480
+ if (!this.active && gateId && !this.directControls.has(gateId) && !retry?.exhausted) {
481
+ if (retry && retry.nextAt > Date.now()) this.#scheduleRetry(gateId);
482
+ else this.reissue(gateId);
483
+ }
484
+ }
485
+
486
+ #scheduleRetry(gateId: string): void {
487
+ const retry = this.retries.get(gateId);
488
+ if (!retry || retry.exhausted || this.queue[0] !== gateId) return;
489
+ const generation = this.#observeHead();
490
+ if (this.retryTimer && this.retryTimerGateId === gateId && this.retryTimerGeneration === generation) return;
491
+ if (this.retryTimer) clearTimeout(this.retryTimer);
492
+ this.retryTimerGateId = gateId;
493
+ this.retryTimerGeneration = generation;
494
+ const delay = Math.max(0, retry.nextAt - Date.now());
495
+ this.retryTimer = setTimeout(() => {
496
+ this.retryTimer = undefined;
497
+ const matchesHead = this.queue[0] === gateId && this.#observeHead() === generation;
498
+ this.retryTimerGateId = undefined;
499
+ if (matchesHead) this.reconcile();
500
+ }, delay);
501
+ }
502
+
503
+ /** Revalidates the live endpoint queue head before bounded recovery. */
504
+ reconcile(): void {
505
+ this.#observeHead();
506
+ const gateId = this.queue[0];
507
+ const retry = gateId ? this.retries.get(gateId) : undefined;
508
+ if (
509
+ !gateId ||
510
+ !this.presentations.has(gateId) ||
511
+ this.active ||
512
+ this.directControls.has(gateId) ||
513
+ retry?.exhausted
514
+ )
515
+ return;
516
+ if (retry && retry.nextAt > Date.now()) {
517
+ this.#scheduleRetry(gateId);
518
+ return;
519
+ }
520
+ this.#promote();
521
+ }
522
+
523
+ /** Explicit production recovery for a previously exhausted endpoint queue head. */
524
+ recover(gateId = this.queue[0]): void {
525
+ if (!gateId || this.queue[0] !== gateId || !this.presentations.has(gateId)) return;
526
+ this.retries.delete(gateId);
527
+ this.#clearTerminalCancellation(gateId);
528
+
529
+ this.#observeHead();
530
+ this.reconcile();
531
+ }
532
+
533
+ hasActivePresentation(): boolean {
534
+ return this.active !== undefined;
535
+ }
536
+
537
+ retireForDirectControl(gateId: string): RetireStatus {
538
+ if (!this.active || this.active.gateId !== gateId) return "stale";
539
+ const active = this.active;
540
+ this.directControls.set(gateId, this.queue.indexOf(gateId));
541
+ const status = parseRetireStatus(this.server.retireIfUnclaimed(active).status);
542
+ if (isTerminalProof(status)) {
543
+ this.routes.delete(active.actionId);
544
+ this.active = undefined;
545
+ this.retiredProofs.set(gateId, status);
546
+ return status;
547
+ }
548
+ this.directControls.delete(gateId);
549
+ return status;
550
+ }
551
+
552
+ prepareDirectControl(
553
+ gateId: string,
554
+ ): { status: "retired" | "queued"; ordinal: number } | { status: "claimed" | "stale" } {
555
+ const ordinal = this.queue.indexOf(gateId);
556
+ if (this.active?.gateId === gateId) {
557
+ const status = this.retireForDirectControl(gateId);
558
+ return status === "retired"
559
+ ? { status, ordinal }
560
+ : { status: status === "already_terminal" ? "stale" : status };
561
+ }
562
+ if (!this.presentations.has(gateId) || ordinal < 0 || this.directControls.has(gateId)) return { status: "stale" };
563
+ // Fence the queued entry before awaiting durable resolution; promotion cannot
564
+ // republish it until the control has a known terminal outcome.
565
+ this.directControls.set(gateId, ordinal);
566
+ this.queuedDirectControls.add(gateId);
567
+ return { status: "queued", ordinal };
568
+ }
569
+
570
+ finishDirectControl(
571
+ gateId: string,
572
+ prepared: { status: "retired" | "queued"; ordinal: number },
573
+ outcome: DirectControlOutcome,
574
+ ): void {
575
+ if (outcome === "accepted") {
576
+ this.directControls.delete(gateId);
577
+ this.complete(gateId);
578
+ return;
579
+ }
580
+ if (outcome === "unknown") {
581
+ // A durable/store/advance failure may have committed. Remove local authority
582
+ // rather than minting a fresh action against an uncertain durable state.
583
+ this.directControls.delete(gateId);
584
+ this.complete(gateId);
585
+ logger.warn("workflow_gate_direct_control_uncertain", { gateId });
586
+ return;
587
+ }
588
+ this.directControls.delete(gateId);
589
+ this.queuedDirectControls.delete(gateId);
590
+ this.retiredProofs.delete(gateId);
591
+ if (!this.presentations.has(gateId)) return;
592
+ const current = this.queue.indexOf(gateId);
593
+ if (current >= 0) this.queue.splice(current, 1);
594
+ this.queue.splice(Math.min(prepared.ordinal, this.queue.length), 0, gateId);
595
+ this.reconcile();
596
+ }
597
+
598
+ constructor(
599
+ private readonly server: NotificationServer,
600
+ private readonly redact: () => boolean,
601
+ private readonly tag: string,
602
+ ) {}
603
+
604
+ retain(presentation: UnattendedGatePresentation): void {
605
+ const alreadyPresent = this.presentations.has(presentation.gateId);
606
+ if (!alreadyPresent) this.queue.push(presentation.gateId);
607
+ this.presentations.set(presentation.gateId, presentation);
608
+ // A fresh durable replay is explicit production recovery after transient N-API exhaustion.
609
+ if (alreadyPresent) this.recover(presentation.gateId);
610
+ else this.#promote();
611
+ }
612
+
613
+ routeFor(actionId: string): string | undefined {
614
+ return this.routes.get(actionId);
615
+ }
616
+
617
+ presentationFor(actionId: string): UnattendedGatePresentation | undefined {
618
+ const gateId = this.routes.get(actionId);
619
+ return gateId ? this.presentations.get(gateId) : undefined;
620
+ }
621
+
622
+ /** The native generic claim has already resolved this old action. */
623
+ toggle(actionId: string, label: string): boolean {
624
+ const presentation = this.presentationFor(actionId);
625
+ if (!presentation?.multi || !presentation.options.includes(label)) return false;
626
+ this.routes.delete(actionId);
627
+ if (this.active?.actionId === actionId) this.active = undefined;
628
+ const selected = new Set(presentation.selectedOptions);
629
+ if (selected.has(label)) selected.delete(label);
630
+ else selected.add(label);
631
+ presentation.selectedOptions = [...selected];
632
+ this.reissue(presentation.gateId);
633
+ return true;
634
+ }
635
+
636
+ /** Clears an interactive route only when it is still the route that settled. */
637
+ completeInteractive(gateId: string, actionId: string): void {
638
+ if (this.routes.get(actionId) !== gateId) return;
639
+ this.routes.delete(actionId);
640
+ if (this.active?.actionId === actionId) this.active = undefined;
641
+ for (const routeGateId of this.routes.values()) {
642
+ if (routeGateId === gateId) return;
643
+ }
644
+ const presentation = this.presentations.get(gateId);
645
+ if (!presentation) return;
646
+ this.presentations.delete(gateId);
647
+ this.directControls.delete(gateId);
648
+ this.queuedDirectControls.delete(gateId);
649
+ this.retiredProofs.delete(gateId);
650
+ this.retries.delete(gateId);
651
+ this.#clearTerminalCancellation(gateId);
652
+
653
+ const index = this.queue.indexOf(gateId);
654
+ if (index >= 0) this.queue.splice(index, 1);
655
+ presentation.onClosed?.();
656
+ this.#promote();
657
+ }
658
+
659
+ /** Clears an interactive presentation after its route was retired for direct control. */
660
+ completeDirect(gateId: string): void {
661
+ const presentation = this.presentations.get(gateId);
662
+ if (!presentation) return;
663
+ this.presentations.delete(gateId);
664
+ this.directControls.delete(gateId);
665
+ this.queuedDirectControls.delete(gateId);
666
+ this.retiredProofs.delete(gateId);
667
+ this.retries.delete(gateId);
668
+ this.#clearTerminalCancellation(gateId);
669
+
670
+ const index = this.queue.indexOf(gateId);
671
+ if (index >= 0) this.queue.splice(index, 1);
672
+ presentation.onClosed?.();
673
+ this.#promote();
674
+ }
675
+
676
+ /** Cancelling the source revokes every interactive route for its presentation. */
677
+ #discardInteractive(gateId: string): void {
678
+ const active = this.active;
679
+ if (active?.gateId === gateId) {
680
+ try {
681
+ this.server.retireIfUnclaimed(active);
682
+ } catch (error) {
683
+ logger.warn(`notifications: interactive route retirement failed: ${String(error)}`);
684
+ }
685
+ }
686
+ for (const [actionId, routeGateId] of this.routes) {
687
+ if (routeGateId !== gateId) continue;
688
+ this.routes.delete(actionId);
689
+ if (this.active?.actionId === actionId) this.active = undefined;
690
+ }
691
+ const presentation = this.presentations.get(gateId);
692
+ if (!presentation) return;
693
+ this.presentations.delete(gateId);
694
+ this.directControls.delete(gateId);
695
+ this.queuedDirectControls.delete(gateId);
696
+ this.retiredProofs.delete(gateId);
697
+ this.retries.delete(gateId);
698
+ this.#clearTerminalCancellation(gateId);
699
+
700
+ const index = this.queue.indexOf(gateId);
701
+ if (index >= 0) this.queue.splice(index, 1);
702
+ presentation.onClosed?.();
703
+ this.#promote();
704
+ }
705
+
706
+ reissueAfterFailure(actionId: string): void {
707
+ const gateId = this.routes.get(actionId);
708
+ if (!gateId) return;
709
+ this.routes.delete(actionId);
710
+ if (this.active?.actionId === actionId) this.active = undefined;
711
+ this.reconcile();
712
+ }
713
+
714
+ reissue(gateId: string): string | undefined {
715
+ const presentation = this.presentations.get(gateId);
716
+ if (!presentation || this.directControls.has(gateId) || this.active) return undefined;
717
+ const actionId = `${presentation.workflowGateId ? "gate-interaction" : "ask"}:${crypto.randomUUID()}`;
718
+ this.routes.set(actionId, gateId);
719
+ try {
720
+ const lease = this.server.registerArbitratedAsk(
721
+ JSON.stringify(
722
+ notificationActionPayload(
723
+ {
724
+ type: "action_needed",
725
+ id: actionId,
726
+ kind: "ask",
727
+ sessionId: presentation.sessionId,
728
+ ...(presentation.workflowGateId ? { workflowGateId: presentation.workflowGateId } : {}),
729
+ question:
730
+ presentation.selectedOptions.length > 0
731
+ ? `(${presentation.selectedOptions.length} selected) ${presentation.question}`
732
+ : presentation.question,
733
+ options: presentation.options,
734
+ controls: presentation.multi
735
+ ? [
736
+ {
737
+ id: "navigation_forward",
738
+ kind: "navigation",
739
+ label: presentation.navigationLabel ?? "Done",
740
+ enabled: presentation.allowEmpty || presentation.selectedOptions.length > 0,
741
+ },
742
+ ]
743
+ : presentation.controls,
744
+ },
745
+ { redact: this.redact(), sessionTag: this.tag },
746
+ ),
747
+ ),
748
+ true,
749
+ );
750
+ if (lease.actionId !== actionId) throw new Error("native arbitrated action id mismatch");
751
+ this.active = { actionId, gateId, registrationEpoch: lease.registrationEpoch };
752
+ this.retries.delete(gateId);
753
+ presentation.onActivated?.(actionId, lease);
754
+ return actionId;
755
+ } catch {
756
+ this.routes.delete(actionId);
757
+ const previous = this.retries.get(gateId);
758
+ const attempts = (previous?.attempts ?? 0) + 1;
759
+ const exhausted = attempts >= PresentationArbiter.maxRegistrationAttempts;
760
+ const delay = Math.min(
761
+ PresentationArbiter.retryMaxDelayMs,
762
+ PresentationArbiter.retryBaseDelayMs * 2 ** (attempts - 1),
763
+ );
764
+ this.retries.set(gateId, { attempts, exhausted, nextAt: Date.now() + delay });
765
+ logger.warn("workflow_gate_presentation_retry", {
766
+ gateId,
767
+ attempts,
768
+ maxAttempts: PresentationArbiter.maxRegistrationAttempts,
769
+ exhausted,
770
+ delayMs: exhausted ? undefined : delay,
771
+ });
772
+ // Exhaustion fences this queue head. Ordinary asks then terminally cancel
773
+ // through the same cancellation path so their caller cannot wait forever;
774
+ // durable workflow gates remain fenced for explicit recovery or cancellation.
775
+ if (exhausted) this.#scheduleTerminalCancellation(gateId);
776
+ else this.#scheduleRetry(gateId);
777
+ return undefined;
778
+ }
779
+ }
780
+
781
+ closeInteraction(actionId: string, reason: string): boolean {
782
+ const gateId = this.routes.get(actionId);
783
+ const active = this.active;
784
+ if (!gateId || !active || active.actionId !== actionId) {
785
+ if (gateId) this.directControls.set(gateId, this.queue.indexOf(gateId));
786
+ logger.error(`notifications: terminalize ${actionId} lacks an exact active lease`);
787
+ return false;
788
+ }
789
+ const status = parseRetireStatus(this.server.retireIfUnclaimed(active).status);
790
+ if (isTerminalProof(status)) {
791
+ this.routes.delete(actionId);
792
+ this.active = undefined;
793
+ void reason;
794
+ return true;
795
+ }
796
+ this.directControls.set(gateId, this.queue.indexOf(gateId));
797
+ logger.error(`notifications: terminalize ${actionId} returned ${status}`);
798
+ return false;
799
+ }
800
+
801
+ complete(gateId: string): WorkflowGateTerminalProof {
802
+ let proof = this.retiredProofs.get(gateId);
803
+ for (const [actionId, routeGateId] of this.routes) {
804
+ if (routeGateId !== gateId) continue;
805
+ if (!this.closeInteraction(actionId, "gate_complete"))
806
+ throw new Error(`workflow gate ${gateId} presentation lacks exact terminal proof`);
807
+ proof = "retired";
808
+ }
809
+ const presentation = this.presentations.get(gateId);
810
+ if (!proof && this.queuedDirectControls.has(gateId)) proof = "not_published";
811
+ if (!proof && presentation)
812
+ throw new Error(`workflow gate ${gateId} presentation lacks an active terminal lease`);
813
+ this.presentations.delete(gateId);
814
+ this.directControls.delete(gateId);
815
+ this.queuedDirectControls.delete(gateId);
816
+ this.retiredProofs.delete(gateId);
817
+ this.retries.delete(gateId);
818
+ this.#clearTerminalCancellation(gateId);
819
+
820
+ const index = this.queue.indexOf(gateId);
821
+ if (index >= 0) this.queue.splice(index, 1);
822
+ presentation?.onClosed?.();
823
+ this.#promote();
824
+ return proof ?? "already_terminal";
825
+ }
826
+
827
+ cancelInteractive(): void {
828
+ for (const [gateId, presentation] of this.presentations) {
829
+ if (!presentation.workflowGateId) this.#discardInteractive(gateId);
830
+ }
831
+ }
832
+
833
+ cancel(gateId: string, reason: string): void {
834
+ this.#discardInteractive(gateId);
835
+ void reason;
836
+ }
837
+
838
+ dispose(): void {
839
+ for (const gateId of [...this.presentations.keys()]) this.cancel(gateId, "session_shutdown");
840
+ }
841
+ }
842
+
843
+ interface SessionRuntime {
844
+ server: NotificationServer;
845
+ host: SessionSdkHost;
846
+ /** Owns stateRoot-backed revisions and removes their spills on terminal shutdown. */
847
+ revisions: RevisionStore;
848
+ /** Releases all snapshot pins before the revision store is closed. */
849
+ cursors: CursorRegistry;
850
+ /** Current endpoint session identity; never re-key an existing host across a switch. */
851
+ id: string;
852
+ idleSeq: number;
853
+ /** Interactive asks awaiting a remote answer, by action id. */
854
+ pendingInteractive: Map<string, PendingInteractiveAsk>;
855
+ /** Deregisters this session's ask answer source. */
856
+ disposeAnswerSource: () => void;
857
+ /** Deregisters this session's Telegram file sink. */
858
+ disposeFileSink: () => void;
859
+ /** Deregisters this session's workflow-gate listener. */
860
+ disposeGateListener: () => void;
861
+ /** Whether notification-only delivery and answer resources are active. */
862
+ notificationsActive: boolean;
863
+ /** Set as soon as terminal teardown is requested, before startup settles. */
864
+ stopping: boolean;
865
+ /** Recreates notification-only resources after `/notify on`. */
866
+ enableNotifications: () => void;
867
+ /** Deregisters canonical workflow-gate terminal cleanup. */
868
+ disposeGateTerminalController: () => void;
869
+ disposeAckRecoveryParticipant: () => void;
870
+ disposeGateEmitterListener: () => void;
871
+ waitForGateResolutionQuiescence: () => Promise<void>;
872
+ workflowGate?: WorkflowGateEmitter;
873
+ gatePresentations?: PresentationArbiter;
874
+ redact: boolean;
875
+ /** True only after the exact host generation was registered with the broker index. */
876
+ brokerRegistrationActive: boolean;
877
+ verbosity: "lean" | "verbose";
878
+ sessionTag: string;
879
+ /** Whether the agent loop is currently running (drives the typing indicator). */
880
+ busy: boolean;
881
+ /** Prompt command/turn identities awaiting their corresponding agent_start. */
882
+ pendingPromptCorrelations: Array<{ commandId: string; turnId: string }>;
883
+ /** Identity bound to the agent lifecycle currently in flight. */
884
+ activePromptCorrelation?: { commandId: string; turnId: string };
885
+ /** Records a correlated prompt terminal boundary after agent unwind. */
886
+ /** Atomically claims a correlated prompt terminal boundary after agent unwind. */
887
+ recordPromptTerminal: (correlation: { commandId: string; turnId: string } | undefined) => boolean;
888
+ /** Records correlated lifecycle frames for replay and delivers them only to the accepted requester after acknowledgement. */
889
+ emitPromptLifecycle: (
890
+ correlation: { commandId: string; turnId: string } | undefined,
891
+ frame:
892
+ | { type: "agent_start" | "agent_end"; sessionId: string; commandId?: string; turnId?: string }
893
+ | {
894
+ type: "agent_failed";
895
+ sessionId: string;
896
+ commandId: string;
897
+ turnId: string;
898
+ error: { code: string; message: string };
899
+ },
900
+ ) => void;
901
+ /** Inbound Telegram update ids injected but not yet consumed by a turn. */
902
+ pendingInbound: Set<number>;
903
+ /** Latest assistant text of the in-flight turn (from message_update). */
904
+ currentTurnText?: string;
905
+ /** Assistant text already flushed before an ask this turn (turn-scoped dedupe
906
+ * so turn_end does not re-emit the pre-ask lead-in). Reset each turn. */
907
+ preAskFlushedText?: string;
908
+ /** Live streaming: opt-in flag, monotonic per-turn ref, and emit throttle state. */
909
+ stream: boolean;
910
+ turnSeq?: number;
911
+ liveRef?: string;
912
+ lastLiveAt?: number;
913
+ lastLiveText?: string;
914
+ /** True between turn_end and the next turn_start: drops late async message_update
915
+ * frames so a stale live edit can never be emitted after the finalized turn. */
916
+ turnClosed?: boolean;
917
+ /** Cancels the postmortem cleanup that emits `session_closed` on process teardown. */
918
+ cancelPostmortemCleanup: () => void;
919
+ /** Stops optional broker presence heartbeats. */
920
+ stopBrokerHeartbeat: () => void;
921
+ }
922
+
923
+ /** Request-local requester authority for stable ControlSurface dispatches. */
924
+ const controlRequesterContext = new AsyncLocalStorage<string>();
925
+ type SessionStartStatus = "started" | "already" | "disabled" | "failed";
926
+ type SessionStartResult = {
927
+ status: SessionStartStatus;
928
+ runtime?: SessionRuntime;
929
+ failure?: SdkStartupFailure;
930
+ };
931
+
932
+ function pushSessionFrame(
933
+ runtime: Pick<SessionRuntime, "server" | "host">,
934
+ frame: { type: string; [key: string]: unknown },
935
+ ): void {
936
+ runtime.host.emitEvent({ kind: frame.type, payload: frame });
937
+ runtime.server.pushFrame(JSON.stringify(frame));
938
+ }
939
+
940
+ /** Agent lifecycle is SDK session truth, independent of optional chat delivery. */
941
+ function emitAgentLifecycle(
942
+ runtime: Pick<SessionRuntime, "server" | "host">,
943
+ frame: { type: "agent_start" | "agent_end"; sessionId: string; commandId?: string; turnId?: string },
944
+ ): void {
945
+ try {
946
+ const json = JSON.stringify(frame);
947
+ runtime.host.emitEvent({ kind: frame.type, payload: frame });
948
+ runtime.server.pushFrame(json);
949
+ } catch (error) {
950
+ logger.warn(`sdk: lifecycle delivery failed: ${String(error)}`);
951
+ }
952
+ }
953
+
954
+ interface ResolvedSettings {
955
+ settings: Settings | undefined;
956
+ cfg: NotificationConfig;
957
+ settingsAvailable: boolean;
958
+ }
959
+
960
+ const TELEGRAM_FILE_REDACTION_ERROR = "Telegram file attachments are disabled while notifications redaction is on.";
961
+
962
+ const defaultConfig: NotificationConfig = {
963
+ enabled: false,
964
+ botToken: undefined,
965
+ chatId: undefined,
966
+ discord: {
967
+ botToken: undefined,
968
+ },
969
+ slack: {
970
+ botToken: undefined,
971
+ channelId: undefined,
972
+ },
973
+ redact: false,
974
+ verbosity: "lean",
975
+ sessionScope: "all",
976
+ idleTimeoutMs: 60_000,
977
+ rich: { enabled: true },
978
+ richDraft: { enabled: false },
979
+ topics: {},
980
+ };
981
+
982
+ export function notificationsEnabled(): boolean {
983
+ return process.env.GJC_NOTIFICATIONS === "1" || Boolean(process.env.GJC_NOTIFICATIONS_TOKEN);
984
+ }
985
+
986
+ // Live streaming (opt-in): emit throttled non-finalized `turn_stream` frames as
987
+ // the assistant message streams so remote clients can edit ONE message live. The
988
+ // finalized frame (turn_end) carries the same messageRef and stays authoritative,
989
+ // so a dropped live frame self-heals. Off unless GJC_NOTIFICATIONS_STREAM=1.
990
+ function streamingEnabled(): boolean {
991
+ return process.env.GJC_NOTIFICATIONS_STREAM === "1";
992
+ }
993
+ function streamIntervalMs(): number {
994
+ return Math.max(200, Number(process.env.GJC_NOTIFICATIONS_STREAM_INTERVAL_MS) || 500);
995
+ }
996
+ // Max chars of a turn's assistant text carried by the FINALIZED turn_stream (and
997
+ // the pre-ask capture). Finalized turns default to the bounded full-turn ceiling
998
+ // because split-capable clients such as the Telegram daemon schedule each
999
+ // splitTelegramHtml chunk through the shared rate-limit pool. Operators who want
1000
+ // glanceable summaries can lower this with GJC_NOTIFICATIONS_TURN_MAX. The value
1001
+ // is always clamped to a finite [280, TURN_TEXT_MAX_CEILING] range so the cap can
1002
+ // never be unbounded. Live frames are intentionally NOT raised — they stay one
1003
+ // editable preview message rather than fanning a long in-progress turn across
1004
+ // sends.
1005
+ const TURN_TEXT_MAX_CEILING = 40_000;
1006
+ function turnTextMax(): number {
1007
+ const raw = Number(process.env.GJC_NOTIFICATIONS_TURN_MAX);
1008
+ if (!Number.isFinite(raw) || raw <= 0) return TURN_TEXT_MAX_CEILING;
1009
+ return Math.min(TURN_TEXT_MAX_CEILING, Math.max(280, raw));
1010
+ }
1011
+ function resolveNotificationConfig(settings: Settings): NotificationConfig {
1012
+ const reader = settings as Partial<NotificationSettingsReader>;
1013
+ return typeof reader.getNotificationSettingsSnapshot === "function"
1014
+ ? getNotificationConfig(reader as NotificationSettingsReader)
1015
+ : defaultConfig;
1016
+ }
1017
+
1018
+ function resolveSettings(settingsOverride?: Settings): ResolvedSettings {
1019
+ if (settingsOverride)
1020
+ return { settings: settingsOverride, cfg: resolveNotificationConfig(settingsOverride), settingsAvailable: true };
1021
+ try {
1022
+ const settings = Settings.instance;
1023
+ return { settings, cfg: getNotificationConfig(settings), settingsAvailable: true };
1024
+ } catch {
1025
+ return { settings: undefined, cfg: defaultConfig, settingsAvailable: false };
1026
+ }
1027
+ }
1028
+
1029
+ function resolveToken(): string {
1030
+ // `GJC_NOTIFICATIONS_TOKEN` remains an enablement compatibility flag, never
1031
+ // a reusable endpoint credential. Every host identity gets fresh authority.
1032
+ return crypto.randomBytes(24).toString("base64url");
1033
+ }
1034
+
1035
+ function parseAnswer(answerJson: string): unknown {
1036
+ try {
1037
+ return JSON.parse(answerJson);
1038
+ } catch {
1039
+ return answerJson;
1040
+ }
1041
+ }
1042
+
1043
+ /** Map a client answer to the option LABEL the local UI would return (or free text). */
1044
+ function mapAnswerToLabel(answerJson: string, options: string[]): string | undefined {
1045
+ const answer = parseAnswer(answerJson);
1046
+ if (typeof answer === "number") return options[answer];
1047
+ if (typeof answer === "string") return answer;
1048
+ if (answer && typeof answer === "object") {
1049
+ const sel = (answer as { selected?: unknown; custom?: unknown }).selected;
1050
+ if (Array.isArray(sel) && sel.length > 0) {
1051
+ const first = sel[0];
1052
+ return typeof first === "number" ? options[first] : String(first);
1053
+ }
1054
+ const custom = (answer as { custom?: unknown }).custom;
1055
+ if (typeof custom === "string") return custom;
1056
+ }
1057
+ return undefined;
1058
+ }
1059
+
1060
+ /** Workflow-gate answer shape. */
1061
+ interface GateAnswer {
1062
+ selected: string[];
1063
+ other?: boolean;
1064
+ custom?: string;
1065
+ }
1066
+
1067
+ /**
1068
+ * Discriminated result of mapping a client answer to a workflow-gate answer.
1069
+ * `ok: false` means the reply is invalid and the caller must close the exact
1070
+ * claim/receipt and reissue the interaction rather than durably accepting it.
1071
+ */
1072
+ type GateAnswerResult = { ok: true; answer: GateAnswer } | { ok: false; reason: string };
1073
+
1074
+ /**
1075
+ * Map a client answer to the workflow-gate answer shape.
1076
+ *
1077
+ * The protocol defines a numeric reply as an option index, so a number outside
1078
+ * `options` is invalid: it must NOT be converted into free text that passes the
1079
+ * ask schema and triggers a misleading success acknowledgement.
1080
+ * Only JSON strings enter the free-text/Other path.
1081
+ */
1082
+ export function mapAnswerToGate(answerJson: string, options: string[]): GateAnswerResult {
1083
+ const answer = parseAnswer(answerJson);
1084
+ if (typeof answer === "number") {
1085
+ const label = options[answer];
1086
+ return label === undefined
1087
+ ? { ok: false, reason: "numeric_selector_out_of_range" }
1088
+ : { ok: true, answer: { selected: [label] } };
1089
+ }
1090
+ if (typeof answer === "string") {
1091
+ return {
1092
+ ok: true,
1093
+ answer: options.includes(answer) ? { selected: [answer] } : { selected: [], other: true, custom: answer },
1094
+ };
1095
+ }
1096
+ if (answer && typeof answer === "object") {
1097
+ const obj = answer as { selected?: unknown; custom?: unknown };
1098
+ const selected = Array.isArray(obj.selected)
1099
+ ? obj.selected.map(s => (typeof s === "number" ? (options[s] ?? String(s)) : String(s)))
1100
+ : [];
1101
+ const custom = typeof obj.custom === "string" ? obj.custom : undefined;
1102
+ return { ok: true, answer: { selected, other: custom !== undefined, custom } };
1103
+ }
1104
+ return { ok: true, answer: { selected: [] } };
1105
+ }
1106
+
1107
+ interface NotificationControlCommandPayload {
1108
+ name?: unknown;
1109
+ action?: unknown;
1110
+ level?: unknown;
1111
+ global?: unknown;
1112
+ selector?: unknown;
1113
+ instructions?: unknown;
1114
+ }
1115
+
1116
+ export interface NotificationControlCommandResult {
1117
+ status: "ok" | "error" | "unavailable";
1118
+ message: string;
1119
+ modelChoices?: Array<{ selector: string; label: string }>;
1120
+ }
1121
+
1122
+ function parseControlCommandPayload(json: string | undefined): NotificationControlCommandPayload | undefined {
1123
+ if (!json) return undefined;
1124
+ try {
1125
+ const parsed = JSON.parse(json) as unknown;
1126
+ return parsed && typeof parsed === "object" ? (parsed as NotificationControlCommandPayload) : undefined;
1127
+ } catch {
1128
+ return undefined;
1129
+ }
1130
+ }
1131
+
1132
+ function formatCompactTokenCount(value: number | null | undefined): string {
1133
+ if (value == null) return "unknown";
1134
+ if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1))}m`;
1135
+ if (value >= 1_000) return `${Number((value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1))}k`;
1136
+ return value.toLocaleString();
1137
+ }
1138
+
1139
+ function formatContextUsageLine(ctx: ExtensionContext): string {
1140
+ const usage = ctx.getContextUsage();
1141
+ if (!usage) return "Context usage unavailable.";
1142
+ const tokens = formatCompactTokenCount(usage.tokens);
1143
+ const window = formatCompactTokenCount(usage.contextWindow);
1144
+ const pct = usage.percent == null ? "unknown" : `${usage.percent.toFixed(1)}%`;
1145
+ return `Context: ${tokens}/${window} ${pct}`;
1146
+ }
1147
+
1148
+ function formatLocalUsage(ctx: ExtensionContext): string {
1149
+ const stats = ctx.sessionManager.getUsageStatistics();
1150
+ return [
1151
+ "Usage",
1152
+ `Input tokens: ${stats.input}`,
1153
+ `Output tokens: ${stats.output}`,
1154
+ `Cache read tokens: ${stats.cacheRead}`,
1155
+ `Cache write tokens: ${stats.cacheWrite}`,
1156
+ `Premium requests: ${stats.premiumRequests}`,
1157
+ `Cost: $${stats.cost.toFixed(6)}`,
1158
+ ].join("\n");
1159
+ }
1160
+
1161
+ interface SafeUsageWindow {
1162
+ kind: "5h" | "7d";
1163
+ usedFraction?: number;
1164
+ resetsAt?: number;
1165
+ }
1166
+
1167
+ function isRecord(value: unknown): value is Record<string, unknown> {
1168
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1169
+ }
1170
+
1171
+ function classifyUsageWindow(limit: Record<string, unknown>): "5h" | "7d" | undefined {
1172
+ const window = isRecord(limit.window) ? limit.window : undefined;
1173
+ const scope = isRecord(limit.scope) ? limit.scope : undefined;
1174
+ const ids = [window?.id, scope?.windowId, limit.id];
1175
+ for (const id of ids) {
1176
+ if (typeof id !== "string") continue;
1177
+ const normalized = id.toLowerCase();
1178
+ if (normalized === "5h" || normalized === "7d") return normalized;
1179
+ }
1180
+ const durationMs = window?.durationMs;
1181
+ if (typeof durationMs !== "number" || !Number.isFinite(durationMs)) return undefined;
1182
+ if (Math.abs(durationMs - 5 * 60 * 60_000) <= 30 * 60_000) return "5h";
1183
+ if (Math.abs(durationMs - 7 * 24 * 60 * 60_000) <= 12 * 60 * 60_000) return "7d";
1184
+ return undefined;
1185
+ }
1186
+
1187
+ function getUsageUsedFraction(amount: Record<string, unknown> | undefined): number | undefined {
1188
+ if (!amount) return undefined;
1189
+ const usedFraction = amount.usedFraction;
1190
+ if (typeof usedFraction === "number" && Number.isFinite(usedFraction)) return usedFraction;
1191
+ const used = amount.used;
1192
+ if (typeof used !== "number" || !Number.isFinite(used)) return undefined;
1193
+ if (amount.unit === "percent") return used / 100;
1194
+ const limit = amount.limit;
1195
+ return typeof limit === "number" && Number.isFinite(limit) && limit !== 0 ? used / limit : undefined;
1196
+ }
1197
+
1198
+ function formatStableResetTime(value: number): string | undefined {
1199
+ if (!Number.isFinite(value)) return undefined;
1200
+ try {
1201
+ return new Date(value)
1202
+ .toISOString()
1203
+ .replace("T", " ")
1204
+ .replace(/\.\d{3}Z$/, " UTC");
1205
+ } catch {
1206
+ return undefined;
1207
+ }
1208
+ }
1209
+
1210
+ function shouldReplaceUsageWindow(current: SafeUsageWindow, candidate: SafeUsageWindow): boolean {
1211
+ if (candidate.usedFraction !== undefined) {
1212
+ if (current.usedFraction === undefined || candidate.usedFraction > current.usedFraction) return true;
1213
+ if (candidate.usedFraction < current.usedFraction) return false;
1214
+ }
1215
+ if (current.usedFraction !== undefined && candidate.usedFraction === undefined) return false;
1216
+ if (candidate.resetsAt === undefined) return false;
1217
+ return current.resetsAt === undefined || candidate.resetsAt < current.resetsAt;
1218
+ }
1219
+
1220
+ function formatRemoteUsageWindows(reports: unknown): string[] {
1221
+ if (!Array.isArray(reports)) return [];
1222
+ const windows = new Map<SafeUsageWindow["kind"], SafeUsageWindow>();
1223
+ for (const report of reports) {
1224
+ if (!isRecord(report) || !Array.isArray(report.limits)) continue;
1225
+ for (const value of report.limits) {
1226
+ if (!isRecord(value)) continue;
1227
+ const kind = classifyUsageWindow(value);
1228
+ if (!kind) continue;
1229
+ const window = isRecord(value.window) ? value.window : undefined;
1230
+ const amount = isRecord(value.amount) ? value.amount : undefined;
1231
+ const usedFraction = getUsageUsedFraction(amount);
1232
+ const resetsAt = window?.resetsAt;
1233
+ const candidate: SafeUsageWindow = {
1234
+ kind,
1235
+ ...(typeof usedFraction === "number" && Number.isFinite(usedFraction) ? { usedFraction } : {}),
1236
+ ...(typeof resetsAt === "number" && Number.isFinite(resetsAt) ? { resetsAt } : {}),
1237
+ };
1238
+ const current = windows.get(kind);
1239
+ if (!current || shouldReplaceUsageWindow(current, candidate)) windows.set(kind, candidate);
1240
+ }
1241
+ }
1242
+ return (["5h", "7d"] as const).flatMap(kind => {
1243
+ const window = windows.get(kind);
1244
+ if (!window) return [];
1245
+ const details = [kind === "5h" ? "5-hour limit" : "Weekly limit"];
1246
+ if (window.usedFraction !== undefined) details.push(`${Number((window.usedFraction * 100).toFixed(1))}% used`);
1247
+ const resetTime = window.resetsAt === undefined ? undefined : formatStableResetTime(window.resetsAt);
1248
+ if (resetTime) details.push(`resets ${resetTime}`);
1249
+ return [details.join(" — ")];
1250
+ });
1251
+ }
1252
+
1253
+ async function formatUsage(ctx: ExtensionContext, api: ExtensionAPI): Promise<string> {
1254
+ const local = formatLocalUsage(ctx);
1255
+ try {
1256
+ const windows = formatRemoteUsageWindows(await api.fetchUsageReportsForControl());
1257
+ return windows.length > 0 ? `${local}\n\nUsage windows\n${windows.join("\n")}` : local;
1258
+ } catch {
1259
+ logger.warn("notifications: usage report fetch failed");
1260
+ return local;
1261
+ }
1262
+ }
1263
+
1264
+ function formatReasoningSettings(api: ExtensionAPI): string {
1265
+ const level = api.getThinkingLevel() ?? ThinkingLevel.Off;
1266
+ const display = api.getThinkingVisibility() === "visible" ? "on" : "off";
1267
+ return [
1268
+ "🧠 Reasoning Settings",
1269
+ `Effort: ${level}`,
1270
+ `Scope: ${api.getThinkingScopeForControl()}`,
1271
+ `Display: ${display}`,
1272
+ telegramControlCommandUsage("reasoning"),
1273
+ ].join("\n");
1274
+ }
1275
+
1276
+ const TELEGRAM_MODEL_CHOICE_LIMIT = 40;
1277
+
1278
+ function getModelChoices(ctx: ExtensionContext): Array<{ selector: string; label: string }> {
1279
+ const choices = new Map<string, { selector: string; label: string }>();
1280
+ for (const model of ctx.modelRegistry.getAvailable()) {
1281
+ const selector = `${model.provider}/${model.id}`;
1282
+ if (!choices.has(selector)) {
1283
+ choices.set(selector, { selector, label: selector.replace(/[\u0000-\u001F\u007F]/g, " ") });
1284
+ }
1285
+ }
1286
+ return [...choices.values()]
1287
+ .sort((left, right) => left.selector.localeCompare(right.selector))
1288
+ .slice(0, TELEGRAM_MODEL_CHOICE_LIMIT);
1289
+ }
1290
+
1291
+ const CONTROL_COMMAND_FAILURE_MESSAGE = "Control command failed.";
1292
+ const STALE_MODEL_BUTTON_MESSAGE = "Button is stale. Run /model again.";
1293
+
1294
+ export async function executeNotificationControlCommand(
1295
+ command: NotificationControlCommandPayload | undefined,
1296
+ ctx: ExtensionContext,
1297
+ api: ExtensionAPI,
1298
+ expectedSessionId?: string,
1299
+ ): Promise<NotificationControlCommandResult> {
1300
+ try {
1301
+ return await executeNotificationControlCommandUnchecked(command, ctx, api, expectedSessionId);
1302
+ } catch {
1303
+ logger.warn("notifications: control command failed");
1304
+ return { status: "error", message: CONTROL_COMMAND_FAILURE_MESSAGE };
1305
+ }
1306
+ }
1307
+
1308
+ async function executeNotificationControlCommandUnchecked(
1309
+ command: NotificationControlCommandPayload | undefined,
1310
+ ctx: ExtensionContext,
1311
+ api: ExtensionAPI,
1312
+ expectedSessionId?: string,
1313
+ ): Promise<NotificationControlCommandResult> {
1314
+ if (!command || typeof command.name !== "string") return { status: "error", message: "Invalid control command." };
1315
+ switch (command.name) {
1316
+ case "reasoning": {
1317
+ const global = command.global === true;
1318
+ if (command.action === "status") return { status: "ok", message: formatReasoningSettings(api) };
1319
+ if (command.action === "cycle") {
1320
+ const next = api.cycleThinkingLevel();
1321
+ return next
1322
+ ? { status: "ok", message: formatReasoningSettings(api) }
1323
+ : { status: "unavailable", message: "Reasoning effort unavailable for this session." };
1324
+ }
1325
+ if (command.action === "set" && typeof command.level === "string") {
1326
+ const requestedLevel = command.level.toLowerCase();
1327
+ const level = requestedLevel === "none" ? "off" : requestedLevel === "reset" ? "inherit" : requestedLevel;
1328
+ const parsed = parseThinkingLevel(level);
1329
+ if (!parsed) return { status: "error", message: "Invalid reasoning effort." };
1330
+ await api.setThinkingLevelForControl(parsed, global);
1331
+ return { status: "ok", message: formatReasoningSettings(api) };
1332
+ }
1333
+ if (command.action === "show" || command.action === "hide") {
1334
+ await api.setThinkingVisibilityForControl(command.action === "show" ? "visible" : "hidden", global);
1335
+ return { status: "ok", message: formatReasoningSettings(api) };
1336
+ }
1337
+ return { status: "error", message: "Invalid reasoning command." };
1338
+ }
1339
+ case "usage":
1340
+ return { status: "ok", message: await formatUsage(ctx, api) };
1341
+ case "context":
1342
+ return { status: "ok", message: formatContextUsageLine(ctx) };
1343
+ case "model": {
1344
+ const choices = getModelChoices(ctx);
1345
+ if (command.action === "list") {
1346
+ return choices.length > 0
1347
+ ? { status: "ok", message: "Select a model.", modelChoices: choices }
1348
+ : { status: "unavailable", message: "No models are available for this session." };
1349
+ }
1350
+ if (command.action !== "set" || typeof command.selector !== "string") {
1351
+ return { status: "error", message: "Invalid model selection." };
1352
+ }
1353
+ const model = ctx.modelRegistry
1354
+ .getAvailable()
1355
+ .find(candidate => `${candidate.provider}/${candidate.id}` === command.selector);
1356
+ if (!model) return { status: "error", message: "Invalid model selection." };
1357
+ if (!(await api.setModelTemporaryForControl(model, expectedSessionId)))
1358
+ return { status: "unavailable", message: "Model unavailable for this session." };
1359
+ return { status: "ok", message: `Model set to ${command.selector}.` };
1360
+ }
1361
+ case "compact": {
1362
+ const before = ctx.getContextUsage()?.tokens;
1363
+ await ctx.compact(typeof command.instructions === "string" ? command.instructions : undefined);
1364
+ const after = ctx.getContextUsage()?.tokens;
1365
+ if (before != null && after != null)
1366
+ return {
1367
+ status: "ok",
1368
+ message: `Compaction complete. Tokens: ${before} -> ${after} (saved ${before - after}).`,
1369
+ };
1370
+ return { status: "ok", message: "Compaction complete." };
1371
+ }
1372
+ default:
1373
+ return { status: "error", message: "Unknown control command." };
1374
+ }
1375
+ }
1376
+
1377
+ function selectedAckOutcome(value: { status: string; messageId?: number; reason?: string }): AskSelectedAckOutcome {
1378
+ if (value.status === "delivered" && typeof value.messageId === "number") {
1379
+ return { status: "delivered", messageId: value.messageId };
1380
+ }
1381
+ if (value.status === "failed") {
1382
+ switch (value.reason) {
1383
+ case "unsupported":
1384
+ case "no_participant":
1385
+ case "ambiguous_participant":
1386
+ case "route_missing":
1387
+ case "expired":
1388
+ case "cancelled":
1389
+ case "telegram_rejected":
1390
+ case "session_closed":
1391
+ return { status: "failed", reason: value.reason };
1392
+ default:
1393
+ return { status: "failed", reason: "session_closed" };
1394
+ }
1395
+ }
1396
+ switch (value.reason) {
1397
+ case "transport_ambiguous":
1398
+ case "origin_disconnected":
1399
+ case "host_timeout":
1400
+ case "shutdown":
1401
+ return { status: "unknown", reason: value.reason };
1402
+ default:
1403
+ return { status: "unknown", reason: "host_timeout" };
1404
+ }
1405
+ }
1406
+
1407
+ async function requestLiveSelectedAck(
1408
+ native: {
1409
+ requestAskSelectedAck(
1410
+ replyReceiptId: string,
1411
+ requestJson: string,
1412
+ ): Promise<{ status: string; messageId?: number; reason?: string }>;
1413
+ },
1414
+ input: { replyReceiptId: string; actionId: string; commitKey: string; deadlineAt: number },
1415
+ ): Promise<AskSelectedAckOutcome> {
1416
+ const requestId = `ack:${crypto.randomUUID()}`;
1417
+ try {
1418
+ return selectedAckOutcome(
1419
+ await native.requestAskSelectedAck(
1420
+ input.replyReceiptId,
1421
+ JSON.stringify({
1422
+ mode: "live",
1423
+ requestId,
1424
+ commitKey: input.commitKey,
1425
+ actionId: input.actionId,
1426
+ deadlineAt: input.deadlineAt,
1427
+ }),
1428
+ ),
1429
+ );
1430
+ } catch (error) {
1431
+ logger.warn(`notifications: Selected acknowledgement failed: ${String(error)}`);
1432
+ return { status: "unknown", reason: "host_timeout" };
1433
+ }
1434
+ }
1435
+
1436
+ async function requestRecoveredSelectedAck(
1437
+ native: {
1438
+ requestRecoveredAskSelectedAck(
1439
+ requestJson: string,
1440
+ ): Promise<{ status: string; messageId?: number; reason?: string }>;
1441
+ },
1442
+ input: { sessionId: string; actionId: string; commitKey: string; deadlineAt: number },
1443
+ ): Promise<AskSelectedAckOutcome> {
1444
+ try {
1445
+ return selectedAckOutcome(
1446
+ await native.requestRecoveredAskSelectedAck(
1447
+ JSON.stringify({
1448
+ mode: "recovery",
1449
+ requestId: `ack:${crypto.randomUUID()}`,
1450
+ commitKey: input.commitKey,
1451
+ sessionId: input.sessionId,
1452
+ actionId: input.actionId,
1453
+ deadlineAt: input.deadlineAt,
1454
+ }),
1455
+ ),
1456
+ );
1457
+ } catch (error) {
1458
+ logger.warn(`notifications: recovered Selected acknowledgement failed: ${String(error)}`);
1459
+ return { status: "unknown", reason: "host_timeout" };
1460
+ }
1461
+ }
1462
+
1463
+ /** Register the interactive `ask` answer source for a session (the ask tool
1464
+ * races the local UI against a remote reply). Returns the deregister disposer. */
1465
+ function registerInteractiveAnswerSource(
1466
+ id: string,
1467
+ pendingInteractive: Map<string, PendingInteractiveAsk>,
1468
+ presentationArbiter: PresentationArbiter,
1469
+ ): () => void {
1470
+ return registerAskAnswerSource(id, {
1471
+ awaitAnswer(question, options, signal) {
1472
+ const result = this.awaitAnswerRequest?.({ question, options, interaction: "selector", controls: [] }, signal);
1473
+ if (!result) return Promise.resolve(undefined);
1474
+ return result.then(answer => {
1475
+ if (!answer || typeof answer === "string") return answer;
1476
+ return answer.interaction.kind === "value" ? answer.interaction.value : undefined;
1477
+ });
1478
+ },
1479
+ awaitAnswerRequest(request: AskAnswerRequest, signal?: AbortSignal): Promise<AskAnswerSourceResult> {
1480
+ if (signal?.aborted) return Promise.resolve(undefined);
1481
+ const presentationId = `interactive:${crypto.randomUUID()}`;
1482
+ return new Promise<AskAnswerSourceResult>(resolve => {
1483
+ let settled = false;
1484
+ const settle = (result: AskAnswerSourceResult) => {
1485
+ if (settled) return;
1486
+ settled = true;
1487
+ resolve(result);
1488
+ };
1489
+ const pending: PendingInteractiveAsk = {
1490
+ resolve: settle,
1491
+ options: request.options,
1492
+ controls: request.controls,
1493
+ retireForDirectControl: () => presentationArbiter.retireForDirectControl(presentationId),
1494
+ reissue: () => {
1495
+ if (!pending.actionId) return false;
1496
+ presentationArbiter.reissueAfterFailure(pending.actionId);
1497
+ return true;
1498
+ },
1499
+ complete: actionId => presentationArbiter.completeInteractive(presentationId, actionId),
1500
+ completeDirect: () => presentationArbiter.completeDirect(presentationId),
1501
+ fail: actionId => presentationArbiter.completeInteractive(presentationId, actionId),
1502
+ };
1503
+ presentationArbiter.retain({
1504
+ gateId: presentationId,
1505
+ sessionId: id,
1506
+ question: request.question,
1507
+ options: request.options,
1508
+ controls: request.controls,
1509
+ multi: false,
1510
+ allowEmpty: false,
1511
+ selectedOptions: [],
1512
+ onActivated: (actionId, lease) => {
1513
+ if (pending.actionId && pendingInteractive.get(pending.actionId) === pending)
1514
+ pendingInteractive.delete(pending.actionId);
1515
+ pending.actionId = actionId;
1516
+ pendingInteractive.set(actionId, pending);
1517
+ void lease;
1518
+ },
1519
+ onClosed: () => {
1520
+ if (pending.actionId && pendingInteractive.get(pending.actionId) === pending)
1521
+ pendingInteractive.delete(pending.actionId);
1522
+ settle(undefined);
1523
+ },
1524
+ });
1525
+ signal?.addEventListener("abort", () => {
1526
+ presentationArbiter.cancel(presentationId, "interactive_abort");
1527
+ });
1528
+ });
1529
+ },
1530
+ });
1531
+ }
1532
+
1533
+ /** Extract the session id from a `<timestamp>_<uuid>.jsonl` session file path. */
1534
+ function sessionIdFromFile(file: string | undefined): string | undefined {
1535
+ if (!file) return undefined;
1536
+ const base = path.basename(file).replace(/\.jsonl$/, "");
1537
+ const underscore = base.indexOf("_");
1538
+ return underscore >= 0 ? base.slice(underscore + 1) : undefined;
1539
+ }
1540
+
1541
+ function safeLifecycleRequestId(value: string | undefined): string | undefined {
1542
+ return value && /^[A-Za-z0-9._-]{1,128}$/.test(value) ? value : undefined;
1543
+ }
1544
+
1545
+ function validateProviderDefinitions(capability: string, definitions: unknown): void {
1546
+ if (capability !== "host_tools" && capability !== "host_uri") return;
1547
+ const invalid = (message: string): never => {
1548
+ throw Object.assign(new Error(message), { code: "invalid_input" });
1549
+ };
1550
+ if (!Array.isArray(definitions)) invalid(`${capability} definitions must be an array.`);
1551
+ for (const definition of definitions as unknown[]) {
1552
+ if (!definition || typeof definition !== "object" || Array.isArray(definition))
1553
+ invalid(`${capability} definitions must contain objects.`);
1554
+ const entry = definition as Record<string, unknown>;
1555
+ if (capability === "host_tools") {
1556
+ if (typeof entry.name !== "string" || entry.name.trim() === "")
1557
+ invalid("host_tools definitions require a non-empty string name.");
1558
+ if (typeof entry.description !== "string") invalid("host_tools definitions require a string description.");
1559
+ if (!entry.parameters || typeof entry.parameters !== "object" || Array.isArray(entry.parameters))
1560
+ invalid("host_tools definitions require an object parameters.");
1561
+ } else if (
1562
+ typeof entry.scheme !== "string" ||
1563
+ !/^[a-z][a-z0-9+.-]*$/.test(entry.scheme) ||
1564
+ ["http", "https", "file", "ws", "wss"].includes(entry.scheme)
1565
+ ) {
1566
+ invalid("host_uri definitions require a non-reserved URI scheme.");
1567
+ }
1568
+ }
1569
+ }
1570
+
1571
+ const UNINSTALLED_CONTROL_OPERATIONS = new Set(["auth.login", "host_tools.register", "host_uri.register"]);
1572
+
1573
+ const CONTROL_BINDINGS: Readonly<Record<string, string | undefined>> = {
1574
+ "model.cycle": "cycleModel",
1575
+ "thinking.cycle": "cycleThinkingLevel",
1576
+ "queue.steering_mode.set": "setQueueMode",
1577
+ "queue.follow_up_mode.set": "setQueueMode",
1578
+ "queue.interrupt_mode.set": "setQueueMode",
1579
+ "todo.replace": "sdkControl",
1580
+ "permission_mode.set": "sdkControl",
1581
+ "skill.invoke": "invokeSkill",
1582
+ "mode.plan.set": "setPlanMode",
1583
+ "mode.goal.operate": "operateGoal",
1584
+
1585
+ "compaction.auto.set": "sdkControl",
1586
+ "retry.auto.set": "sdkControl",
1587
+ "retry.abort": "sdkControl",
1588
+ "bash.execute": "sdkControl",
1589
+ "bash.abort": "sdkControl",
1590
+ "session.new": "sdkControl",
1591
+ "session.fork": "sdkControl",
1592
+ "session.resume": "sdkControl",
1593
+ "session.close": "sdkControl",
1594
+ "session.switch": "sdkControl",
1595
+ "session.branch": "sdkControl",
1596
+ "session.rename": "sdkControl",
1597
+ "session.handoff": "sdkControl",
1598
+ "session.export_html": "sdkControl",
1599
+ "runtime.reload": "sdkControl",
1600
+ "service_tier.set": "sdkControl",
1601
+ "queue.message.remove": "sdkControl",
1602
+ "queue.message.move": "sdkControl",
1603
+ "queue.message.update": "sdkControl",
1604
+ "extension.set_enabled": "sdkControl",
1605
+ "session.delete": "sdkControl",
1606
+ "session.cwd.move": "sdkControl",
1607
+ "retry.last": "sdkControl",
1608
+ "retry.now": "sdkControl",
1609
+ "bash.background": "sdkControl",
1610
+ };
1611
+ const QUERY_BINDINGS: Readonly<Record<string, string | undefined>> = {
1612
+ "skill.list/state": "getSkillState",
1613
+ "config.list/get": "getConfigItems",
1614
+ "session.branch_candidates": "getBranchCandidates",
1615
+ "extensions.list": "getExtensions",
1616
+ "artifact.read": "getArtifactRange",
1617
+
1618
+ "runtime.jobs.list": "getJobs",
1619
+ };
1620
+
1621
+ function hasTerminalArbitrationCapability(
1622
+ workflowGate: WorkflowGateEmitter | undefined,
1623
+ ): workflowGate is WorkflowGateEmitter &
1624
+ Required<
1625
+ Pick<
1626
+ WorkflowGateEmitter,
1627
+ "resolveGate" | "prepareTerminalization" | "clearPreparedTerminalization" | "registerGateTerminalController"
1628
+ >
1629
+ > {
1630
+ return (
1631
+ typeof workflowGate?.resolveGate === "function" &&
1632
+ typeof workflowGate.prepareTerminalization === "function" &&
1633
+ typeof workflowGate.clearPreparedTerminalization === "function" &&
1634
+ typeof workflowGate.registerGateTerminalController === "function"
1635
+ );
1636
+ }
1637
+
1638
+ function installedOperations(ctx: ExtensionContext, kind: "control" | "query"): Set<string> {
1639
+ const bindings = new Set(ctx.sdkBindings?.() ?? []);
1640
+ const required = kind === "control" ? CONTROL_BINDINGS : QUERY_BINDINGS;
1641
+ const candidates = OPERATIONS.filter(
1642
+ operation =>
1643
+ operation.kind === kind &&
1644
+ (kind !== "control" ||
1645
+ (!UNINSTALLED_CONTROL_OPERATIONS.has(operation.sdkId) &&
1646
+ ((operation.sdkId !== "workflow.gate_answer" && operation.sdkId !== "workflow.plan_approve") ||
1647
+ hasTerminalArbitrationCapability(ctx.workflowGate)) &&
1648
+ (!required[operation.sdkId] || bindings.has(required[operation.sdkId]!)))),
1649
+ );
1650
+ return new Set(candidates.map(operation => operation.sdkId));
1651
+ }
1652
+
1653
+ function sdkQuerySurface(
1654
+ ctx: ExtensionContext,
1655
+ id: string,
1656
+ api: ExtensionAPI,
1657
+ getInstalledDefinitions: (capability: string) => unknown | undefined = () => undefined,
1658
+ getLiveState: () => { isStreaming: boolean; steeringQueueDepth: number; followupQueueDepth: number } = () => ({
1659
+ isStreaming: false,
1660
+ steeringQueueDepth: 0,
1661
+ followupQueueDepth: 0,
1662
+ }),
1663
+ configOverrides: ReadonlyMap<string, unknown> = new Map(),
1664
+ ): SessionSurface {
1665
+ const metadata = () => ({
1666
+ sessionId: id,
1667
+ name: ctx.sessionManager.getSessionName(),
1668
+ cwd: ctx.cwd,
1669
+ kind: ctx.sessionMetadata?.kind ?? "main",
1670
+ });
1671
+ const lastAssistantText = () => {
1672
+ for (const entry of ctx.sessionManager.getBranch().toReversed()) {
1673
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
1674
+ const { content } = entry.message;
1675
+ if (typeof content === "string") return content;
1676
+ if (Array.isArray(content))
1677
+ return content
1678
+ .filter(
1679
+ (block): block is { type: "text"; text: string } =>
1680
+ block.type === "text" && typeof block.text === "string",
1681
+ )
1682
+ .map(block => block.text)
1683
+ .join("");
1684
+ }
1685
+ return undefined;
1686
+ };
1687
+ const getDiff = async () => {
1688
+ try {
1689
+ const { stdout } = await execFileAsync("git", ["diff", "--no-ext-diff"], {
1690
+ cwd: ctx.cwd,
1691
+ maxBuffer: 1024 * 1024,
1692
+ });
1693
+ return stdout
1694
+ .split(/^diff --git /m)
1695
+ .filter(Boolean)
1696
+ .map(section => {
1697
+ const header = section.split("\n", 1)[0] ?? "";
1698
+ const match = /a\/(.+?) b\/(.+)$/.exec(header);
1699
+ return { id: match?.[2] ?? header, path: match?.[2] ?? header, body: `diff --git ${section}` };
1700
+ });
1701
+ } catch (error) {
1702
+ const detail = error instanceof Error ? error.message : String(error);
1703
+ const stderr = error && typeof error === "object" && "stderr" in error ? String(error.stderr ?? "") : "";
1704
+ if (/not a git repository/i.test(`${detail}\n${stderr}`))
1705
+ throw new DiffQueryError("not_git_repository", "diff queries require a Git working tree");
1706
+ if (/maxbuffer|ERR_CHILD_PROCESS_STDIO_MAXBUFFER/i.test(detail))
1707
+ throw new DiffQueryError("diff_too_large", "diff exceeds the 1 MiB query limit");
1708
+ throw error;
1709
+ }
1710
+ };
1711
+ return {
1712
+ getTranscriptEntries: () =>
1713
+ typeof (ctx as Partial<ExtensionContext>).getTranscript === "function" ? ctx.getTranscript() : [],
1714
+ getContextSnapshot: () => ({
1715
+ usage: ctx.getContextUsage(),
1716
+ systemPrompt: ctx.getSystemPrompt(),
1717
+ ...getLiveState(),
1718
+ }),
1719
+ getGoalState: () =>
1720
+ typeof (ctx as Partial<ExtensionContext>).getGoalState === "function" ? ctx.getGoalState() : undefined,
1721
+ getTodoState: () =>
1722
+ typeof (ctx as Partial<ExtensionContext>).getTodoState === "function" ? ctx.getTodoState() : [],
1723
+ getDiff,
1724
+ getUsage: () => ctx.sessionManager.getUsageStatistics(),
1725
+ getModels: () => {
1726
+ const models = ctx.modelRegistry.getAll();
1727
+ const currentModel = ctx.model;
1728
+ const currentThinkingLevel = api.getThinkingLevel();
1729
+ return projectQ10Models({ models, currentModel, currentThinkingLevel });
1730
+ },
1731
+ getSkillState: () => ctx.getSkillState(),
1732
+ getGates: () => {
1733
+ const workflowGate = ctx.workflowGate;
1734
+ if (!workflowGate) return [];
1735
+ return (
1736
+ workflowGate.listWorkflowGateQueryRecords?.() ??
1737
+ workflowGate.listPendingGates?.().map(gate => ({
1738
+ ...gate,
1739
+ id: `pending:${gate.gate_id}`,
1740
+ tag: "pending" as const,
1741
+ })) ??
1742
+ []
1743
+ );
1744
+ },
1745
+ getConfigItems: () => {
1746
+ const items = ctx.getConfigItems();
1747
+ return items && typeof items === "object" && !Array.isArray(items)
1748
+ ? { ...(items as Record<string, unknown>), ...Object.fromEntries(configOverrides) }
1749
+ : items;
1750
+ },
1751
+
1752
+ getSessionMetadata: metadata,
1753
+ getStats: () => ctx.sessionManager.getUsageStatistics(),
1754
+ getBranchCandidates: () => ctx.getBranchCandidates(),
1755
+ getLastAssistant: lastAssistantText,
1756
+
1757
+ getCapabilities: () => ({
1758
+ operations: [...installedOperations(ctx, "control"), ...installedOperations(ctx, "query")],
1759
+ hostTools: getInstalledDefinitions("host_tools") !== undefined,
1760
+ }),
1761
+ getAuthProviders: () => [...new Set(ctx.modelRegistry.getAll().map(model => model.provider))],
1762
+ getTools: () => {
1763
+ const tools = typeof (ctx as Partial<ExtensionContext>).getAllTools === "function" ? ctx.getAllTools() : [];
1764
+ return tools.length > 0 ? tools : (getInstalledDefinitions("host_tools") ?? []);
1765
+ },
1766
+ getQueueMessages: () => ctx.getQueuedMessages(),
1767
+ getExtensions: () => ctx.getExtensions(),
1768
+ getArtifactRange: (id, offset, length) => ctx.getArtifactRange?.(id, offset, length),
1769
+ getJobs: () => ctx.getJobs(),
1770
+ installedQueries: installedOperations(ctx, "query"),
1771
+ };
1772
+ }
1773
+
1774
+ function containsSecretConfigKey(value: unknown, seen = new Set<object>()): boolean {
1775
+ if (!value || typeof value !== "object") return false;
1776
+ if (seen.has(value)) return false;
1777
+ seen.add(value);
1778
+ if (Array.isArray(value)) return value.some(item => containsSecretConfigKey(item, seen));
1779
+ return Object.entries(value as Record<string, unknown>).some(
1780
+ ([key, nested]) =>
1781
+ /(?:token|secret|password|api[_-]?key|credential|authorization)/i.test(key) ||
1782
+ containsSecretConfigKey(nested, seen),
1783
+ );
1784
+ }
1785
+
1786
+ function sdkControlSurface(
1787
+ ctx: ExtensionContext,
1788
+ pendingInteractive: Map<string, PendingInteractiveAsk>,
1789
+ gatePresentations: PresentationArbiter | undefined,
1790
+ api: ExtensionAPI,
1791
+ isBusy: () => boolean,
1792
+ onPromptAccepted: (
1793
+ correlation: { commandId: string; turnId: string },
1794
+ requesterConnectionId?: string,
1795
+ ) => void = () => {},
1796
+ onPromptFailed: (correlation: { commandId: string; turnId: string }, error: unknown) => void = () => {},
1797
+ acceptGateResolution: () => boolean,
1798
+ trackGateResolution: <T>(resolution: Promise<T>) => Promise<T>,
1799
+ settings?: Settings,
1800
+ configOverrides: Map<string, unknown> = new Map(),
1801
+ configRevision: { current: number } = { current: 0 },
1802
+ ): ControlSurface {
1803
+ const unavailable = (operation: string, reason: string) => () => {
1804
+ throw Object.assign(new Error(`${operation} is unavailable: ${reason}`), { code: "unavailable" });
1805
+ };
1806
+ const bindings = new Set(ctx.sdkBindings?.() ?? []);
1807
+ const missingExpectedSessionAudits = new Set<"workflow.gate_answer" | "workflow.plan_approve">();
1808
+ const auditMissingExpectedSessionId = (operation: "workflow.gate_answer" | "workflow.plan_approve") => {
1809
+ if (missingExpectedSessionAudits.has(operation)) return;
1810
+ missingExpectedSessionAudits.add(operation);
1811
+ logger.warn("workflow_control_missing_expected_session_id", { operation });
1812
+ };
1813
+ const reconcileUnknownGateFailure = (gateId: string): "pending" | "terminal" | "unavailable" => {
1814
+ const pending = ctx.workflowGate?.listPendingGates;
1815
+ if (!pending) return "unavailable";
1816
+ try {
1817
+ return pending().some(gate => gate.gate_id === gateId) ? "pending" : "terminal";
1818
+ } catch {
1819
+ logger.warn("workflow_gate_reconciliation_unavailable", { gateId });
1820
+ return "unavailable";
1821
+ }
1822
+ };
1823
+ const reconcileDirectControlFailure = (gateId: string): DirectControlOutcome => {
1824
+ const durable = reconcileUnknownGateFailure(gateId);
1825
+ if (durable === "pending") return "rejected";
1826
+ if (durable === "terminal") return "accepted";
1827
+ try {
1828
+ ctx.workflowGate?.quarantineGate?.(gateId);
1829
+ } catch {
1830
+ // The local arbiter still fails closed when the durable fence is unavailable.
1831
+ }
1832
+ return "unknown";
1833
+ };
1834
+ const sendSteer = (text: string) => {
1835
+ api.sendUserMessage(text, { deliverAs: "steer" });
1836
+ return { commandId: crypto.randomUUID(), accepted: true };
1837
+ };
1838
+ const resolveModel = (id: string) => {
1839
+ const [provider, ...modelId] = id.split("/");
1840
+ const model =
1841
+ modelId.length > 0
1842
+ ? ctx.modelRegistry.find(provider, modelId.join("/"))
1843
+ : ctx.modelRegistry.getAll().find(candidate => candidate.id === id);
1844
+ if (!model) throw Object.assign(new Error(`Model ${id} was not found.`), { code: "invalid_input" });
1845
+ return model;
1846
+ };
1847
+ const unavailablePerSession = (operation: string) =>
1848
+ unavailable(operation, "the registry classifies it outside the per-session extension host");
1849
+ const typed = (operation: string, input: Record<string, unknown> = {}) => {
1850
+ if (!bindings.has("sdkControl") || !ctx.sdkControl)
1851
+ return unavailable(operation, "no typed session seam is installed")();
1852
+ return ctx.sdkControl(operation, input);
1853
+ };
1854
+ const pendingPreflightCancellations = new Set<() => void>();
1855
+ const cancelPendingPreflights = () => {
1856
+ for (const cancel of pendingPreflightCancellations) cancel();
1857
+ };
1858
+ const isSessionBusy = () => isBusy() || ctx.isIdle?.() === false;
1859
+ const awaitAbortReady = async () => {
1860
+ cancelPendingPreflights();
1861
+ await (ctx.abort as () => unknown)();
1862
+ while (isSessionBusy()) {
1863
+ await Bun.sleep(10);
1864
+ }
1865
+ };
1866
+ const submitPrompt = async (
1867
+ text: string,
1868
+ images: unknown,
1869
+ forceFresh = false,
1870
+ deliverAs?: "steer" | "followUp",
1871
+ rejectWhenBusy = false,
1872
+ requesterConnectionId?: string,
1873
+ ) => {
1874
+ if (forceFresh && isSessionBusy()) {
1875
+ throw Object.assign(new Error("Previous turn did not finish aborting before replacement prompt submission."), {
1876
+ code: "busy",
1877
+ });
1878
+ }
1879
+ if (rejectWhenBusy && isSessionBusy())
1880
+ throw Object.assign(
1881
+ new Error("turn.prompt is unavailable while the agent is busy; use turn.steer explicitly."),
1882
+ {
1883
+ code: "busy",
1884
+ },
1885
+ );
1886
+ const promptImages = Array.isArray(images) ? (images as { data: string; mimeType?: string }[]) : [];
1887
+ const content: string | (TextContent | ImageContent)[] =
1888
+ promptImages.length > 0
1889
+ ? [
1890
+ ...(text ? [{ type: "text", text } as TextContent] : []),
1891
+ ...promptImages.map(
1892
+ img => ({ type: "image", data: img.data, mimeType: img.mimeType ?? "image/jpeg" }) as ImageContent,
1893
+ ),
1894
+ ]
1895
+ : text;
1896
+ const commandId = crypto.randomUUID();
1897
+ const turnId = crypto.randomUUID();
1898
+ type PreflightTerminalResult = { status: "accepted" } | { status: "rejected"; error: unknown };
1899
+ const preflight = Promise.withResolvers<PreflightTerminalResult>();
1900
+ let preflightSettled = false;
1901
+ let accepted = false;
1902
+ const correlation = { commandId, turnId };
1903
+ const settlePreflight = (result: PreflightTerminalResult) => {
1904
+ if (preflightSettled) return;
1905
+ preflightSettled = true;
1906
+ preflight.resolve(result);
1907
+ };
1908
+ const cancelPreflight = () =>
1909
+ settlePreflight({
1910
+ status: "rejected",
1911
+ error: Object.assign(new Error("Prompt preflight was cancelled before execution."), { code: "busy" }),
1912
+ });
1913
+ pendingPreflightCancellations.add(cancelPreflight);
1914
+ const onPreflightAccepted = () => {
1915
+ if (preflightSettled) return;
1916
+ accepted = true;
1917
+ onPromptAccepted(correlation, requesterConnectionId);
1918
+ settlePreflight({ status: "accepted" });
1919
+ };
1920
+ // Do not acknowledge the prompt until AgentSession's async preflight
1921
+ // succeeds. The terminal result records correlation before agent_start can fire.
1922
+ let submission: Promise<void> | undefined;
1923
+ try {
1924
+ submission = Promise.resolve(
1925
+ api.sendUserMessage(content, {
1926
+ ...(deliverAs ? { deliverAs } : !forceFresh && isBusy() ? { deliverAs: "steer" as const } : {}),
1927
+ onPreflightAccepted,
1928
+ }),
1929
+ );
1930
+ } catch (error) {
1931
+ if (accepted) onPromptFailed(correlation, error);
1932
+ else settlePreflight({ status: "rejected", error });
1933
+ }
1934
+ if (submission) {
1935
+ void submission.then(
1936
+ () => {
1937
+ if (!accepted)
1938
+ settlePreflight({
1939
+ status: "rejected",
1940
+ error: Object.assign(new Error("Prompt submission completed without preflight acceptance."), {
1941
+ code: "busy",
1942
+ }),
1943
+ });
1944
+ },
1945
+ error => {
1946
+ if (accepted) onPromptFailed(correlation, error);
1947
+ else settlePreflight({ status: "rejected", error });
1948
+ },
1949
+ );
1950
+ }
1951
+ try {
1952
+ const result = await preflight.promise;
1953
+ if (result.status === "rejected") throw result.error;
1954
+ return { commandId, turnId, accepted: true };
1955
+ } finally {
1956
+ pendingPreflightCancellations.delete(cancelPreflight);
1957
+ }
1958
+ };
1959
+ const surface: ControlSurface & { cancelPendingPreflights(): void } = {
1960
+ prompt: (text, images) => submitPrompt(text, images, false, undefined, true, controlRequesterContext.getStore()),
1961
+ steer: text => sendSteer(text),
1962
+ followUp: text => submitPrompt(text, undefined, false, "followUp", false, controlRequesterContext.getStore()),
1963
+ abort: () => {
1964
+ cancelPendingPreflights();
1965
+ ctx.abort();
1966
+ return { aborted: true };
1967
+ },
1968
+ abortAndPrompt: async text => {
1969
+ await awaitAbortReady();
1970
+ return await submitPrompt(text, undefined, true, undefined, false, controlRequesterContext.getStore());
1971
+ },
1972
+ cancelPendingPreflights,
1973
+ answerAsk: (id, answer) => {
1974
+ const pending = pendingInteractive.get(id);
1975
+ if (!pending) throw Object.assign(new Error(`Ask ${id} was not found.`), { code: "resource_gone" });
1976
+ const outcome = pending.retireForDirectControl();
1977
+ if (outcome === "claimed")
1978
+ throw Object.assign(new Error("The active action is already being answered."), { code: "action_claimed" });
1979
+ if (outcome === "stale") throw Object.assign(new Error(`Ask ${id} was not found.`), { code: "resource_gone" });
1980
+ if (pendingInteractive.get(id) === pending) pendingInteractive.delete(id);
1981
+ pending.resolve(mapAnswerToLabel(JSON.stringify(answer), pending.options));
1982
+ pending.completeDirect();
1983
+ return { resolved: true };
1984
+ },
1985
+ answerGate: async (id, response, expectedSessionId) => {
1986
+ if (!acceptGateResolution())
1987
+ throw Object.assign(new Error("Workflow gate is no longer answerable."), { code: "resource_gone" });
1988
+ if (expectedSessionId === undefined) auditMissingExpectedSessionId("workflow.gate_answer");
1989
+ if (expectedSessionId !== undefined && expectedSessionId !== ctx.sessionManager.getSessionId())
1990
+ throw Object.assign(new Error("Workflow gate session does not match this endpoint."), {
1991
+ code: "resource_gone",
1992
+ });
1993
+ const presentations = gatePresentations;
1994
+ if (!presentations)
1995
+ throw Object.assign(new Error("Workflow gates are unavailable for this session."), {
1996
+ code: "resource_gone",
1997
+ });
1998
+ const prepared = presentations.prepareDirectControl(id);
1999
+ if (!prepared || prepared.status === "stale")
2000
+ throw Object.assign(new Error("Workflow gate is no longer answerable."), { code: "resource_gone" });
2001
+ if (prepared.status === "claimed")
2002
+ throw Object.assign(new Error("The active action is already being answered."), { code: "action_claimed" });
2003
+ if (prepared.status !== "queued" && prepared.status !== "retired")
2004
+ throw new Error(`Unexpected direct control preparation: ${prepared.status}`);
2005
+ if (
2006
+ ctx.workflowGate?.prepareTerminalization?.(
2007
+ id,
2008
+ prepared.status === "queued" ? "not_published" : "retired",
2009
+ ) !== true
2010
+ ) {
2011
+ presentations.finishDirectControl(id, prepared, "rejected");
2012
+ throw Object.assign(new Error("Workflow gate lacks a terminalization proof."), { code: "resource_gone" });
2013
+ }
2014
+ 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
+ );
2022
+ const status = (resolution as { status?: unknown }).status;
2023
+ if (status === "accepted" || status === "rejected") {
2024
+ if (status === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
2025
+ presentations.finishDirectControl(id, prepared, status);
2026
+ return resolution;
2027
+ }
2028
+ } catch (error) {
2029
+ const outcome = reconcileDirectControlFailure(id);
2030
+ if (outcome === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
2031
+ presentations.finishDirectControl(id, prepared, outcome);
2032
+ if (outcome === "unknown")
2033
+ throw Object.assign(new Error("Workflow gate resolution outcome is uncertain."), {
2034
+ code: "terminal_uncertain",
2035
+ });
2036
+ throw error;
2037
+ }
2038
+ const outcome = reconcileDirectControlFailure(id);
2039
+ if (outcome === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
2040
+ presentations.finishDirectControl(id, prepared, outcome);
2041
+ logger.warn("workflow_gate_direct_control_uncertain_outcome", {
2042
+ operation: "workflow.gate_answer",
2043
+ gateId: id,
2044
+ outcome,
2045
+ });
2046
+ throw Object.assign(new Error("Workflow gate resolution outcome is uncertain."), {
2047
+ code: "terminal_uncertain",
2048
+ });
2049
+ },
2050
+ approvePlan: async (id, choice, expectedSessionId) => {
2051
+ if (!acceptGateResolution())
2052
+ throw Object.assign(new Error("Workflow plan is no longer answerable."), { code: "resource_gone" });
2053
+ if (expectedSessionId === undefined) auditMissingExpectedSessionId("workflow.plan_approve");
2054
+ if (expectedSessionId !== undefined && expectedSessionId !== ctx.sessionManager.getSessionId())
2055
+ throw Object.assign(new Error("Workflow plan session does not match this endpoint."), {
2056
+ code: "resource_gone",
2057
+ });
2058
+ const presentations = gatePresentations;
2059
+ if (!presentations)
2060
+ throw Object.assign(new Error("Workflow gates are unavailable for this session."), {
2061
+ code: "resource_gone",
2062
+ });
2063
+ const prepared = presentations.prepareDirectControl(id);
2064
+ if (!prepared || prepared.status === "stale")
2065
+ throw Object.assign(new Error("Workflow plan is no longer answerable."), { code: "resource_gone" });
2066
+ if (prepared.status === "claimed")
2067
+ throw Object.assign(new Error("The active action is already being answered."), { code: "action_claimed" });
2068
+ if (prepared.status !== "queued" && prepared.status !== "retired")
2069
+ throw new Error(`Unexpected direct control preparation: ${prepared.status}`);
2070
+ if (
2071
+ ctx.workflowGate?.prepareTerminalization?.(
2072
+ id,
2073
+ prepared.status === "queued" ? "not_published" : "retired",
2074
+ ) !== true
2075
+ ) {
2076
+ presentations.finishDirectControl(id, prepared, "rejected");
2077
+ throw Object.assign(new Error("Workflow plan lacks a terminalization proof."), { code: "resource_gone" });
2078
+ }
2079
+ 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
+ );
2087
+ const status = (resolution as { status?: unknown }).status;
2088
+ if (status === "accepted" || status === "rejected") {
2089
+ if (status === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
2090
+ presentations.finishDirectControl(id, prepared, status);
2091
+ return resolution;
2092
+ }
2093
+ } catch (error) {
2094
+ const outcome = reconcileDirectControlFailure(id);
2095
+ if (outcome === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
2096
+ presentations.finishDirectControl(id, prepared, outcome);
2097
+ if (outcome === "unknown")
2098
+ throw Object.assign(new Error("Workflow plan resolution outcome is uncertain."), {
2099
+ code: "terminal_uncertain",
2100
+ });
2101
+ throw error;
2102
+ }
2103
+ const outcome = reconcileDirectControlFailure(id);
2104
+ if (outcome === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
2105
+ presentations.finishDirectControl(id, prepared, outcome);
2106
+ logger.warn("workflow_gate_direct_control_uncertain_outcome", {
2107
+ operation: "workflow.plan_approve",
2108
+ gateId: id,
2109
+ outcome,
2110
+ });
2111
+ throw Object.assign(new Error("Workflow plan resolution outcome is uncertain."), {
2112
+ code: "terminal_uncertain",
2113
+ });
2114
+ },
2115
+ invokeSkill: (name, args) => {
2116
+ if (!bindings.has("invokeSkill") || !ctx.invokeSkill)
2117
+ return unavailable("skill.invoke", "no skill invocation seam is installed")();
2118
+
2119
+ if (typeof args !== "undefined" && typeof args !== "string")
2120
+ throw Object.assign(new Error("skill.invoke args must be a string."), { code: "invalid_input" });
2121
+ return ctx.invokeSkill(name, args);
2122
+ },
2123
+ setPlanMode: on => {
2124
+ if (!bindings.has("setPlanMode") || !ctx.setPlanMode)
2125
+ return unavailable("mode.plan.set", "no plan-mode seam is installed")();
2126
+
2127
+ if (typeof on !== "boolean")
2128
+ throw Object.assign(new Error("mode.plan.set requires a boolean on value."), { code: "invalid_input" });
2129
+
2130
+ return { state: ctx.setPlanMode(on) };
2131
+ },
2132
+ operateGoal: (op, objective) => {
2133
+ if (!bindings.has("operateGoal") || !ctx.operateGoal)
2134
+ return unavailable("mode.goal.operate", "no goal-mode seam is installed")();
2135
+ if (!["create", "get", "resume", "pause", "complete", "drop"].includes(op))
2136
+ throw Object.assign(new Error("mode.goal.operate requires a supported op."), { code: "invalid_input" });
2137
+ if (objective !== undefined && typeof objective !== "string")
2138
+ throw Object.assign(new Error("mode.goal.operate objective must be a string."), { code: "invalid_input" });
2139
+ return ctx.operateGoal(op as "create" | "get" | "resume" | "pause" | "complete" | "drop", objective);
2140
+ },
2141
+ replaceTodo: items => typed("todo.replace", { items }),
2142
+ setModel: async (id, requestedThinkingLevel) => {
2143
+ const model = resolveModel(id);
2144
+ if (requestedThinkingLevel === undefined) return { changed: await api.setModel(model) };
2145
+ const thinkingLevel =
2146
+ typeof requestedThinkingLevel === "string" ? parseThinkingLevel(requestedThinkingLevel) : undefined;
2147
+ if (!thinkingLevel || thinkingLevel === ThinkingLevel.Inherit)
2148
+ throw Object.assign(
2149
+ new Error("model.set thinkingLevel must be off, minimal, low, medium, high, xhigh, or max."),
2150
+ { code: "invalid_input" },
2151
+ );
2152
+ return typed("model.set", { id: `${model.provider}/${model.id}`, thinkingLevel });
2153
+ },
2154
+ cycleModel: async () => {
2155
+ if (!bindings.has("cycleModel"))
2156
+ return unavailable("model.cycle", "no session model-cycle seam is installed")();
2157
+ return { changed: (await ctx.cycleModel()) !== undefined };
2158
+ },
2159
+ setThinking: level => {
2160
+ api.setThinkingLevel(level as ThinkingLevel);
2161
+ return { changed: true };
2162
+ },
2163
+ cycleThinking: () => {
2164
+ if (!bindings.has("cycleThinkingLevel"))
2165
+ return unavailable("thinking.cycle", "no session thinking-cycle seam is installed")();
2166
+ return { level: ctx.cycleThinkingLevel() };
2167
+ },
2168
+ setPermissionMode: mode => typed("permission_mode.set", { mode }),
2169
+ setQueueMode: (kind, mode) => {
2170
+ if (!bindings.has("setQueueMode"))
2171
+ return unavailable(`queue.${kind}_mode.set`, "no session queue-mode seam is installed")();
2172
+ if (!ctx.setQueueMode(kind as "steering" | "follow_up" | "interrupt", mode))
2173
+ throw Object.assign(new Error("Invalid queue mode."), { code: "invalid_input" });
2174
+ return { changed: true };
2175
+ },
2176
+ runCompaction: async () => {
2177
+ try {
2178
+ await ctx.compact();
2179
+ return { started: true };
2180
+ } catch (error) {
2181
+ throw Object.assign(
2182
+ new Error(error instanceof Error ? error.message : "Compaction is unavailable for the current state."),
2183
+ { code: "invalid_request" },
2184
+ );
2185
+ }
2186
+ },
2187
+ setAutoCompaction: on => typed("compaction.auto.set", { on }),
2188
+ setAutoRetry: on => typed("retry.auto.set", { on }),
2189
+ abortRetry: () => typed("retry.abort"),
2190
+ executeBash: cmd => typed("bash.execute", { cmd }),
2191
+ abortBash: () => typed("bash.abort"),
2192
+ newSession: () => typed("session.new"),
2193
+ forkSession: () => typed("session.fork"),
2194
+ resumeSession: id => typed("session.resume", { id }),
2195
+ closeSession: () => typed("session.close"),
2196
+ switchSession: id => typed("session.switch", { id }),
2197
+ branchSession: entryId => typed("session.branch", { entryId }),
2198
+ renameSession: name => typed("session.rename", { name }),
2199
+ handoffSession: target => typed("session.handoff", { target }),
2200
+ exportHtml: () => typed("session.export_html"),
2201
+ patchConfig: patch => {
2202
+ if (!patch || typeof patch !== "object" || Array.isArray(patch))
2203
+ throw Object.assign(new Error("config.patch requires an object."), { code: "invalid_input" });
2204
+ if (containsSecretConfigKey(patch))
2205
+ throw Object.assign(new Error("config.patch rejects secret fields at the SDK host."), {
2206
+ code: "invalid_input",
2207
+ });
2208
+ if (!settings) return unavailable("config.patch", "configuration settings are unavailable for this session")();
2209
+ const entries = Object.entries(patch as Record<string, unknown>);
2210
+ for (const [key, value] of entries) settings.set(key as never, value as never);
2211
+ for (const [key, value] of entries) configOverrides.set(key, value);
2212
+ configRevision.current += 1;
2213
+ return { patched: entries.map(([key]) => key), revision: String(configRevision.current) };
2214
+ },
2215
+
2216
+ reloadRuntime: components => typed("runtime.reload", { components }),
2217
+ login: unavailablePerSession("auth.login"),
2218
+ registerHostTools: unavailablePerSession("host_tools.register"),
2219
+ registerHostUri: unavailablePerSession("host_uri.register"),
2220
+ setServiceTier: tier => typed("service_tier.set", { tier }),
2221
+ setActiveTools: async names => {
2222
+ await api.setActiveTools(
2223
+ Array.isArray(names) ? names.filter((name): name is string => typeof name === "string") : [],
2224
+ );
2225
+ return { changed: true };
2226
+ },
2227
+ removeQueueMessage: id => typed("queue.message.remove", { id }),
2228
+ moveQueueMessage: (id, position) => typed("queue.message.move", { id, ...position }),
2229
+ updateQueueMessage: (id, patch) => typed("queue.message.update", { id, patch }),
2230
+ setExtensionEnabled: (id, on) => typed("extension.set_enabled", { id, on }),
2231
+ clearContext: async confirm => {
2232
+ if (!confirm)
2233
+ throw Object.assign(new Error("context.clear requires confirmation."), { code: "confirmation_required" });
2234
+ return { cleared: await ctx.clearContext() };
2235
+ },
2236
+ deleteSession: (id, confirm) => {
2237
+ if (!confirm)
2238
+ throw Object.assign(new Error("session.delete requires confirmation."), { code: "confirmation_required" });
2239
+ return typed("session.delete", { id });
2240
+ },
2241
+ moveCwd: path => typed("session.cwd.move", { path }),
2242
+ retryLast: () => typed("retry.last"),
2243
+ retryNow: () => typed("retry.now"),
2244
+ backgroundBash: () => typed("bash.background"),
2245
+ installedOperations: installedOperations(ctx, "control"),
2246
+ revisionProvider: resource => (resource === "config" ? String(configRevision.current) : undefined),
2247
+ };
2248
+ return surface;
2249
+ }
2250
+
2251
+ /** Parse only v3 frames carried through the existing control-command seam. */
2252
+ function sdkInboundFrame(commandJson: string | undefined): Record<string, unknown> | undefined {
2253
+ if (!commandJson) return undefined;
2254
+ try {
2255
+ const frame = JSON.parse(commandJson) as unknown;
2256
+ if (!frame || typeof frame !== "object") return undefined;
2257
+ const type = (frame as Record<string, unknown>).type;
2258
+ return type === "control_request" ||
2259
+ type === "query_request" ||
2260
+ type === "event_replay" ||
2261
+ type === "register_provider" ||
2262
+ type === "provider_heartbeat" ||
2263
+ type === "lease_release" ||
2264
+ type === "reverse_response"
2265
+ ? (frame as Record<string, unknown>)
2266
+ : undefined;
2267
+ } catch {
2268
+ return undefined;
2269
+ }
2270
+ }
2271
+
2272
+ export function createNotificationsExtension(
2273
+ api: ExtensionAPI,
2274
+ options: {
2275
+ settings?: Settings;
2276
+ ensureTelegramDaemon?: (input: {
2277
+ settings: Settings;
2278
+ cwd: string;
2279
+ sessionId: string;
2280
+ }) => Promise<EnsureDaemonResult>;
2281
+ /** Suppress auto-delivery for a GJC-spawned child under `sessionScope=primary`. */
2282
+ spawnedByGjc?: boolean;
2283
+ controller?: NotificationSessionController;
2284
+
2285
+ onSdkRequest?: (kind: "control" | "query", connectionId: string, frame: Record<string, unknown>) => void;
2286
+ } = {},
2287
+ ): void {
2288
+ const lifecycleStartupCapability = lifecycleStartupCapabilityForApi(api);
2289
+ const runtimes = new Map<string, SessionRuntime>();
2290
+ const controller =
2291
+ options.controller ??
2292
+ new NotificationSessionController({
2293
+ eligible: true,
2294
+ getConfig: () => resolveSettings(options.settings).cfg,
2295
+ });
2296
+
2297
+ const sessionStartPromises = new Map<string, Promise<SessionStartResult>>();
2298
+ let activeRuntimeId: string | undefined;
2299
+ let identityControlInFlight = false;
2300
+ let deferredIdentityRotation: { event: { previousSessionFile?: string }; ctx: ExtensionContext } | undefined;
2301
+
2302
+ async function ensureTelegramOwner(
2303
+ settings: Settings,
2304
+ cwd: string,
2305
+ id: string,
2306
+ ): Promise<"ready" | "blocked_identity"> {
2307
+ if (options.ensureTelegramDaemon) {
2308
+ return (await options.ensureTelegramDaemon({ settings, cwd, sessionId: id })) === "blocked"
2309
+ ? "blocked_identity"
2310
+ : "ready";
2311
+ }
2312
+ return (await ensureTelegramDaemonRunningDetailed({ settings, cwd, sessionId: id })) === "blocked_identity"
2313
+ ? "blocked_identity"
2314
+ : "ready";
2315
+ }
2316
+ async function ensureConfiguredDaemons(
2317
+ settings: Settings,
2318
+ cfg: NotificationConfig,
2319
+ cwd: string,
2320
+ id: string,
2321
+ ): Promise<boolean> {
2322
+ if (isTelegramConfigured(cfg)) {
2323
+ if ((await ensureTelegramOwner(settings, cwd, id)) === "blocked_identity") return false;
2324
+ }
2325
+ if (isDiscordConfigured(cfg)) await ensureDiscordDaemon(settings);
2326
+ if (isSlackConfigured(cfg)) await ensureSlackDaemon(settings);
2327
+ return true;
2328
+ }
2329
+ const identityControlOperations = new Set([
2330
+ "session.new",
2331
+ "session.fork",
2332
+ "session.resume",
2333
+ "session.switch",
2334
+ "session.branch",
2335
+ ]);
2336
+ const sessionId = (ctx: ExtensionContext): string => ctx.sessionManager.getSessionId();
2337
+ const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
2338
+
2339
+ async function stopSession(
2340
+ id: string,
2341
+ reason: "session" | "notifications" = "session",
2342
+ expectedRuntime?: SessionRuntime,
2343
+ ): Promise<boolean> {
2344
+ const requestedRuntime = runtimes.get(id);
2345
+ if (expectedRuntime && requestedRuntime !== expectedRuntime) return false;
2346
+ if (reason === "session" && requestedRuntime) requestedRuntime.stopping = true;
2347
+ if (reason === "session" && requestedRuntime) {
2348
+ // Fence the exact runtime before awaiting its startup promise: a late start
2349
+ // must observe removal and clean itself up rather than becoming reachable.
2350
+ runtimes.delete(id);
2351
+ if (activeRuntimeId === id) activeRuntimeId = undefined;
2352
+ }
2353
+ const pendingStart = sessionStartPromises.get(id);
2354
+ if (pendingStart)
2355
+ void pendingStart
2356
+ .catch(() => {})
2357
+ .then(() => {
2358
+ if (runtimes.get(id) === requestedRuntime) void stopSession(id, reason, requestedRuntime);
2359
+ });
2360
+ const rt = requestedRuntime;
2361
+
2362
+ if (expectedRuntime && rt !== expectedRuntime) return false;
2363
+
2364
+ if (!rt) {
2365
+ if (activeRuntimeId === id) activeRuntimeId = undefined;
2366
+ return false;
2367
+ }
2368
+ if (reason === "notifications" && rt.host.started) {
2369
+ rt.notificationsActive = false;
2370
+ try {
2371
+ rt.disposeAnswerSource();
2372
+ } catch {}
2373
+ try {
2374
+ rt.disposeFileSink();
2375
+ } catch {}
2376
+ rt.gatePresentations?.cancelInteractive();
2377
+ for (const pending of rt.pendingInteractive.values()) pending.resolve(undefined);
2378
+ rt.pendingInteractive.clear();
2379
+ return true;
2380
+ }
2381
+
2382
+ try {
2383
+ rt.cancelPostmortemCleanup();
2384
+ } catch {}
2385
+ try {
2386
+ rt.stopBrokerHeartbeat();
2387
+ } catch {}
2388
+ try {
2389
+ rt.disposeAnswerSource();
2390
+ } catch {}
2391
+ try {
2392
+ rt.disposeFileSink();
2393
+ } catch {}
2394
+ try {
2395
+ rt.disposeGateListener();
2396
+ } catch {}
2397
+ await rt.waitForGateResolutionQuiescence();
2398
+ try {
2399
+ rt.disposeAckRecoveryParticipant();
2400
+ } catch {}
2401
+ try {
2402
+ rt.disposeGateEmitterListener();
2403
+ } catch {}
2404
+ rt.gatePresentations?.dispose();
2405
+ try {
2406
+ rt.disposeGateTerminalController();
2407
+ } catch {}
2408
+ let hostStopped = false;
2409
+ let brokerRegistrationReleased = false;
2410
+
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)}`);
2418
+ }
2419
+ rt.host.reverse.dispose();
2420
+ // Resolve any still-pending interactive asks so the ask tool is not left hanging.
2421
+ for (const pending of rt.pendingInteractive.values()) pending.resolve(undefined);
2422
+ rt.pendingInteractive.clear();
2423
+ try {
2424
+ rt.cursors.close();
2425
+ await rt.revisions.close();
2426
+ } catch (e) {
2427
+ logger.warn(`sdk query snapshots: close failed: ${String(e)}`);
2428
+ }
2429
+ let closeFrameSent = false;
2430
+ try {
2431
+ pushSessionFrame(rt, { type: "session_closed", sessionId: id });
2432
+ closeFrameSent = true;
2433
+ } catch (e) {
2434
+ logger.warn(`notifications: session_closed failed: ${String(e)}`);
2435
+ }
2436
+ 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)}`);
2443
+ }
2444
+ lifecycleStartupCapability?.rollback?.recordStop(rt.host.generation, {
2445
+ runtimeRemoved: true,
2446
+ hostStopped: hostStopped && serverStopped,
2447
+ brokerRegistrationReleased,
2448
+ });
2449
+ return true;
2450
+ }
2451
+
2452
+ function isNotificationEligibleContext(ctx: ExtensionContext): boolean {
2453
+ return ctx.sessionMetadata?.kind !== "sub";
2454
+ }
2455
+
2456
+ async function startSession(ctx: ExtensionContext): Promise<SessionStartResult> {
2457
+ const id = sessionId(ctx);
2458
+ const lifecycleRequestId = safeLifecycleRequestId(process.env.GJC_LIFECYCLE_REQUEST_ID);
2459
+ const { settings, cfg, settingsAvailable } = resolveSettings(options.settings);
2460
+ const notificationsEnabledForSession = controller.query(ctx).effectiveEnabled;
2461
+ const sdkEnabledForSession = shouldHostSdk(settings, isNotificationEligibleContext(ctx));
2462
+ const lifecycleRequired = lifecycleStartupCapability !== undefined;
2463
+ const failLifecycleStartup = (
2464
+ reason: "disabled" | "ineligible" | "failed",
2465
+ error?: unknown,
2466
+ ): SessionStartResult => {
2467
+ const failure =
2468
+ lifecycleStartupCapability?.normalizeFailure("startup", reason, error) ??
2469
+ normalizeSdkStartupFailure("startup", reason, error);
2470
+
2471
+ lifecycleStartupCapability?.settleFailure(failure);
2472
+ return { status: reason === "disabled" ? "disabled" : "failed", failure };
2473
+ };
2474
+ const throwIfLifecycleStopped = (): void => {
2475
+ if (lifecycleStartupCapability?.cancelled || runtime?.stopping || runtimes.get(id) !== runtime)
2476
+ throw new Error("Lifecycle SDK startup was cancelled.");
2477
+ };
2478
+
2479
+ if (
2480
+ !lifecycleRequired &&
2481
+ (!isNotificationEligibleContext(ctx) || (!notificationsEnabledForSession && !sdkEnabledForSession))
2482
+ )
2483
+ return { status: "disabled" };
2484
+ if (lifecycleRequired && !isNotificationEligibleContext(ctx)) return failLifecycleStartup("ineligible");
2485
+ const pendingStart = sessionStartPromises.get(id);
2486
+ if (pendingStart) return pendingStart;
2487
+ const existingRuntime = runtimes.get(id);
2488
+ if (existingRuntime) {
2489
+ activeRuntimeId = id;
2490
+ if (lifecycleRequired) {
2491
+ if (existingRuntime.host.started) lifecycleStartupCapability?.settleStarted();
2492
+ else return failLifecycleStartup("failed", "SDK host is not started.");
2493
+ }
2494
+ return { status: "already", runtime: existingRuntime };
2495
+ }
2496
+
2497
+ const stateRoot = path.join(ctx.cwd, ".gjc", "state");
2498
+ const lifecycleAgentDir = lifecycleRequired ? settings?.getAgentDir?.() : undefined;
2499
+ if (lifecycleRequired && !lifecycleAgentDir)
2500
+ return failLifecycleStartup("failed", "Lifecycle SDK startup requires an agent directory.");
2501
+
2502
+ const gateOptions = new Map<string, string[]>();
2503
+ const pendingInteractive = new Map<string, PendingInteractiveAsk>();
2504
+ const pendingPromptCorrelations: Array<{ commandId: string; turnId: string }> = [];
2505
+ const tag = sessionTag(id);
2506
+ const redact = cfg.redact;
2507
+ const verbosity = cfg.verbosity;
2508
+ let runtime: SessionRuntime | undefined;
2509
+
2510
+ // The SDK can always answer now (interactive via the answer source, or the
2511
+ // workflow gate), so the endpoint advertises a resolver. Validate the native
2512
+ // build information and required capability while lifecycle startup can settle
2513
+ // a structured failure instead of leaving the lifecycle caller pending.
2514
+ const token = resolveToken();
2515
+ let server: NotificationServer;
2516
+ try {
2517
+ assertNativeRuntimeCompatibility({
2518
+ runtimeVersion: VERSION,
2519
+ nativeVersion: nativeBuildInfo().version,
2520
+ notificationServer: NotificationServer.prototype,
2521
+ });
2522
+ server = new NotificationServer(id, token, stateRoot, true);
2523
+ } catch (error) {
2524
+ if (lifecycleRequired) return failLifecycleStartup("failed", error);
2525
+ throw error;
2526
+ }
2527
+ const gatePresentations = new PresentationArbiter(server, () => runtime?.redact ?? redact, tag);
2528
+ let inboundSdkFrame: ((connectionId: string, frame: Record<string, unknown>) => void) | undefined;
2529
+ let stopBrokerHeartbeat = () => {};
2530
+ const inFlightGateResolutions = new Set<Promise<void>>();
2531
+ const trackGateResolution = <T>(resolution: Promise<T>): Promise<T> => {
2532
+ const quiesced = resolution.then(
2533
+ () => {},
2534
+ () => {},
2535
+ );
2536
+ inFlightGateResolutions.add(quiesced);
2537
+ void quiesced.finally(() => inFlightGateResolutions.delete(quiesced));
2538
+ return resolution;
2539
+ };
2540
+
2541
+ const revisions = new RevisionStore(id, Date.now, { storageDir: stateRoot });
2542
+ let host: SessionSdkHost | undefined;
2543
+ const installProviderDefinitions = (capability: string, definitions: unknown) => {
2544
+ validateProviderDefinitions(capability, definitions);
2545
+ if (capability !== "permission") return;
2546
+ ctx.setSdkPermissionProvider?.(async (toolCall, permissionOptions, signal) => {
2547
+ const result = await host!.reverse.request("permission", "request", {
2548
+ toolCall,
2549
+ options: permissionOptions,
2550
+ aborted: signal?.aborted === true,
2551
+ });
2552
+ if (!result || typeof result !== "object")
2553
+ throw new Error("permission provider returned an invalid response");
2554
+ const response = result as { outcome?: unknown; optionId?: unknown; kind?: unknown };
2555
+ if (response.outcome === "cancelled") return { outcome: "cancelled" };
2556
+ if (response.outcome === "selected" && typeof response.optionId === "string")
2557
+ return {
2558
+ outcome: "selected",
2559
+ optionId: response.optionId,
2560
+ ...(typeof response.kind === "string"
2561
+ ? { kind: response.kind as "allow_once" | "allow_always" | "reject_once" | "reject_always" }
2562
+ : {}),
2563
+ };
2564
+ throw new Error("permission provider returned an invalid response");
2565
+ });
2566
+ };
2567
+ const removeProviderDefinitions = (capability: string) => {
2568
+ if (capability === "permission") ctx.setSdkPermissionProvider?.(undefined);
2569
+ };
2570
+
2571
+ const configOverrides = new Map<string, unknown>();
2572
+ const configRevision = { current: 0 };
2573
+ const PROMPT_SUBMISSION_CAPACITY = 128;
2574
+ const PROMPT_SUBMISSION_TTL_MS = 5 * 60_000;
2575
+ const PROMPT_TERMINAL_TOMBSTONE_CAPACITY = 256;
2576
+ const PROMPT_TERMINAL_TOMBSTONE_TTL_MS = 15 * 60_000;
2577
+ const promptSubmissionKey = (correlation: { commandId: string; turnId: string }) =>
2578
+ `${correlation.commandId}:${correlation.turnId}`;
2579
+ type PromptLifecycleFrame =
2580
+ | { type: "agent_start" | "agent_end"; sessionId: string; commandId?: string; turnId?: string }
2581
+ | {
2582
+ type: "agent_failed";
2583
+ sessionId: string;
2584
+ commandId: string;
2585
+ turnId: string;
2586
+ error: { code: string; message: string };
2587
+ };
2588
+ type PromptSubmission = {
2589
+ acknowledged: boolean;
2590
+ connectionId: string;
2591
+ abandoned: boolean;
2592
+ failed: boolean;
2593
+ error: unknown;
2594
+ terminal: boolean;
2595
+ createdAt: number;
2596
+ bufferedLifecycle: PromptLifecycleFrame[];
2597
+ };
2598
+ const promptSubmissions = new Map<string, PromptSubmission>();
2599
+ const promptTerminalTombstones = new Map<string, number>();
2600
+ const removePendingPromptCorrelation = (correlation: { commandId: string; turnId: string }) => {
2601
+ const pendingIndex = pendingPromptCorrelations.findIndex(
2602
+ candidate => candidate.commandId === correlation.commandId && candidate.turnId === correlation.turnId,
2603
+ );
2604
+ if (pendingIndex !== -1) pendingPromptCorrelations.splice(pendingIndex, 1);
2605
+ };
2606
+ const addTerminalTombstone = (key: string, now = Date.now()) => {
2607
+ promptTerminalTombstones.delete(key);
2608
+ promptTerminalTombstones.set(key, now + PROMPT_TERMINAL_TOMBSTONE_TTL_MS);
2609
+ while (promptTerminalTombstones.size > PROMPT_TERMINAL_TOMBSTONE_CAPACITY)
2610
+ promptTerminalTombstones.delete(promptTerminalTombstones.keys().next().value!);
2611
+ };
2612
+ const finalizePrompt = (key: string, correlation: { commandId: string; turnId: string }) => {
2613
+ promptSubmissions.delete(key);
2614
+ removePendingPromptCorrelation(correlation);
2615
+ addTerminalTombstone(key);
2616
+ };
2617
+ const cleanupPromptRecords = (now = Date.now()) => {
2618
+ for (const [key, expiresAt] of promptTerminalTombstones)
2619
+ if (expiresAt <= now) promptTerminalTombstones.delete(key);
2620
+ for (const [key, submission] of promptSubmissions)
2621
+ if (submission.createdAt + PROMPT_SUBMISSION_TTL_MS <= now) {
2622
+ const [commandId, turnId] = key.split(":", 2);
2623
+ if (commandId && turnId) finalizePrompt(key, { commandId, turnId });
2624
+ }
2625
+ };
2626
+ const abandonPrompt = (submission: PromptSubmission) => {
2627
+ submission.abandoned = true;
2628
+ submission.bufferedLifecycle.length = 0;
2629
+ };
2630
+ const emitPromptLifecycle = (
2631
+ correlation: { commandId: string; turnId: string } | undefined,
2632
+ frame: PromptLifecycleFrame,
2633
+ ) => {
2634
+ cleanupPromptRecords();
2635
+ if (!correlation || !runtime) {
2636
+ emitAgentLifecycle(runtime!, frame as Extract<PromptLifecycleFrame, { type: "agent_start" | "agent_end" }>);
2637
+ return;
2638
+ }
2639
+ const key = promptSubmissionKey(correlation);
2640
+ const submission = promptSubmissions.get(key);
2641
+ if (!submission) return;
2642
+ runtime.host.emitEvent({ kind: frame.type, payload: frame });
2643
+ if (submission.abandoned) {
2644
+ if (submission.terminal) finalizePrompt(key, correlation);
2645
+ return;
2646
+ }
2647
+ if (!submission.acknowledged) {
2648
+ submission.bufferedLifecycle.push(frame);
2649
+ return;
2650
+ }
2651
+ try {
2652
+ runtime.server.sendTo(submission.connectionId, JSON.stringify(frame));
2653
+ } catch (error) {
2654
+ logger.warn(`sdk: correlated lifecycle delivery failed: ${String(error)}`);
2655
+ abandonPrompt(submission);
2656
+ }
2657
+ if (submission.terminal) finalizePrompt(key, correlation);
2658
+ };
2659
+ const flushPromptLifecycle = (key: string, submission: PromptSubmission) => {
2660
+ for (const frame of submission.bufferedLifecycle.splice(0)) {
2661
+ try {
2662
+ server.sendTo(submission.connectionId, JSON.stringify(frame));
2663
+ } catch (error) {
2664
+ logger.warn(`sdk: buffered correlated lifecycle delivery failed: ${String(error)}`);
2665
+ abandonPrompt(submission);
2666
+ break;
2667
+ }
2668
+ }
2669
+ if (submission.terminal) {
2670
+ const [commandId, turnId] = key.split(":", 2);
2671
+ if (commandId && turnId) finalizePrompt(key, { commandId, turnId });
2672
+ }
2673
+ };
2674
+ const recordPromptAccepted = (
2675
+ correlation: { commandId: string; turnId: string },
2676
+ requesterConnectionId?: string,
2677
+ ) => {
2678
+ if (!requesterConnectionId) return;
2679
+ cleanupPromptRecords();
2680
+ while (promptSubmissions.size >= PROMPT_SUBMISSION_CAPACITY) {
2681
+ const oldest = promptSubmissions.entries().next().value as [string, PromptSubmission] | undefined;
2682
+ if (!oldest) break;
2683
+ const [key] = oldest;
2684
+ const [commandId, turnId] = key.split(":", 2);
2685
+ if (commandId && turnId) finalizePrompt(key, { commandId, turnId });
2686
+ }
2687
+ pendingPromptCorrelations.push(correlation);
2688
+ promptSubmissions.set(promptSubmissionKey(correlation), {
2689
+ acknowledged: false,
2690
+ connectionId: requesterConnectionId,
2691
+ abandoned: false,
2692
+ failed: false,
2693
+ error: undefined,
2694
+ terminal: false,
2695
+ createdAt: Date.now(),
2696
+ bufferedLifecycle: [],
2697
+ });
2698
+ };
2699
+ const recordPromptTerminal = (correlation: { commandId: string; turnId: string } | undefined) => {
2700
+ if (!correlation) return false;
2701
+ cleanupPromptRecords();
2702
+ const key = promptSubmissionKey(correlation);
2703
+ if (promptTerminalTombstones.has(key)) return false;
2704
+ const submission = promptSubmissions.get(key);
2705
+ if (!submission || submission.terminal) return false;
2706
+ submission.terminal = true;
2707
+ return true;
2708
+ };
2709
+ const emitPromptFailure = (correlation: { commandId: string; turnId: string }, error: unknown) => {
2710
+ const submission = promptSubmissions.get(promptSubmissionKey(correlation));
2711
+ if (!submission || !runtime || !recordPromptTerminal(correlation)) return;
2712
+ const candidate = error as { code?: unknown; message?: unknown };
2713
+ emitPromptLifecycle(correlation, {
2714
+ type: "agent_failed",
2715
+ sessionId: runtime.id,
2716
+ ...correlation,
2717
+ error: {
2718
+ code: typeof candidate.code === "string" ? candidate.code : "internal",
2719
+ message: typeof candidate.message === "string" ? candidate.message : "Prompt submission failed.",
2720
+ },
2721
+ });
2722
+ };
2723
+ const recordPromptFailure = (correlation: { commandId: string; turnId: string }, error: unknown) => {
2724
+ const submission = promptSubmissions.get(promptSubmissionKey(correlation));
2725
+ if (!submission) return;
2726
+ submission.failed = true;
2727
+ submission.error = error;
2728
+ removePendingPromptCorrelation(correlation);
2729
+ emitPromptFailure(correlation, error);
2730
+ };
2731
+ const acknowledgePrompt = (connectionId: string, correlation: { commandId: string; turnId: string }) => {
2732
+ const key = promptSubmissionKey(correlation);
2733
+ const submission = promptSubmissions.get(key);
2734
+ if (!submission || submission.abandoned || submission.connectionId !== connectionId) return;
2735
+ submission.acknowledged = true;
2736
+ flushPromptLifecycle(key, submission);
2737
+ };
2738
+
2739
+ const cursors = new CursorRegistry(token, revisions);
2740
+ const queryHandlers = new QueryHandlers(
2741
+ sdkQuerySurface(
2742
+ ctx,
2743
+ id,
2744
+ api,
2745
+ capability => host?.reverse.getInstalledDefinitions(capability),
2746
+ () => {
2747
+ // Live session truth: the agent loop drives rt.busy on
2748
+ // agent_start/agent_end regardless of whether notifications are
2749
+ // active, and ctx.isIdle() is the session's own idle signal.
2750
+ const counts = ctx.getPendingMessageCounts();
2751
+ return {
2752
+ isStreaming: runtime?.busy === true || !ctx.isIdle(),
2753
+ steeringQueueDepth: counts.steering,
2754
+ followupQueueDepth: counts.followUp,
2755
+ };
2756
+ },
2757
+ configOverrides,
2758
+ ),
2759
+ id,
2760
+ revisions,
2761
+ cursors,
2762
+ );
2763
+ const controlSurface = sdkControlSurface(
2764
+ ctx,
2765
+ pendingInteractive,
2766
+ gatePresentations,
2767
+ api,
2768
+ () => runtime?.busy === true || pendingPromptCorrelations.length > 0,
2769
+ recordPromptAccepted,
2770
+ recordPromptFailure,
2771
+ () => runtime?.stopping !== true,
2772
+ trackGateResolution,
2773
+ settings,
2774
+ configOverrides,
2775
+ configRevision,
2776
+ );
2777
+ const abandonPromptResponse = (connectionId: string, frame: Record<string, unknown>) => {
2778
+ if (
2779
+ frame.type !== "control_response" ||
2780
+ frame.ok !== true ||
2781
+ !frame.result ||
2782
+ typeof frame.result !== "object"
2783
+ )
2784
+ return;
2785
+ const result = frame.result as { accepted?: unknown; commandId?: unknown; turnId?: unknown };
2786
+ if (result.accepted !== true || typeof result.commandId !== "string" || typeof result.turnId !== "string")
2787
+ return;
2788
+ const submission = promptSubmissions.get(
2789
+ promptSubmissionKey({ commandId: result.commandId, turnId: result.turnId }),
2790
+ );
2791
+ if (!submission || submission.acknowledged || submission.connectionId !== connectionId) return;
2792
+ abandonPrompt(submission);
2793
+ };
2794
+
2795
+ const sendSdkFrame = (connectionId: string, frame: Record<string, unknown>) => {
2796
+ const json = JSON.stringify(frame);
2797
+ if (connectionId.startsWith("seam:")) {
2798
+ try {
2799
+ pushSessionFrame(runtime!, {
2800
+ type: "control_command_result",
2801
+ sessionId: runtime!.id,
2802
+ requestId: connectionId.slice("seam:".length),
2803
+ status: "ok",
2804
+ message: json,
2805
+ });
2806
+ } catch (error) {
2807
+ logger.warn(`sdk: seam response delivery failed for ${connectionId}: ${String(error)}`);
2808
+ abandonPromptResponse(connectionId, frame);
2809
+ throw error;
2810
+ }
2811
+ return;
2812
+ }
2813
+ try {
2814
+ server.sendTo(connectionId, json);
2815
+ } catch (error) {
2816
+ logger.warn(`sdk: directed response delivery failed for ${connectionId}: ${String(error)}`);
2817
+ abandonPromptResponse(connectionId, frame);
2818
+ throw error;
2819
+ }
2820
+ };
2821
+
2822
+ host = new SessionSdkHost({
2823
+ sessionId: id,
2824
+ stateRoot,
2825
+ token,
2826
+ sendFrame: (connectionId, frame) => sendSdkFrame(connectionId, frame),
2827
+ installProviderDefinitions,
2828
+ onProviderDefinitionsRemoved: removeProviderDefinitions,
2829
+ onFrame: handler => {
2830
+ inboundSdkFrame = handler;
2831
+ return () => {
2832
+ inboundSdkFrame = undefined;
2833
+ };
2834
+ },
2835
+ onRequest: options.onSdkRequest,
2836
+ afterControlResponse: async (connectionId, request, response) => {
2837
+ if (
2838
+ (request.operation === "turn.prompt" ||
2839
+ request.operation === "turn.follow_up" ||
2840
+ request.operation === "turn.abort_and_prompt") &&
2841
+ response.ok === true &&
2842
+ response.result &&
2843
+ typeof response.result === "object" &&
2844
+ !Array.isArray(response.result)
2845
+ ) {
2846
+ const result = response.result as { accepted?: unknown; commandId?: unknown; turnId?: unknown };
2847
+ if (
2848
+ result.accepted === true &&
2849
+ typeof result.commandId === "string" &&
2850
+ typeof result.turnId === "string"
2851
+ )
2852
+ acknowledgePrompt(connectionId, { commandId: result.commandId, turnId: result.turnId });
2853
+ }
2854
+
2855
+ if (request.operation === "session.close" && response.ok === true) ctx.shutdown();
2856
+ if (typeof request.operation === "string" && identityControlOperations.has(request.operation)) {
2857
+ const pending = deferredIdentityRotation;
2858
+ deferredIdentityRotation = undefined;
2859
+ identityControlInFlight = false;
2860
+ if (response.ok === true && pending) await rotateSessionAuthority(pending.event, pending.ctx);
2861
+ }
2862
+ },
2863
+ control: async (connectionId, frame) => {
2864
+ const request = frame as {
2865
+ id?: unknown;
2866
+ operation?: unknown;
2867
+ input?: unknown;
2868
+ expectedRevision?: unknown;
2869
+ idempotencyKey?: unknown;
2870
+ confirm?: unknown;
2871
+ };
2872
+ const requestId = typeof request.id === "string" ? request.id : "";
2873
+ const operation = typeof request.operation === "string" ? request.operation : "";
2874
+ const rotatesIdentity = identityControlOperations.has(operation);
2875
+ if (rotatesIdentity && identityControlInFlight)
2876
+ return {
2877
+ id: requestId,
2878
+ ok: false,
2879
+ error: { code: "conflict", message: "session identity mutation is already active" },
2880
+ };
2881
+ if (rotatesIdentity) identityControlInFlight = true;
2882
+ const response = await controlRequesterContext.run(connectionId, () =>
2883
+ dispatchControl(
2884
+ controlSurface,
2885
+ OPERATIONS.find(row => row.kind === "control" && row.sdkId === operation),
2886
+ {
2887
+ id: requestId,
2888
+ operation,
2889
+ input: request.input,
2890
+ expectedRevision:
2891
+ typeof request.expectedRevision === "string" ? request.expectedRevision : undefined,
2892
+ idempotencyKey: typeof request.idempotencyKey === "string" ? request.idempotencyKey : undefined,
2893
+ confirm: request.confirm === true,
2894
+ },
2895
+ ),
2896
+ );
2897
+ if (rotatesIdentity && response.ok !== true) {
2898
+ identityControlInFlight = false;
2899
+ deferredIdentityRotation = undefined;
2900
+ }
2901
+ return response;
2902
+ },
2903
+ query: async (connectionId, frame) => {
2904
+ const request = frame as { id?: unknown; query?: unknown; input?: unknown; cursor?: unknown };
2905
+ const response = await queryHandlers.dispatch({
2906
+ id: typeof request.id === "string" ? request.id : undefined,
2907
+ query: typeof request.query === "string" ? request.query : "",
2908
+ input:
2909
+ request.input && typeof request.input === "object" && !Array.isArray(request.input)
2910
+ ? (request.input as Record<string, unknown>)
2911
+ : undefined,
2912
+ cursor: typeof request.cursor === "string" ? request.cursor : undefined,
2913
+ connectionId,
2914
+ });
2915
+ return { type: "query_response", ...response };
2916
+ },
2917
+ });
2918
+
2919
+ // Install the runtime before either transport can expose the host. session_start
2920
+ // is deliberately fire-and-forget, so agent lifecycle events and direct v3
2921
+ // seam replies can otherwise arrive between server.start() and the old
2922
+ // registration below. Keeping this state live first makes those frames
2923
+ // replayable rather than dropping them (or dereferencing an absent runtime).
2924
+ runtime = {
2925
+ server,
2926
+ host,
2927
+ revisions,
2928
+ cursors,
2929
+ id,
2930
+ idleSeq: 0,
2931
+ pendingInteractive,
2932
+ brokerRegistrationActive: false,
2933
+ disposeAnswerSource: () => {},
2934
+ disposeFileSink: () => {},
2935
+ disposeGateListener: () => {},
2936
+ notificationsActive: false,
2937
+ enableNotifications: () => {},
2938
+ disposeGateTerminalController: () => {},
2939
+ disposeAckRecoveryParticipant: () => {},
2940
+ disposeGateEmitterListener: () => {},
2941
+ waitForGateResolutionQuiescence: async () => {
2942
+ await Promise.allSettled(inFlightGateResolutions);
2943
+ },
2944
+ workflowGate: undefined,
2945
+ gatePresentations,
2946
+ stopping: false,
2947
+ cancelPostmortemCleanup: () => {},
2948
+ stopBrokerHeartbeat,
2949
+
2950
+ redact,
2951
+ verbosity,
2952
+ stream: streamingEnabled(),
2953
+ sessionTag: tag,
2954
+ busy: false,
2955
+ pendingPromptCorrelations,
2956
+ activePromptCorrelation: undefined,
2957
+ recordPromptTerminal,
2958
+ emitPromptLifecycle,
2959
+ pendingInbound: new Set<number>(),
2960
+ };
2961
+ runtimes.set(id, runtime);
2962
+ activeRuntimeId = id;
2963
+ const startSettled = Promise.withResolvers<SessionStartResult>();
2964
+ sessionStartPromises.set(id, startSettled.promise);
2965
+ const finishStartup = (result: SessionStartResult): void => {
2966
+ if (lifecycleRequired) {
2967
+ if (result.status === "started") lifecycleStartupCapability?.settleStarted();
2968
+ else
2969
+ lifecycleStartupCapability?.settleFailure(
2970
+ result.failure ??
2971
+ lifecycleStartupCapability?.normalizeFailure(
2972
+ "startup",
2973
+ result.status === "disabled" ? "disabled" : "failed",
2974
+ ) ??
2975
+ normalizeSdkStartupFailure("startup", result.status === "disabled" ? "disabled" : "failed"),
2976
+ );
2977
+ }
2978
+ if (sessionStartPromises.get(id) === startSettled.promise) sessionStartPromises.delete(id);
2979
+ startSettled.resolve(result);
2980
+ };
2981
+ const cleanupAbandonedStartup = async (): Promise<void> => {
2982
+ try {
2983
+ server.stop();
2984
+ } catch {}
2985
+ try {
2986
+ await host.stop();
2987
+ host.reverse.dispose();
2988
+ } catch {}
2989
+ try {
2990
+ cursors.close();
2991
+ await revisions.close();
2992
+ } catch {}
2993
+ };
2994
+
2995
+ try {
2996
+ server.onSdkFrame((err, inbound) => {
2997
+ if (err || !inbound) return;
2998
+ try {
2999
+ inboundSdkFrame?.(inbound.connectionId, JSON.parse(inbound.json) as Record<string, unknown>);
3000
+ } catch {}
3001
+ });
3002
+ server.onConnectionClose((_err, connectionId) => {
3003
+ if (!connectionId) return;
3004
+ host.handleDisconnect(connectionId);
3005
+ for (const submission of promptSubmissions.values())
3006
+ if (submission.connectionId === connectionId) abandonPrompt(submission);
3007
+ });
3008
+
3009
+ server.onReply((err, reply) => {
3010
+ if (err || !reply) return;
3011
+ if (runtime?.stopping || runtimes.get(id) !== runtime) {
3012
+ try {
3013
+ server.closeClaimInvalid(reply.replyReceiptId, "session_stopping");
3014
+ } catch {}
3015
+ return;
3016
+ }
3017
+ const native = server as unknown as {
3018
+ resolveClaim(receiptId: string, answerJson?: string, idempotencyKey?: string): void;
3019
+ closeClaimInvalid(receiptId: string, reason: string): void;
3020
+ requestAskSelectedAck(
3021
+ receiptId: string,
3022
+ requestJson: string,
3023
+ ): Promise<{ status: string; messageId?: number; reason?: string }>;
3024
+ };
3025
+ const pending = pendingInteractive.get(reply.id);
3026
+ if (pending) {
3027
+ if (pendingInteractive.get(reply.id) === pending) pendingInteractive.delete(reply.id);
3028
+ let interaction: AskRemoteInteraction | undefined;
3029
+ try {
3030
+ const answer = JSON.parse(reply.answerJson) as unknown;
3031
+ if (typeof answer === "object" && answer && "controlId" in answer) {
3032
+ const controlId = (answer as { controlId?: unknown }).controlId;
3033
+ if (
3034
+ controlId === "navigation_forward" &&
3035
+ pending.controls.some(control => control.id === controlId && control.enabled)
3036
+ ) {
3037
+ interaction = { kind: "control", controlId };
3038
+ }
3039
+ } else {
3040
+ const value = mapAnswerToLabel(reply.answerJson, pending.options);
3041
+ if (value !== undefined) interaction = { kind: "value", value };
3042
+ }
3043
+ } catch {}
3044
+ if (!interaction) {
3045
+ try {
3046
+ native.closeClaimInvalid(reply.replyReceiptId, "invalid_answer");
3047
+ } catch {}
3048
+ if (!pending.reissue()) pending.resolve(undefined);
3049
+ return;
3050
+ }
3051
+ let settled: Promise<AskSettlementResult> | undefined;
3052
+ const receipt: AskRemoteReceipt = {
3053
+ source: "remote",
3054
+ interaction,
3055
+ settle(settlement: AskSettlement): Promise<AskSettlementResult> {
3056
+ if (settled) return settled;
3057
+ settled = Promise.resolve().then(async () => {
3058
+ if (settlement.kind === "invalid") {
3059
+ try {
3060
+ native.closeClaimInvalid(reply.replyReceiptId, settlement.reason);
3061
+ } catch (error) {
3062
+ pending.fail(reply.id);
3063
+ throw error;
3064
+ }
3065
+ pending.reissue();
3066
+ return { kind: "invalid_closed" };
3067
+ }
3068
+ try {
3069
+ if (settlement.kind === "resolve_without_commit") {
3070
+ native.resolveClaim(
3071
+ reply.replyReceiptId,
3072
+ reply.answerJson,
3073
+ reply.idempotencyKey ?? undefined,
3074
+ );
3075
+ pending.complete(reply.id);
3076
+ return { kind: "resolved_without_commit" };
3077
+ }
3078
+ const ack = await requestLiveSelectedAck(native, {
3079
+ replyReceiptId: reply.replyReceiptId,
3080
+ actionId: reply.id,
3081
+ commitKey: `${reply.id}:${reply.idempotencyKey ?? reply.replyReceiptId}`,
3082
+ deadlineAt: Date.now() + 8_000,
3083
+ });
3084
+ native.resolveClaim(
3085
+ reply.replyReceiptId,
3086
+ reply.answerJson,
3087
+ reply.idempotencyKey ?? undefined,
3088
+ );
3089
+ pending.complete(reply.id);
3090
+ return { kind: "committed", ack };
3091
+ } catch (error) {
3092
+ try {
3093
+ native.closeClaimInvalid(reply.replyReceiptId, "settlement_failed");
3094
+ } catch {}
3095
+ pending.fail(reply.id);
3096
+ throw error;
3097
+ }
3098
+ });
3099
+ return settled;
3100
+ },
3101
+ };
3102
+ pending.resolve(receipt);
3103
+ return;
3104
+ }
3105
+ const gate = runtime?.workflowGate;
3106
+ const workflowGateActive =
3107
+ gate?.isUnattended?.() === true &&
3108
+ typeof gate.onGateEmitted === "function" &&
3109
+ typeof gate.resolveGateFromNotification === "function";
3110
+ const gateId = gatePresentations.routeFor(reply.id);
3111
+ if (gate && workflowGateActive && gateId && gate.resolveGateFromNotification) {
3112
+ const presentation = gatePresentations.presentationFor(reply.id);
3113
+ const rawAnswer = parseAnswer(reply.answerJson);
3114
+ if (presentation?.multi) {
3115
+ const option =
3116
+ typeof rawAnswer === "number"
3117
+ ? presentation.options[rawAnswer]
3118
+ : typeof rawAnswer === "string" && presentation.options.includes(rawAnswer)
3119
+ ? rawAnswer
3120
+ : undefined;
3121
+ if (option !== undefined) {
3122
+ native.resolveClaim(reply.replyReceiptId, reply.answerJson, reply.idempotencyKey ?? undefined);
3123
+ if (!gatePresentations.toggle(reply.id, option)) gatePresentations.reissue(gateId);
3124
+ return;
3125
+ }
3126
+ }
3127
+ let answer: unknown;
3128
+ if (
3129
+ presentation?.multi &&
3130
+ typeof rawAnswer === "object" &&
3131
+ rawAnswer !== null &&
3132
+ (rawAnswer as { controlId?: unknown }).controlId === "navigation_forward"
3133
+ ) {
3134
+ if (!presentation.allowEmpty && presentation.selectedOptions.length === 0) {
3135
+ native.closeClaimInvalid(reply.replyReceiptId, "invalid_control");
3136
+ gatePresentations.closeInteraction(reply.id, "invalid_control");
3137
+ gatePresentations.reissue(gateId);
3138
+ return;
3139
+ }
3140
+ answer = { selected: presentation.selectedOptions };
3141
+ } else if (
3142
+ typeof rawAnswer === "object" &&
3143
+ rawAnswer !== null &&
3144
+ (rawAnswer as { action?: unknown }).action === "clarify"
3145
+ ) {
3146
+ answer = rawAnswer;
3147
+ } else if (presentation?.multi && typeof rawAnswer === "string") {
3148
+ answer = { selected: presentation.selectedOptions, other: true, custom: rawAnswer };
3149
+ } else {
3150
+ const mapped = mapAnswerToGate(reply.answerJson, gateOptions.get(gateId) ?? []);
3151
+ if (!mapped.ok) {
3152
+ // A numeric selector outside options is invalid (issue #2030): close the
3153
+ // exact claim/receipt and reissue the interaction — never a success ack.
3154
+ native.closeClaimInvalid(reply.replyReceiptId, mapped.reason);
3155
+ gatePresentations.closeInteraction(reply.id, mapped.reason);
3156
+ gatePresentations.reissue(gateId);
3157
+ return;
3158
+ }
3159
+ answer = mapped.answer;
3160
+ }
3161
+ const resolution = gate
3162
+ .resolveGateFromNotification(
3163
+ { gate_id: gateId, answer, idempotency_key: reply.idempotencyKey ?? undefined },
3164
+ {
3165
+ interactionActionId: reply.id,
3166
+ replyReceiptId: reply.replyReceiptId,
3167
+ answerJson: reply.answerJson,
3168
+ idempotencyKey: reply.idempotencyKey ?? undefined,
3169
+ resolveClaim: () =>
3170
+ native.resolveClaim(
3171
+ reply.replyReceiptId,
3172
+ reply.answerJson,
3173
+ reply.idempotencyKey ?? undefined,
3174
+ ),
3175
+ closeClaimInvalid: reason => {
3176
+ native.closeClaimInvalid(reply.replyReceiptId, reason);
3177
+ gatePresentations.closeInteraction(reply.id, reason);
3178
+ gatePresentations.reconcile();
3179
+ },
3180
+ requestSelectedAck: input =>
3181
+ requestLiveSelectedAck(native, {
3182
+ replyReceiptId: input.replyReceiptId,
3183
+ actionId: input.actionId,
3184
+ commitKey: input.commitKey,
3185
+ deadlineAt: input.daemonDeadlineAt,
3186
+ }),
3187
+ },
3188
+ )
3189
+ .catch(() => {
3190
+ let durable: "pending" | "terminal" | "unavailable" = "unavailable";
3191
+ try {
3192
+ if (gate.listPendingGates)
3193
+ durable = gate.listPendingGates().some(candidate => candidate.gate_id === gateId)
3194
+ ? "pending"
3195
+ : "terminal";
3196
+ } catch {
3197
+ // Durable state is unavailable; remain fail-closed.
3198
+ }
3199
+ if (durable === "pending") gatePresentations.reconcile();
3200
+ else {
3201
+ if (durable === "unavailable") {
3202
+ try {
3203
+ gate.quarantineGate?.(gateId);
3204
+ } catch {
3205
+ // The presentation remains fail-closed when the durable fence is unavailable.
3206
+ }
3207
+ }
3208
+ gatePresentations.complete(gateId);
3209
+ }
3210
+ logger.warn("workflow_gate_notification_resolution_failed", { gateId, durable });
3211
+ });
3212
+ trackGateResolution(resolution);
3213
+ return;
3214
+ }
3215
+ try {
3216
+ server.closeClaimInvalid(reply.replyReceiptId, "unknown_action");
3217
+ } catch (error) {
3218
+ logger.warn(`notifications: closeClaimInvalid failed: ${String(error)}`);
3219
+ }
3220
+ });
3221
+
3222
+ // Inbound free-text injection / in-thread config command from a session
3223
+ // thread (forwarded by the daemon over the WS, fail-closed at the daemon).
3224
+ server.onInbound((err, inbound) => {
3225
+ if (err || !inbound) return;
3226
+ if (inbound.kind === "control_command") {
3227
+ const frame = sdkInboundFrame(inbound.commandJson);
3228
+ if (frame) {
3229
+ inboundSdkFrame?.(`seam:${inbound.requestId ?? "notification"}`, frame);
3230
+ return;
3231
+ }
3232
+ }
3233
+
3234
+ if (inbound.kind === "user_message") {
3235
+ // Inject as a user turn (steers/continues the agent; the resulting
3236
+ // turn streams back via the turn_end handler even when not idle).
3237
+ // Record the update id so it can be acked as "consumed" on the next
3238
+ // turn_start, and steer (vs start a fresh turn) when already busy.
3239
+ const text = inbound.text ?? "";
3240
+ const images = inbound.images ?? [];
3241
+ if (!text && images.length === 0) return;
3242
+ if (runtime && typeof inbound.updateId === "number") runtime.pendingInbound.add(inbound.updateId);
3243
+ const content: string | (TextContent | ImageContent)[] =
3244
+ images.length > 0
3245
+ ? [
3246
+ ...(text ? [{ type: "text", text } as TextContent] : []),
3247
+ ...images.map(
3248
+ img =>
3249
+ ({
3250
+ type: "image",
3251
+ data: img.data,
3252
+ mimeType: img.mime ?? "image/jpeg",
3253
+ }) as ImageContent,
3254
+ ),
3255
+ ]
3256
+ : text;
3257
+ try {
3258
+ api.sendUserMessage(content, runtime?.busy ? { deliverAs: "steer" } : undefined);
3259
+ } catch (e) {
3260
+ logger.warn(`notifications: sendUserMessage failed: ${String(e)}`);
3261
+ }
3262
+ return;
3263
+ }
3264
+ if (inbound.kind === "config_command") {
3265
+ if (!runtime) return;
3266
+ const update: {
3267
+ type: "config_update";
3268
+ sessionId: string;
3269
+ verbosity?: "lean" | "verbose";
3270
+ redact?: boolean;
3271
+ } = {
3272
+ type: "config_update",
3273
+ sessionId: runtime.id,
3274
+ };
3275
+ if (inbound.verbosity === "lean" || inbound.verbosity === "verbose") {
3276
+ runtime.verbosity = inbound.verbosity;
3277
+ update.verbosity = inbound.verbosity;
3278
+ }
3279
+ if (typeof inbound.redact === "boolean") {
3280
+ runtime.redact = inbound.redact;
3281
+ update.redact = inbound.redact;
3282
+ }
3283
+ if (update.verbosity !== undefined || update.redact !== undefined) {
3284
+ try {
3285
+ pushSessionFrame(runtime, update);
3286
+ } catch (error) {
3287
+ logger.warn(`notifications: config_update failed: ${String(error)}`);
3288
+ }
3289
+ }
3290
+ }
3291
+ if (inbound.kind === "control_command") {
3292
+ if (!runtime || !inbound.requestId) return;
3293
+ const activeRuntime = runtime;
3294
+ if (inbound.sessionId !== activeRuntime.id) {
3295
+ pushSessionFrame(activeRuntime, {
3296
+ type: "control_command_result",
3297
+ sessionId: activeRuntime.id,
3298
+ requestId: inbound.requestId,
3299
+ updateId: inbound.updateId,
3300
+ status: "error",
3301
+ message: STALE_MODEL_BUTTON_MESSAGE,
3302
+ });
3303
+ return;
3304
+ }
3305
+ void executeNotificationControlCommand(
3306
+ parseControlCommandPayload(inbound.commandJson),
3307
+ ctx,
3308
+ api,
3309
+ inbound.sessionId,
3310
+ ).then(result => {
3311
+ if (runtime !== activeRuntime) return;
3312
+ pushSessionFrame(activeRuntime, {
3313
+ type: "control_command_result",
3314
+ sessionId: activeRuntime.id,
3315
+ requestId: inbound.requestId,
3316
+ updateId: inbound.updateId,
3317
+ status: result.status,
3318
+ message: result.message,
3319
+ modelChoices: result.modelChoices,
3320
+ });
3321
+ });
3322
+ }
3323
+ });
3324
+
3325
+ await host.start();
3326
+ lifecycleStartupCapability?.rollback?.recordGeneration(host.generation);
3327
+ throwIfLifecycleStopped();
3328
+ if (runtimes.get(id) !== runtime) {
3329
+ finishStartup({ status: "failed" });
3330
+ await cleanupAbandonedStartup();
3331
+ return { status: "failed" };
3332
+ }
3333
+ if (notificationsEnabledForSession && settingsAvailable && settings) {
3334
+ try {
3335
+ if (!(await ensureConfiguredDaemons(settings, cfg, ctx.cwd, id))) {
3336
+ const result = failLifecycleStartup("failed", "Telegram daemon ownership is blocked.");
3337
+ finishStartup(result);
3338
+ await cleanupAbandonedStartup();
3339
+ return result;
3340
+ }
3341
+ } catch (error) {
3342
+ const result = failLifecycleStartup("failed", error);
3343
+ finishStartup(result);
3344
+ await cleanupAbandonedStartup();
3345
+ return result;
3346
+ }
3347
+ }
3348
+
3349
+ // Startup contract: identity is committed to the replayable SDK event log
3350
+ // immediately after the host starts, before awaiting notification transport
3351
+ // startup. Native frames are ephemeral, so ensure configured daemon owners
3352
+ // before broadcasting identity; late SDK consumers still recover it from
3353
+ // event_replay.
3354
+ const identityHeader = {
3355
+ type: "identity_header",
3356
+ sessionId: id,
3357
+ ...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
3358
+ };
3359
+ host.emitEvent({ kind: identityHeader.type, payload: identityHeader });
3360
+ const endpoint = await server.start();
3361
+ throwIfLifecycleStopped();
3362
+ if (runtimes.get(id) !== runtime) {
3363
+ finishStartup({ status: "failed" });
3364
+ await cleanupAbandonedStartup();
3365
+ return { status: "failed" };
3366
+ }
3367
+
3368
+ server.pushFrame(JSON.stringify(identityHeader));
3369
+ const agentDir = lifecycleAgentDir ?? settings?.getAgentDir?.();
3370
+ if (lifecycleRequired && !agentDir) throw new Error("Lifecycle SDK host requires an agent directory.");
3371
+
3372
+ if (agentDir) {
3373
+ try {
3374
+ await ensureBroker({ agentDir });
3375
+ throwIfLifecycleStopped();
3376
+ const index = await new SessionIndex(agentDir).open();
3377
+ throwIfLifecycleStopped();
3378
+ const locator = { repo: path.resolve(ctx.cwd), stateRoot };
3379
+ const endpointMtimeMs = fs.statSync(path.join(stateRoot, "sdk", `${id}.json`)).mtimeMs;
3380
+ await host.registerWithBroker({
3381
+ // The endpoint is written before registration. Its exact mtime
3382
+ // binds this index generation to that discovery record.
3383
+ register: async input => {
3384
+ await index.append({
3385
+ type: "host_registered",
3386
+ ...input,
3387
+ locator,
3388
+ pid: process.pid,
3389
+ endpointMtimeMs,
3390
+ ...(lifecycleRequestId ? { lifecycleRequestId } : {}),
3391
+ });
3392
+ },
3393
+ unregister: async input => {
3394
+ await index.append({
3395
+ type: "host_unregistered",
3396
+ ...input,
3397
+ locator,
3398
+ pid: process.pid,
3399
+ ...(lifecycleRequestId ? { lifecycleRequestId } : {}),
3400
+ });
3401
+ },
3402
+ });
3403
+ 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);
3417
+ } catch (brokerError) {
3418
+ if (lifecycleRequired) throw brokerError;
3419
+ logger.warn(`sdk broker registration skipped: ${String(brokerError)}`);
3420
+ }
3421
+ }
3422
+
3423
+ runtime.stopBrokerHeartbeat = stopBrokerHeartbeat;
3424
+ const startedRuntime = runtime;
3425
+ runtime.enableNotifications = () => {
3426
+ const runtime = startedRuntime;
3427
+ if (runtime.notificationsActive) return;
3428
+ runtime.notificationsActive = true;
3429
+ runtime.disposeAnswerSource = registerInteractiveAnswerSource(
3430
+ runtime.id,
3431
+ pendingInteractive,
3432
+ gatePresentations,
3433
+ );
3434
+ runtime.disposeFileSink = registerTelegramFileSink(runtime.id, async file => {
3435
+ if (runtime.redact) return { ok: false, error: TELEGRAM_FILE_REDACTION_ERROR };
3436
+ try {
3437
+ 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
+ });
3445
+ return { ok: true };
3446
+ } catch (e) {
3447
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
3448
+ }
3449
+ });
3450
+ };
3451
+ const activeRuntime = runtime;
3452
+ // A native terminal close (SIGHUP), SIGTERM, Ctrl+C exit, or fatal error
3453
+ // skips AgentSession.dispose(), so the `session_shutdown` extension event
3454
+ // never fires and the daemon-side topic would be orphaned. postmortem
3455
+ // awaits registered cleanups on those paths, so send the graceful
3456
+ // `session_closed` frame from there too. stopSession() cancels this
3457
+ // 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
+ });
3461
+ logger.info(`notifications: serving session ${id} at ${endpoint.url}`);
3462
+ // A workflow-gate emitter can be installed after session startup.
3463
+ // Attach dynamically so the SDK bus presents every durable gate.
3464
+ const attachWorkflowGate = (gate: WorkflowGateEmitter | undefined): void => {
3465
+ if (activeRuntime.workflowGate === gate) return;
3466
+ activeRuntime.disposeGateListener();
3467
+ activeRuntime.disposeAckRecoveryParticipant();
3468
+ gatePresentations.dispose();
3469
+ activeRuntime.disposeGateTerminalController();
3470
+ activeRuntime.disposeGateListener = () => {};
3471
+ activeRuntime.disposeGateTerminalController = () => {};
3472
+ activeRuntime.disposeAckRecoveryParticipant = () => {};
3473
+ activeRuntime.workflowGate = undefined;
3474
+ gateOptions.clear();
3475
+ if (typeof gate?.onGateEmitted !== "function" || typeof gate.resolveGateFromNotification !== "function") {
3476
+ return;
3477
+ }
3478
+ activeRuntime.workflowGate = gate;
3479
+ if (hasTerminalArbitrationCapability(gate)) {
3480
+ const controller: WorkflowGateTerminalController = {
3481
+ completeGateInteractions: gateId => gatePresentations.complete(gateId),
3482
+ cancelGateInteractions: (gateId, reason) => gatePresentations.cancel(gateId, reason),
3483
+ };
3484
+ try {
3485
+ activeRuntime.disposeGateTerminalController = gate.registerGateTerminalController(controller);
3486
+ } catch (error) {
3487
+ logger.warn(`notifications: gate terminal controller unavailable: ${String(error)}`);
3488
+ }
3489
+ }
3490
+ activeRuntime.disposeGateListener = gate.onGateEmitted(g => {
3491
+ const options = (g.options ?? []).map(o => String((o as { label?: unknown }).label ?? ""));
3492
+ gateOptions.set(g.gate_id, options);
3493
+ const promptCtx = g.context as { prompt?: unknown; title?: unknown } | undefined;
3494
+ const question =
3495
+ (typeof promptCtx?.prompt === "string" && promptCtx.prompt) ||
3496
+ (typeof promptCtx?.title === "string" && promptCtx.title) ||
3497
+ "Question";
3498
+ const stageState =
3499
+ typeof g.context?.stage_state === "object" && g.context.stage_state !== null
3500
+ ? (g.context.stage_state as Record<string, unknown>)
3501
+ : {};
3502
+ gatePresentations.retain({
3503
+ gateId: g.gate_id,
3504
+ workflowGateId: g.gate_id,
3505
+ sessionId: id,
3506
+ question,
3507
+ options,
3508
+ controls: [],
3509
+ multi: stageState.multi === true,
3510
+ allowEmpty: stageState.allow_empty === true,
3511
+ navigationLabel: stageState.navigation_label === "Next" ? "Next" : "Done",
3512
+ selectedOptions: [],
3513
+ });
3514
+ });
3515
+ if (gate.setAckRecoveryParticipant) {
3516
+ const native = server as unknown as {
3517
+ requestRecoveredAskSelectedAck(
3518
+ requestJson: string,
3519
+ ): Promise<{ status: string; messageId?: number; reason?: string }>;
3520
+ };
3521
+ gate.setAckRecoveryParticipant({
3522
+ requestRecoveredAskSelectedAck: input =>
3523
+ requestRecoveredSelectedAck(native, {
3524
+ sessionId: input.sessionId,
3525
+ actionId: input.actionId,
3526
+ commitKey: input.commitKey,
3527
+ deadlineAt: input.deadlineAt,
3528
+ }),
3529
+ });
3530
+ activeRuntime.disposeAckRecoveryParticipant = () => gate.setAckRecoveryParticipant?.(null);
3531
+ }
3532
+ void gate.recoverAcceptedGates?.();
3533
+ };
3534
+ activeRuntime.disposeGateEmitterListener = registerWorkflowGateEmitterListener(id, attachWorkflowGate);
3535
+ if (ctx.workflowGate) attachWorkflowGate(ctx.workflowGate);
3536
+ if (notificationsEnabledForSession) runtime.enableNotifications();
3537
+ finishStartup({ status: "started", runtime });
3538
+ return { status: "started", runtime };
3539
+ } catch (e) {
3540
+ logger.warn(`notifications: failed to start server: ${String(e)}`);
3541
+ const result = failLifecycleStartup("failed", e);
3542
+ finishStartup(result);
3543
+ if (!(await stopSession(id, "session", runtime))) await cleanupAbandonedStartup();
3544
+ return { ...result, runtime };
3545
+ }
3546
+ }
3547
+
3548
+ const sessionRuntime: NotificationSessionRuntime<ExtensionContext> = {
3549
+ isRunning: binding => runtimes.get(binding.sessionId)?.notificationsActive === true,
3550
+ start: async binding => {
3551
+ if (sessionStartPromises.has(binding.sessionId)) {
3552
+ const result = await startSession(binding.context);
3553
+ if (result.status !== "started" && result.status !== "already") return result.status;
3554
+ const runtime = runtimes.get(binding.sessionId);
3555
+ if (!runtime || sessionId(binding.context) !== binding.sessionId || activeRuntimeId !== binding.sessionId) {
3556
+ return "failed";
3557
+ }
3558
+ runtime.enableNotifications();
3559
+ return "started";
3560
+ }
3561
+ const runtime = runtimes.get(binding.sessionId);
3562
+ if (runtime) {
3563
+ runtime.enableNotifications();
3564
+ return "started";
3565
+ }
3566
+ const result = await startSession(binding.context);
3567
+ return result.status === "started" || result.status === "already" ? "started" : result.status;
3568
+ },
3569
+ stop: async binding => await stopSession(binding.sessionId, "notifications"),
3570
+ ensureTelegramDaemon: async binding => {
3571
+ const { settings, settingsAvailable } = resolveSettings(options.settings);
3572
+ if (!settingsAvailable || !settings) return "blocked_identity";
3573
+ try {
3574
+ return await ensureTelegramOwner(settings, binding.cwd, binding.sessionId);
3575
+ } catch {
3576
+ return "blocked_identity";
3577
+ }
3578
+ },
3579
+ };
3580
+ controller.attachRuntime(sessionRuntime);
3581
+
3582
+ api.registerCommand("notify", {
3583
+ description: "Control notifications for this session (on, off, status).",
3584
+ async handler(args: string, ctx: ExtensionCommandContext): Promise<void> {
3585
+ const id = sessionId(ctx);
3586
+ const command = args.trim().split(/\s+/, 1)[0]?.toLowerCase() || "status";
3587
+ const resolved = resolveSettings(options.settings);
3588
+ const enabledWithoutLocalOff = isSessionNotificationsEnabled({
3589
+ cfg: resolved.cfg,
3590
+ env: process.env,
3591
+ sessionDisabled: false,
3592
+ });
3593
+
3594
+ if (command === "off") {
3595
+ const result = await controller.setLocalEnabled(ctx, false);
3596
+ ctx.ui.notify(
3597
+ result.outcome === "stopped"
3598
+ ? "Notifications disabled for this session."
3599
+ : "Notifications already disabled for this session.",
3600
+ "info",
3601
+ );
3602
+ return;
3603
+ }
3604
+
3605
+ if (command === "on") {
3606
+ if (!isNotificationEligibleContext(ctx)) {
3607
+ ctx.ui.notify("Notifications are disabled for subagent sessions.", "warning");
3608
+ return;
3609
+ }
3610
+ if (process.env.GJC_NOTIFICATIONS === "0") {
3611
+ ctx.ui.notify(
3612
+ "Notifications remain disabled: GJC_NOTIFICATIONS=0 is an authoritative opt-out.",
3613
+ "warning",
3614
+ );
3615
+ return;
3616
+ }
3617
+ if (!enabledWithoutLocalOff) {
3618
+ ctx.ui.notify(
3619
+ "Notifications are not configured. Run `gjc notify setup` or set GJC_NOTIFICATIONS=1.",
3620
+ "warning",
3621
+ );
3622
+ return;
3623
+ }
3624
+ const result = await controller.setLocalEnabled(ctx, true);
3625
+ const enabled = result.status.running && result.status.effectiveEnabled;
3626
+ const rotated = sessionId(ctx) !== id;
3627
+ const failed = result.outcome === "failed" || (!enabled && !rotated && activeRuntimeId !== id);
3628
+ ctx.ui.notify(
3629
+ enabled
3630
+ ? "Notifications enabled for this session."
3631
+ : rotated
3632
+ ? "Notifications were not enabled because the active session changed during startup."
3633
+ : failed
3634
+ ? "Notifications failed to start for this session."
3635
+ : "Notifications were not enabled because daemon ownership could not be proved.",
3636
+ enabled ? "info" : rotated ? "warning" : failed ? "error" : "warning",
3637
+ );
3638
+ return;
3639
+ }
3640
+
3641
+ if (command !== "status") {
3642
+ ctx.ui.notify("Usage: /notify status | /notify on | /notify off", "warning");
3643
+ return;
3644
+ }
3645
+
3646
+ const status = controller.query(ctx);
3647
+ const runtime = runtimes.get(id);
3648
+ ctx.ui.notify(
3649
+ `Notifications ${status.running ? "running" : status.effectiveEnabled ? "enabled" : "disabled"} for this session; redaction ${(runtime?.redact ?? resolved.cfg.redact) ? "on" : "off"}; verbosity ${runtime?.verbosity ?? resolved.cfg.verbosity}${status.locallyEnabled ? "" : "; locally off"}.`,
3650
+ "info",
3651
+ );
3652
+ },
3653
+ });
3654
+
3655
+ api.on("session_start", async (_event, ctx) => {
3656
+ await startSession(ctx);
3657
+ await controller.reconcileCurrentSession(ctx);
3658
+ });
3659
+
3660
+ // A session endpoint's token and generation are authority for exactly one
3661
+ // session id. `/new`, fork, and resume must all tear down A before publishing
3662
+ // B. Chat implementations may preserve a topic as metadata, but it must never
3663
+ // preserve A's endpoint or credentials as B's control/viewing authority.
3664
+ const rotateSessionAuthority = async (
3665
+ event: { previousSessionFile?: string },
3666
+ ctx: ExtensionContext,
3667
+ ): Promise<void> => {
3668
+ const newId = sessionId(ctx);
3669
+ const prevId = activeRuntimeId ?? sessionIdFromFile(event.previousSessionFile);
3670
+ if (prevId && prevId !== newId) {
3671
+ controller.rekeySession(prevId, newId);
3672
+ await stopSession(prevId);
3673
+ }
3674
+ await startSession(ctx);
3675
+ await controller.reconcileCurrentSession(ctx);
3676
+ };
3677
+ api.on("session_switch", async (event, ctx) => {
3678
+ if (identityControlInFlight) {
3679
+ deferredIdentityRotation = { event, ctx };
3680
+ return;
3681
+ }
3682
+ await rotateSessionAuthority(event, ctx);
3683
+ });
3684
+ api.on("session_branch", async (event, ctx) => {
3685
+ if (identityControlInFlight) {
3686
+ deferredIdentityRotation = { event, ctx };
3687
+ return;
3688
+ }
3689
+ await rotateSessionAuthority(event, ctx);
3690
+ });
3691
+
3692
+ // Drive the live typing indicator: mark busy when the agent loop starts so
3693
+ // the daemon shows "typing…" in the thread while the agent is thinking,
3694
+ // before any turn output exists. Cleared on `agent_end` below.
3695
+ api.on("agent_start", (_event, ctx) => {
3696
+ const id = sessionId(ctx);
3697
+ const rt = runtimes.get(id);
3698
+ if (!rt) return;
3699
+ // Streaming state is SDK-visible session truth (context.get isStreaming);
3700
+ // it is tracked regardless of whether notifications are active.
3701
+ rt.busy = true;
3702
+ const correlation = rt.pendingPromptCorrelations.shift();
3703
+ rt.activePromptCorrelation = correlation;
3704
+ rt.emitPromptLifecycle(correlation, { type: "agent_start", sessionId: id, ...correlation });
3705
+ try {
3706
+ // `activity` is the native live-host lifecycle surface. The separately
3707
+ // emitted agent_start above is replayable with command/turn correlation.
3708
+ pushSessionFrame(rt, { type: "activity", sessionId: id, state: "busy" });
3709
+ } catch (e) {
3710
+ logger.warn(`notifications: activity (busy) failed: ${String(e)}`);
3711
+ }
3712
+ });
3713
+
3714
+ // Each turn that starts has absorbed any messages injected from the thread,
3715
+ // so ack them as "consumed": the daemon flips the queued reaction on the
3716
+ // originating Telegram message to the consumed (double-check) reaction.
3717
+ api.on("turn_start", (_event, ctx) => {
3718
+ const id = sessionId(ctx);
3719
+ const rt = runtimes.get(id);
3720
+ if (!rt?.notificationsActive) return;
3721
+ // A new turn is live: re-open the live-stream window (see turnClosed).
3722
+ rt.turnClosed = false;
3723
+ if (rt.pendingInbound.size === 0) return;
3724
+ for (const updateId of rt.pendingInbound) {
3725
+ try {
3726
+ pushSessionFrame(rt, { type: "inbound_ack", sessionId: id, updateId, state: "consumed" });
3727
+ } catch (e) {
3728
+ logger.warn(`notifications: inbound_ack failed: ${String(e)}`);
3729
+ }
3730
+ }
3731
+ rt.pendingInbound.clear();
3732
+ });
3733
+
3734
+ // Idle fires on `agent_end` (the agent loop settling to await the user), NOT
3735
+ // per `turn_end`. turn_end fires once per turn iteration, so a single
3736
+ // user-visible idle previously produced many idle pings (the flood); agent_end
3737
+ // fires exactly once per settle, yielding exactly one idle notification.
3738
+ api.on("agent_end", (_event, ctx) => {
3739
+ const id = sessionId(ctx);
3740
+ const rt = runtimes.get(id);
3741
+ if (!rt) return;
3742
+ // Clear the streaming flag for SDK consumers even when notifications are off.
3743
+ rt.busy = false;
3744
+ const correlation = rt.activePromptCorrelation;
3745
+ if (correlation) {
3746
+ if (rt.recordPromptTerminal(correlation))
3747
+ rt.emitPromptLifecycle(correlation, { type: "agent_end", sessionId: id, ...correlation });
3748
+ } else {
3749
+ rt.emitPromptLifecycle(undefined, { type: "agent_end", sessionId: id });
3750
+ }
3751
+ try {
3752
+ pushSessionFrame(rt, { type: "activity", sessionId: id, state: "idle" });
3753
+ } catch (e) {
3754
+ logger.warn(`notifications: activity (idle) failed: ${String(e)}`);
3755
+ }
3756
+ if (!rt.notificationsActive) return;
3757
+ void rt.workflowGate?.recoverAcceptedGates?.();
3758
+ const seq = rt.idleSeq++;
3759
+ // Re-assert the identity header so the daemon renames the topic once the
3760
+ // session title has been auto-generated ("{repo}/{branch} - {title}"). The
3761
+ // daemon only renames when the title actually changed.
3762
+ try {
3763
+ pushSessionFrame(rt, {
3764
+ type: "identity_header",
3765
+ sessionId: id,
3766
+ ...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
3767
+ });
3768
+ } catch {}
3769
+ try {
3770
+ rt.server.noteIdle(
3771
+ JSON.stringify(
3772
+ notificationActionPayload(
3773
+ {
3774
+ id: `idle:${id}#${seq}`,
3775
+ kind: "idle",
3776
+ sessionId: id,
3777
+ summary: undefined,
3778
+ },
3779
+ { redact: rt.redact, sessionTag: rt.sessionTag },
3780
+ ),
3781
+ ),
3782
+ );
3783
+ } catch (e) {
3784
+ logger.warn(`notifications: noteIdle failed: ${String(e)}`);
3785
+ }
3786
+
3787
+ // On idle, stream a context update with metadata (token/model usage +
3788
+ // working-tree diff) unless redaction is on. The agent's last message is
3789
+ // NOT repeated here — it is already streamed once via `turn_stream`.
3790
+ if (!rt.redact && rt.verbosity === "verbose") {
3791
+ const usage = (
3792
+ ctx as { getContextUsage?: () => { tokens: number | null; contextWindow: number } | undefined }
3793
+ ).getContextUsage?.();
3794
+ const model = (ctx as { getModel?: () => { id?: string } | undefined }).getModel?.();
3795
+ const tokenUsage = usage && usage.tokens != null ? `${usage.tokens}/${usage.contextWindow}` : undefined;
3796
+ const modelId = model?.id;
3797
+ void readGitDiffStat(ctx.cwd).then(diff => {
3798
+ const cwd = compactCwd(ctx.cwd);
3799
+ if (!diff && !tokenUsage && !modelId && !cwd) return;
3800
+ try {
3801
+ pushSessionFrame(rt, {
3802
+ type: "context_update",
3803
+ sessionId: id,
3804
+ tokenUsage,
3805
+ model: modelId,
3806
+ diff,
3807
+ cwd,
3808
+ });
3809
+ } catch (e) {
3810
+ logger.warn(`notifications: context_update failed: ${String(e)}`);
3811
+ }
3812
+ });
3813
+ }
3814
+ });
3815
+
3816
+ // Stream viable agent output per turn (the live thread mirror). Unlike idle,
3817
+ // turn output is expected to be multiple messages — one per turn that
3818
+ // produced assistant text. Tool-only turns yield no text and are skipped.
3819
+ // Redaction suppresses streamed content (only the one-time identity header
3820
+ // survives redaction). The daemon coalesces/throttles these via its shared
3821
+ // rate-limit pool before sending to Telegram.
3822
+ // Push the in-flight turn's assistant text as a finalized turn_stream, deduped
3823
+ // against what was already flushed for this turn (the pre-ask lead-in).
3824
+ const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined, finalAnswer: boolean): void => {
3825
+ if (!text || text === rt.preAskFlushedText || !rt.notificationsActive) return;
3826
+ rt.preAskFlushedText = text;
3827
+ // Decision A: a stream-enabled turn must finalize as an in-place edit of ONE
3828
+ // 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;
3834
+ rt.liveRef = String(rt.turnSeq);
3835
+ }
3836
+ try {
3837
+ pushSessionFrame(rt, {
3838
+ type: "turn_stream",
3839
+ sessionId: id,
3840
+ phase: "finalized",
3841
+ finalAnswer,
3842
+ text,
3843
+ ...(rt.liveRef ? { messageRef: rt.liveRef } : {}),
3844
+ });
3845
+ } catch (e) {
3846
+ logger.warn(`notifications: pushFrame (turn) failed: ${String(e)}`);
3847
+ }
3848
+ };
3849
+
3850
+ // Emit the assistant text that precedes an ask BEFORE the ask's action_needed
3851
+ // is broadcast, so the remote (e.g. Telegram) shows the lead-in first instead
3852
+ // of only after the ask resolves at turn_end. The text is captured on
3853
+ // message_end (which, like tool_execution_start, is on the awaited extension
3854
+ // path and ordered before it — unlike message_update, which is queued async),
3855
+ // then flushed here before the ask tool's execute calls registerAsk.
3856
+ api.on("tool_execution_start", (event, ctx) => {
3857
+ if (event.toolName !== "ask") return;
3858
+ const id = sessionId(ctx);
3859
+ const rt = runtimes.get(id);
3860
+ if (!rt?.notificationsActive || rt.redact) return;
3861
+ flushTurnText(rt, id, rt.currentTurnText, false);
3862
+ });
3863
+
3864
+ api.on("turn_end", (event, ctx) => {
3865
+ const id = sessionId(ctx);
3866
+ const rt = runtimes.get(id);
3867
+ if (!rt?.notificationsActive) return;
3868
+ const text = rt.redact ? undefined : summaryFromMessage(event.message, turnTextMax());
3869
+ if (text) flushTurnText(rt, id, text, true);
3870
+ // Reset per-turn streaming state so the next turn starts fresh and a later
3871
+ // turn with identical text is not falsely deduped.
3872
+ rt.currentTurnText = undefined;
3873
+ rt.preAskFlushedText = undefined;
3874
+ rt.liveRef = undefined;
3875
+ // Close the live-stream window: any message_update queued after turn_end is
3876
+ // dropped so it can never emit a stale live edit past the finalized turn.
3877
+ rt.turnClosed = true;
3878
+ rt.lastLiveAt = undefined;
3879
+ rt.lastLiveText = undefined;
3880
+ });
3881
+
3882
+ // Live streaming (opt-in): push throttled in-progress assistant text as
3883
+ // non-finalized turn_stream frames so remote clients edit one message as the
3884
+ // turn streams. The finalized frame (turn_end) carries the same messageRef and
3885
+ // lands the authoritative text. Suppressed under redaction.
3886
+ api.on("message_update", (event, ctx) => {
3887
+ const id = sessionId(ctx);
3888
+ const rt = runtimes.get(id);
3889
+ if (!rt?.notificationsActive || !rt.stream || rt.redact || rt.turnClosed) return;
3890
+ if ((event.message as { role?: unknown }).role !== "assistant") return;
3891
+ if (rt.liveRef === undefined) {
3892
+ rt.turnSeq = (rt.turnSeq ?? 0) + 1;
3893
+ rt.liveRef = String(rt.turnSeq);
3894
+ }
3895
+ const now = Date.now();
3896
+ if (now - (rt.lastLiveAt ?? 0) < streamIntervalMs()) return;
3897
+ const text = summaryFromMessage(event.message, 3500);
3898
+ if (!text || text === rt.lastLiveText) return;
3899
+ rt.lastLiveAt = now;
3900
+ rt.lastLiveText = text;
3901
+ try {
3902
+ pushSessionFrame(rt, { type: "turn_stream", sessionId: id, phase: "live", text, messageRef: rt.liveRef });
3903
+ } catch (e) {
3904
+ logger.warn(`notifications: pushFrame (live) failed: ${String(e)}`);
3905
+ }
3906
+ });
3907
+
3908
+ // Stream agent-produced images (computer/browser/tool screenshots) as
3909
+ // image_attachment frames; suppressed when redaction is on.
3910
+ api.on("message_end", (event, ctx) => {
3911
+ const id = sessionId(ctx);
3912
+ const rt = runtimes.get(id);
3913
+ if (!rt?.notificationsActive || rt.redact) return;
3914
+ // Capture the in-flight ASSISTANT text here (message_end is on the awaited
3915
+ // extension path and ordered before tool_execution_start) so the pre-ask
3916
+ // flush can emit it before the ask prompt. Role-scoped: message_end also
3917
+ // fires for the user prompt, which must never be mirrored back as turn output.
3918
+ if ((event.message as { role?: unknown }).role === "assistant") {
3919
+ const turnText = summaryFromMessage(event.message, turnTextMax());
3920
+ if (turnText) rt.currentTurnText = turnText;
3921
+ }
3922
+ for (const img of imageAttachmentsFromMessage(event.message)) {
3923
+ try {
3924
+ pushSessionFrame(rt, {
3925
+ type: "image_attachment",
3926
+ sessionId: id,
3927
+ source: img.source,
3928
+ mime: img.mime,
3929
+ data: img.data,
3930
+ });
3931
+ } catch (e) {
3932
+ logger.warn(`notifications: image_attachment failed: ${String(e)}`);
3933
+ }
3934
+ }
3935
+ });
3936
+
3937
+ api.on("session_shutdown", async (_event, ctx) => {
3938
+ const controllerStop =
3939
+ typeof ctx.sessionManager.getCwd === "function" ? controller.stopCurrentSession(ctx) : Promise.resolve(false);
3940
+ void controllerStop.catch(error => logger.warn(`notifications: controller shutdown failed: ${String(error)}`));
3941
+ await stopSession(sessionId(ctx));
3942
+ });
3943
+ }