@gajae-code/coding-agent 0.10.2 → 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 (465) hide show
  1. package/CHANGELOG.md +50 -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/model-selector.d.ts +1 -0
  54. package/dist/types/modes/components/notifications-settings-editor.d.ts +135 -0
  55. package/dist/types/modes/components/settings-selector.d.ts +6 -4
  56. package/dist/types/modes/components/welcome.d.ts +1 -1
  57. package/dist/types/modes/controllers/selector-controller.d.ts +35 -1
  58. package/dist/types/modes/index.d.ts +0 -4
  59. package/dist/types/modes/interactive-mode.d.ts +3 -0
  60. package/dist/types/modes/runtime-init.d.ts +2 -2
  61. package/dist/types/modes/shared/agent-wire/approval-gate.d.ts +6 -9
  62. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +4 -6
  63. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +6 -6
  64. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +4 -9
  65. package/dist/types/modes/shared/agent-wire/event-observation.d.ts +1 -1
  66. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +169 -30
  67. package/dist/types/modes/shared/agent-wire/workflow-gate-schema.d.ts +7 -7
  68. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +105 -0
  69. package/dist/types/modes/types.d.ts +5 -0
  70. package/dist/types/runtime-mcp/client.d.ts +2 -0
  71. package/dist/types/runtime-mcp/config.d.ts +5 -1
  72. package/dist/types/runtime-mcp/manager.d.ts +8 -1
  73. package/dist/types/runtime-mcp/types.d.ts +4 -0
  74. package/dist/types/sdk/acp/adapter.d.ts +67 -0
  75. package/dist/types/sdk/acp/index.d.ts +2 -0
  76. package/dist/types/sdk/broker/broker.d.ts +53 -0
  77. package/dist/types/sdk/broker/discovery.d.ts +30 -0
  78. package/dist/types/sdk/broker/ensure.d.ts +33 -0
  79. package/dist/types/sdk/broker/identity.d.ts +3 -0
  80. package/dist/types/sdk/broker/index.d.ts +8 -0
  81. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +95 -0
  82. package/dist/types/sdk/broker/lifecycle.d.ts +85 -0
  83. package/dist/types/sdk/broker/process-incarnation.d.ts +14 -0
  84. package/dist/types/sdk/broker/runtime.d.ts +33 -0
  85. package/dist/types/sdk/broker/session-index.d.ts +57 -0
  86. package/dist/types/sdk/broker/state-version.d.ts +8 -0
  87. package/dist/types/sdk/broker/transport.d.ts +9 -0
  88. package/dist/types/sdk/bus/chat-command-policy.d.ts +49 -0
  89. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +21 -0
  90. package/dist/types/sdk/bus/chat-daemon-control.d.ts +111 -0
  91. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +77 -0
  92. package/dist/types/sdk/bus/chat-effect-journal.d.ts +84 -0
  93. package/dist/types/{notifications → sdk/bus}/config-commands.d.ts +7 -2
  94. package/dist/types/sdk/bus/config.d.ts +274 -0
  95. package/dist/types/sdk/bus/conversation-store.d.ts +72 -0
  96. package/dist/types/sdk/bus/discord-conversation.d.ts +58 -0
  97. package/dist/types/sdk/bus/discord-daemon.d.ts +45 -0
  98. package/dist/types/sdk/bus/discord-live-provider.d.ts +70 -0
  99. package/dist/types/sdk/bus/discord-provider.d.ts +85 -0
  100. package/dist/types/{notifications → sdk/bus}/html-format.d.ts +2 -2
  101. package/dist/types/{notifications → sdk/bus}/index.d.ts +113 -13
  102. package/dist/types/{notifications → sdk/bus}/lifecycle-control-runtime.d.ts +4 -2
  103. package/dist/types/{notifications → sdk/bus}/lifecycle-orchestrator.d.ts +18 -21
  104. package/dist/types/{notifications → sdk/bus}/managed-daemon.d.ts +1 -1
  105. package/dist/types/sdk/bus/native-runtime-compatibility.d.ts +13 -0
  106. package/dist/types/sdk/bus/notification-orchestration.d.ts +170 -0
  107. package/dist/types/{notifications → sdk/bus}/notification-service.d.ts +7 -1
  108. package/dist/types/sdk/bus/session-control.d.ts +83 -0
  109. package/dist/types/sdk/bus/slack-conversation.d.ts +56 -0
  110. package/dist/types/sdk/bus/slack-daemon.d.ts +60 -0
  111. package/dist/types/sdk/bus/slack-live-provider.d.ts +59 -0
  112. package/dist/types/sdk/bus/slack-provider.d.ts +55 -0
  113. package/dist/types/{notifications → sdk/bus}/telegram-cli.d.ts +4 -4
  114. package/dist/types/{notifications → sdk/bus}/telegram-daemon-cli.d.ts +14 -5
  115. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -0
  116. package/dist/types/{notifications → sdk/bus}/telegram-daemon-control.d.ts +2 -2
  117. package/dist/types/{notifications → sdk/bus}/telegram-daemon.d.ts +55 -17
  118. package/dist/types/{notifications → sdk/bus}/telegram-reference.d.ts +2 -2
  119. package/dist/types/sdk/bus/telegram-setup.d.ts +128 -0
  120. package/dist/types/{notifications → sdk/bus}/topic-registry.d.ts +11 -0
  121. package/dist/types/sdk/cli/index.d.ts +1 -0
  122. package/dist/types/sdk/cli/session-cli.d.ts +19 -0
  123. package/dist/types/sdk/client/client.d.ts +1 -0
  124. package/dist/types/sdk/client/discovery.d.ts +27 -0
  125. package/dist/types/sdk/client/index.d.ts +2 -0
  126. package/dist/types/sdk/host/control/dispatch.d.ts +34 -0
  127. package/dist/types/sdk/host/control/index.d.ts +2 -0
  128. package/dist/types/sdk/host/control/operations.d.ts +65 -0
  129. package/dist/types/sdk/host/events.d.ts +35 -0
  130. package/dist/types/sdk/host/host.d.ts +35 -0
  131. package/dist/types/sdk/host/index.d.ts +4 -0
  132. package/dist/types/sdk/host/query/cursor.d.ts +62 -0
  133. package/dist/types/sdk/host/query/handlers.d.ts +70 -0
  134. package/dist/types/sdk/host/query/index.d.ts +3 -0
  135. package/dist/types/sdk/host/query/revision-store.d.ts +60 -0
  136. package/dist/types/sdk/host/reverse-leases.d.ts +45 -0
  137. package/dist/types/sdk/host/types.d.ts +32 -0
  138. package/dist/types/sdk/index.d.ts +6 -0
  139. package/dist/types/sdk/lifecycle-session.d.ts +14 -0
  140. package/dist/types/sdk/mcp/index.d.ts +1 -0
  141. package/dist/types/sdk/mcp/server.d.ts +37 -0
  142. package/dist/types/sdk/models.d.ts +44 -0
  143. package/dist/types/sdk/protocol/adapter-validation.d.ts +14 -0
  144. package/dist/types/sdk/protocol/operation-registry.d.ts +24 -0
  145. package/dist/types/{sdk.d.ts → sdk/session.d.ts} +44 -38
  146. package/dist/types/sdk/startup-capability.d.ts +50 -0
  147. package/dist/types/session/agent-session.d.ts +114 -21
  148. package/dist/types/session/default-model-selection.d.ts +25 -0
  149. package/dist/types/session/fallback-chain-controller.d.ts +52 -0
  150. package/dist/types/session/session-manager.d.ts +58 -1
  151. package/dist/types/setup/credential-auto-import.d.ts +36 -6
  152. package/dist/types/tools/ask-answer-registry.d.ts +1 -1
  153. package/dist/types/tools/browser/benchmark/detector-report.d.ts +63 -0
  154. package/dist/types/tools/browser/benchmark/run-suite.d.ts +18 -0
  155. package/dist/types/tools/browser/launch.d.ts +22 -0
  156. package/dist/types/tools/browser/profile-discovery.d.ts +31 -0
  157. package/dist/types/tools/browser/profile-posture.d.ts +37 -0
  158. package/dist/types/tools/browser/profile-reuse.d.ts +39 -0
  159. package/dist/types/tools/browser/profile-warmup.d.ts +39 -0
  160. package/dist/types/tools/browser/registry.d.ts +19 -0
  161. package/dist/types/tools/browser/tab-protocol.d.ts +4 -0
  162. package/dist/types/tools/index.d.ts +8 -8
  163. package/package.json +34 -13
  164. package/scripts/build-sdk-package-smoke.ts +94 -0
  165. package/scripts/capture-notifications-settings-showcase.ts +435 -0
  166. package/scripts/compile-args.ts +4 -2
  167. package/scripts/g005-daemon-smoke.ts +4 -5
  168. package/scripts/g011-daemon-path-smoke.ts +5 -5
  169. package/scripts/generate-sdk-adapter-parity-manifest.ts +163 -0
  170. package/scripts/generate-sdk-operation-inventory.ts +867 -0
  171. package/scripts/generate-telegram-baseline-manifest.ts +128 -0
  172. package/scripts/manifest-schema.ts +124 -0
  173. package/scripts/run-test-manifest.ts +153 -0
  174. package/scripts/run-threat-model-check.ts +157 -0
  175. package/scripts/verify-gjc-sdk-canonicalization.ts +3467 -0
  176. package/scripts/verify-gjc-sdk-rename.ts +90 -0
  177. package/src/append-only-mode.ts +1 -1
  178. package/src/cli/args.ts +43 -12
  179. package/src/cli/auth-gateway-cli.ts +2 -2
  180. package/src/cli/config-cli.ts +2 -1
  181. package/src/cli/daemon-cli.ts +63 -14
  182. package/src/cli/list-models.ts +1 -1
  183. package/src/cli/notify-cli.ts +288 -286
  184. package/src/cli.ts +21 -2
  185. package/src/commands/daemon.ts +50 -6
  186. package/src/commands/harness.ts +93 -36
  187. package/src/commands/launch.ts +5 -2
  188. package/src/commands/mcp-serve.ts +19 -12
  189. package/src/commands/notify.ts +36 -3
  190. package/src/commands/sdk.ts +426 -0
  191. package/src/commit/agentic/agent.ts +1 -0
  192. package/src/config/atomic-yaml-patch.ts +423 -0
  193. package/src/config/model-profile-activation.ts +240 -89
  194. package/src/config/model-profiles.ts +18 -15
  195. package/src/config/model-registry.ts +51 -26
  196. package/src/config/model-resolver.ts +138 -51
  197. package/src/config/model-selector-value.ts +50 -0
  198. package/src/config/models-config-schema.ts +14 -21
  199. package/src/config/settings-schema.ts +128 -10
  200. package/src/config/settings.ts +1120 -158
  201. package/src/coordinator-mcp/model-preset.ts +74 -20
  202. package/src/coordinator-mcp/server.ts +1597 -3052
  203. package/src/daemon/builtin.ts +8 -5
  204. package/src/daemon/control-types.ts +1 -1
  205. package/src/defaults/gjc/skills/team/SKILL.md +4 -4
  206. package/src/discovery/mcp-json.ts +327 -20
  207. package/src/extensibility/custom-tools/types.ts +1 -1
  208. package/src/extensibility/extensions/compact-handler.ts +3 -3
  209. package/src/extensibility/extensions/get-commands-handler.ts +2 -2
  210. package/src/extensibility/extensions/loader.ts +69 -5
  211. package/src/extensibility/extensions/runner.ts +112 -4
  212. package/src/extensibility/extensions/types.ts +176 -17
  213. package/src/extensibility/hooks/types.ts +1 -1
  214. package/src/gjc-runtime/launch-tmux.ts +170 -86
  215. package/src/gjc-runtime/psmux-detect.ts +82 -4
  216. package/src/gjc-runtime/session-state-sidecar.ts +41 -31
  217. package/src/gjc-runtime/team-runtime.ts +54 -35
  218. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +53 -17
  219. package/src/gjc-runtime/tmux-owner-isolation.ts +1 -1
  220. package/src/gjc-runtime/tmux-sessions.ts +19 -13
  221. package/src/gjc-runtime/ultragoal-guard.ts +53 -6
  222. package/src/gjc-runtime/ultragoal-runtime.ts +155 -3
  223. package/src/gjc-runtime/windows-powershell-command.ts +58 -0
  224. package/src/harness-control-plane/operate.ts +9 -9
  225. package/src/harness-control-plane/owner.ts +283 -39
  226. package/src/harness-control-plane/receipts.ts +0 -1
  227. package/src/harness-control-plane/sdk-transport.ts +395 -0
  228. package/src/harness-control-plane/session-transport.ts +94 -0
  229. package/src/harness-control-plane/types.ts +1 -1
  230. package/src/internal-urls/docs-index.generated.ts +34 -35
  231. package/src/lsp/client.ts +42 -6
  232. package/src/lsp/index.ts +2 -2
  233. package/src/main.ts +299 -204
  234. package/src/modes/DESIGN.md +258 -0
  235. package/src/modes/acp/acp-agent.ts +1228 -2612
  236. package/src/modes/acp/acp-client-bridge.ts +2 -20
  237. package/src/modes/acp/acp-event-mapper.ts +18 -2
  238. package/src/modes/acp/acp-mode.ts +8 -36
  239. package/src/modes/acp/permission-mode.ts +25 -0
  240. package/src/modes/acp/startup-options.ts +6 -0
  241. package/src/modes/components/agent-dashboard.ts +37 -17
  242. package/src/modes/components/model-selector.ts +93 -35
  243. package/src/modes/components/notifications-settings-editor.ts +1379 -0
  244. package/src/modes/components/settings-selector.ts +60 -11
  245. package/src/modes/components/welcome.ts +4 -4
  246. package/src/modes/controllers/command-controller.ts +1 -2
  247. package/src/modes/controllers/event-controller.ts +11 -16
  248. package/src/modes/controllers/extension-ui-controller.ts +348 -4
  249. package/src/modes/controllers/selector-controller.ts +674 -36
  250. package/src/modes/index.ts +0 -22
  251. package/src/modes/interactive-mode.ts +59 -20
  252. package/src/modes/runtime-init.ts +298 -6
  253. package/src/modes/shared/agent-wire/approval-gate.ts +9 -11
  254. package/src/modes/shared/agent-wire/deep-interview-gate.ts +10 -11
  255. package/src/modes/shared/agent-wire/event-contract.ts +7 -8
  256. package/src/modes/shared/agent-wire/event-envelope.ts +9 -17
  257. package/src/modes/shared/agent-wire/event-observation.ts +16 -7
  258. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +1254 -87
  259. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +15 -15
  260. package/src/modes/shared/agent-wire/workflow-gate-types.ts +113 -0
  261. package/src/modes/types.ts +5 -0
  262. package/src/runtime-mcp/client.ts +94 -19
  263. package/src/runtime-mcp/config.ts +27 -15
  264. package/src/runtime-mcp/manager.ts +343 -105
  265. package/src/runtime-mcp/oauth-flow.ts +42 -12
  266. package/src/runtime-mcp/transports/http.ts +49 -19
  267. package/src/runtime-mcp/transports/stdio.ts +47 -28
  268. package/src/runtime-mcp/types.ts +10 -0
  269. package/src/sdk/acp/adapter.ts +418 -0
  270. package/src/sdk/acp/index.ts +2 -0
  271. package/src/sdk/broker/broker.ts +642 -0
  272. package/src/sdk/broker/discovery.ts +94 -0
  273. package/src/sdk/broker/ensure.ts +249 -0
  274. package/src/sdk/broker/identity.ts +42 -0
  275. package/src/sdk/broker/index.ts +8 -0
  276. package/src/sdk/broker/internal-source-marker-2178.txt +1 -0
  277. package/src/sdk/broker/internal-source.bunfig.toml +1 -0
  278. package/src/sdk/broker/lifecycle-ledger.ts +286 -0
  279. package/src/sdk/broker/lifecycle.ts +2157 -0
  280. package/src/sdk/broker/process-incarnation.ts +159 -0
  281. package/src/sdk/broker/runtime.ts +164 -0
  282. package/src/sdk/broker/session-index.ts +318 -0
  283. package/src/sdk/broker/state-version.ts +23 -0
  284. package/src/sdk/broker/transport.ts +125 -0
  285. package/src/sdk/bus/chat-command-policy.ts +107 -0
  286. package/src/sdk/bus/chat-daemon-cli.ts +214 -0
  287. package/src/sdk/bus/chat-daemon-control.ts +713 -0
  288. package/src/sdk/bus/chat-daemon-runtime.ts +526 -0
  289. package/src/sdk/bus/chat-effect-journal.ts +353 -0
  290. package/src/{notifications → sdk/bus}/config-commands.ts +39 -10
  291. package/src/sdk/bus/config.ts +433 -0
  292. package/src/sdk/bus/conversation-store.ts +391 -0
  293. package/src/sdk/bus/discord-conversation.ts +85 -0
  294. package/src/sdk/bus/discord-daemon.ts +2275 -0
  295. package/src/sdk/bus/discord-live-provider.ts +521 -0
  296. package/src/sdk/bus/discord-provider.ts +56 -0
  297. package/src/{notifications → sdk/bus}/html-format.ts +2 -2
  298. package/src/sdk/bus/index.ts +3943 -0
  299. package/src/{notifications → sdk/bus}/lifecycle-commands.ts +2 -0
  300. package/src/{notifications → sdk/bus}/lifecycle-control-runtime.ts +225 -196
  301. package/src/{notifications → sdk/bus}/lifecycle-orchestrator.ts +161 -74
  302. package/src/{notifications → sdk/bus}/managed-daemon.ts +2 -2
  303. package/src/sdk/bus/native-runtime-compatibility.ts +42 -0
  304. package/src/sdk/bus/natives-augment.d.ts +38 -0
  305. package/src/sdk/bus/notification-orchestration.ts +535 -0
  306. package/src/{notifications → sdk/bus}/notification-service.ts +106 -12
  307. package/src/sdk/bus/session-control.ts +333 -0
  308. package/src/sdk/bus/slack-conversation.ts +74 -0
  309. package/src/sdk/bus/slack-daemon.ts +2144 -0
  310. package/src/sdk/bus/slack-live-provider.ts +380 -0
  311. package/src/sdk/bus/slack-provider.ts +76 -0
  312. package/src/{notifications → sdk/bus}/telegram-cli.ts +8 -8
  313. package/src/sdk/bus/telegram-daemon-cli.ts +330 -0
  314. package/src/sdk/bus/telegram-daemon-contract.ts +14 -0
  315. package/src/{notifications → sdk/bus}/telegram-daemon-control.ts +4 -4
  316. package/src/{notifications → sdk/bus}/telegram-daemon.ts +502 -54
  317. package/src/{notifications → sdk/bus}/telegram-reference.ts +2 -2
  318. package/src/sdk/bus/telegram-setup.ts +663 -0
  319. package/src/{notifications → sdk/bus}/topic-registry.ts +32 -0
  320. package/src/sdk/cli/index.ts +1 -0
  321. package/src/sdk/cli/session-cli.ts +275 -0
  322. package/src/sdk/client/client.ts +1 -0
  323. package/src/sdk/client/discovery.ts +108 -0
  324. package/src/sdk/client/index.ts +2 -0
  325. package/src/sdk/host/control/dispatch.ts +367 -0
  326. package/src/sdk/host/control/index.ts +10 -0
  327. package/src/sdk/host/control/operations.ts +63 -0
  328. package/src/sdk/host/events.ts +91 -0
  329. package/src/sdk/host/host.ts +296 -0
  330. package/src/sdk/host/index.ts +4 -0
  331. package/src/sdk/host/query/cursor.ts +189 -0
  332. package/src/sdk/host/query/handlers.ts +533 -0
  333. package/src/sdk/host/query/index.ts +3 -0
  334. package/src/sdk/host/query/revision-store.ts +1126 -0
  335. package/src/sdk/host/reverse-leases.ts +325 -0
  336. package/src/sdk/host/types.ts +29 -0
  337. package/src/sdk/index.ts +13 -0
  338. package/src/sdk/lifecycle-session.ts +39 -0
  339. package/src/sdk/mcp/index.ts +1 -0
  340. package/src/sdk/mcp/server.ts +332 -0
  341. package/src/sdk/models.ts +168 -0
  342. package/src/sdk/protocol/adapter-validation.ts +48 -0
  343. package/src/sdk/protocol/operation-inventory.generated.json +5654 -0
  344. package/src/sdk/protocol/operation-registry.ts +312 -0
  345. package/src/{sdk.ts → sdk/session.ts} +339 -212
  346. package/src/sdk/startup-capability.ts +189 -0
  347. package/src/session/agent-session.ts +2453 -894
  348. package/src/session/default-model-selection.ts +93 -0
  349. package/src/session/fallback-chain-controller.ts +136 -0
  350. package/src/session/session-manager.ts +438 -11
  351. package/src/session/session-storage.ts +8 -1
  352. package/src/setup/credential-auto-import.ts +290 -35
  353. package/src/setup/hermes/templates/operator-instructions.v1.md +3 -5
  354. package/src/slash-commands/builtin-registry.ts +2 -1
  355. package/src/task/executor.ts +62 -13
  356. package/src/tools/ask-answer-registry.ts +1 -1
  357. package/src/tools/ask.ts +13 -8
  358. package/src/tools/browser/STEALTH-THIRD-PARTY-NOTICES.md +26 -0
  359. package/src/tools/browser/benchmark/detector-report.ts +157 -0
  360. package/src/tools/browser/benchmark/run-suite.ts +39 -0
  361. package/src/tools/browser/launch.ts +124 -19
  362. package/src/tools/browser/profile-discovery.ts +63 -0
  363. package/src/tools/browser/profile-posture.ts +59 -0
  364. package/src/tools/browser/profile-reuse.ts +102 -0
  365. package/src/tools/browser/profile-warmup.ts +109 -0
  366. package/src/tools/browser/registry.ts +87 -11
  367. package/src/tools/browser/tab-protocol.ts +1 -0
  368. package/src/tools/browser/tab-supervisor.ts +1 -0
  369. package/src/tools/browser/tab-worker.ts +1 -1
  370. package/src/tools/browser.ts +6 -0
  371. package/src/tools/index.ts +8 -8
  372. package/src/tools/puppeteer/14_stealth_permissions.txt +57 -0
  373. package/src/tools/puppeteer/15_stealth_webrtc.txt +60 -0
  374. package/src/tools/telegram-send.ts +2 -2
  375. package/vendor/insane-search/MANIFEST.json +2 -1
  376. package/vendor/insane-search/engine/bias_check.py +4 -1
  377. package/vendor/insane-search/engine/phase0.py +26 -1
  378. package/dist/types/harness-control-plane/rpc-adapter.d.ts +0 -69
  379. package/dist/types/modes/bridge/auth.d.ts +0 -12
  380. package/dist/types/modes/bridge/bridge-client-bridge.d.ts +0 -9
  381. package/dist/types/modes/bridge/bridge-mode.d.ts +0 -47
  382. package/dist/types/modes/bridge/bridge-ui-context.d.ts +0 -88
  383. package/dist/types/modes/bridge/event-stream.d.ts +0 -8
  384. package/dist/types/modes/rpc/host-tools.d.ts +0 -1
  385. package/dist/types/modes/rpc/host-uris.d.ts +0 -1
  386. package/dist/types/modes/rpc/rpc-client.d.ts +0 -299
  387. package/dist/types/modes/rpc/rpc-mode.d.ts +0 -86
  388. package/dist/types/modes/rpc/rpc-socket-security.d.ts +0 -10
  389. package/dist/types/modes/rpc/rpc-types.d.ts +0 -810
  390. package/dist/types/modes/shared/agent-wire/command-contract.d.ts +0 -18
  391. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +0 -37
  392. package/dist/types/modes/shared/agent-wire/command-validation.d.ts +0 -2
  393. package/dist/types/modes/shared/agent-wire/handshake.d.ts +0 -56
  394. package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +0 -16
  395. package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +0 -17
  396. package/dist/types/modes/shared/agent-wire/protocol.d.ts +0 -25
  397. package/dist/types/modes/shared/agent-wire/responses.d.ts +0 -4
  398. package/dist/types/modes/shared/agent-wire/scopes.d.ts +0 -18
  399. package/dist/types/modes/shared/agent-wire/session-registry.d.ts +0 -25
  400. package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +0 -42
  401. package/dist/types/modes/shared/agent-wire/ui-result.d.ts +0 -27
  402. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +0 -29
  403. package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +0 -68
  404. package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +0 -166
  405. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +0 -127
  406. package/dist/types/notifications/config.d.ts +0 -132
  407. package/src/harness-control-plane/rpc-adapter.ts +0 -290
  408. package/src/modes/bridge/auth.ts +0 -41
  409. package/src/modes/bridge/bridge-client-bridge.ts +0 -47
  410. package/src/modes/bridge/bridge-mode.ts +0 -675
  411. package/src/modes/bridge/bridge-ui-context.ts +0 -200
  412. package/src/modes/bridge/event-stream.ts +0 -70
  413. package/src/modes/rpc/host-tools.ts +0 -1
  414. package/src/modes/rpc/host-uris.ts +0 -1
  415. package/src/modes/rpc/rpc-client.ts +0 -1166
  416. package/src/modes/rpc/rpc-mode.ts +0 -1029
  417. package/src/modes/rpc/rpc-socket-security.ts +0 -115
  418. package/src/modes/rpc/rpc-types.ts +0 -637
  419. package/src/modes/shared/agent-wire/command-contract.ts +0 -18
  420. package/src/modes/shared/agent-wire/command-dispatch.ts +0 -498
  421. package/src/modes/shared/agent-wire/command-validation.ts +0 -181
  422. package/src/modes/shared/agent-wire/handshake.ts +0 -157
  423. package/src/modes/shared/agent-wire/host-tool-bridge.ts +0 -197
  424. package/src/modes/shared/agent-wire/host-uri-bridge.ts +0 -236
  425. package/src/modes/shared/agent-wire/protocol.ts +0 -46
  426. package/src/modes/shared/agent-wire/responses.ts +0 -17
  427. package/src/modes/shared/agent-wire/scopes.ts +0 -94
  428. package/src/modes/shared/agent-wire/session-registry.ts +0 -109
  429. package/src/modes/shared/agent-wire/ui-request-broker.ts +0 -150
  430. package/src/modes/shared/agent-wire/ui-result.ts +0 -48
  431. package/src/modes/shared/agent-wire/unattended-action-policy.ts +0 -365
  432. package/src/modes/shared/agent-wire/unattended-audit.ts +0 -176
  433. package/src/modes/shared/agent-wire/unattended-run-controller.ts +0 -426
  434. package/src/modes/shared/agent-wire/unattended-session.ts +0 -528
  435. package/src/notifications/config.ts +0 -219
  436. package/src/notifications/index.ts +0 -1835
  437. package/src/notifications/telegram-daemon-cli.ts +0 -243
  438. package/src/tools/puppeteer/09_stealth_locale.txt +0 -46
  439. /package/dist/types/{notifications → sdk/bus}/attachment-registry.d.ts +0 -0
  440. /package/dist/types/{notifications → sdk/bus}/chat-adapters.d.ts +0 -0
  441. /package/dist/types/{notifications → sdk/bus}/daemon-paths.d.ts +0 -0
  442. /package/dist/types/{notifications → sdk/bus}/engine.d.ts +0 -0
  443. /package/dist/types/{notifications → sdk/bus}/helpers.d.ts +0 -0
  444. /package/dist/types/{notifications → sdk/bus}/lifecycle-commands.d.ts +0 -0
  445. /package/dist/types/{notifications → sdk/bus}/operator-runtime.d.ts +0 -0
  446. /package/dist/types/{notifications → sdk/bus}/rate-limit-pool.d.ts +0 -0
  447. /package/dist/types/{notifications → sdk/bus}/recent-activity.d.ts +0 -0
  448. /package/dist/types/{notifications → sdk/bus}/reply-sent-store.d.ts +0 -0
  449. /package/dist/types/{notifications → sdk/bus}/rich-draft.d.ts +0 -0
  450. /package/dist/types/{notifications → sdk/bus}/rich-render.d.ts +0 -0
  451. /package/dist/types/{notifications → sdk/bus}/threaded-inbound.d.ts +0 -0
  452. /package/dist/types/{notifications → sdk/bus}/threaded-render.d.ts +0 -0
  453. /package/src/{notifications → sdk/bus}/attachment-registry.ts +0 -0
  454. /package/src/{notifications → sdk/bus}/chat-adapters.ts +0 -0
  455. /package/src/{notifications → sdk/bus}/daemon-paths.ts +0 -0
  456. /package/src/{notifications → sdk/bus}/engine.ts +0 -0
  457. /package/src/{notifications → sdk/bus}/helpers.ts +0 -0
  458. /package/src/{notifications → sdk/bus}/operator-runtime.ts +0 -0
  459. /package/src/{notifications → sdk/bus}/rate-limit-pool.ts +0 -0
  460. /package/src/{notifications → sdk/bus}/recent-activity.ts +0 -0
  461. /package/src/{notifications → sdk/bus}/reply-sent-store.ts +0 -0
  462. /package/src/{notifications → sdk/bus}/rich-draft.ts +0 -0
  463. /package/src/{notifications → sdk/bus}/rich-render.ts +0 -0
  464. /package/src/{notifications → sdk/bus}/threaded-inbound.ts +0 -0
  465. /package/src/{notifications → sdk/bus}/threaded-render.ts +0 -0
@@ -1,1835 +0,0 @@
1
- /**
2
- * Notifications extension.
3
- *
4
- * Hosts a per-session loopback WebSocket notification server (the Rust core via
5
- * N-API) and bridges GJC session events + the `ask` tool to it so a remote client
6
- * (e.g. a Telegram bot) can both see action-needed signals and ANSWER them —
7
- * without requiring RPC/unattended mode:
8
- *
9
- * - `ask` (interactive): registers an {@link AskAnswerSource}; the ask tool races
10
- * the local UI against a remote reply. First valid answer wins; a local answer
11
- * aborts the remote wait (and broadcasts `action_resolved` resolvedBy=local).
12
- * - `ask` (unattended/RPC): observes emitted workflow gates and resolves the real
13
- * gate on a remote reply via `ctx.workflowGate`.
14
- * - `turn_end` -> `action_needed` (kind `idle`, deduped per turn).
15
- * - `session_shutdown` -> `session_closed` frame, stop server, deregister answer source.
16
- *
17
- * Enable with Settings notifications config, `GJC_NOTIFICATIONS=1` (a token is
18
- * generated), or `GJC_NOTIFICATIONS_TOKEN`.
19
- */
20
-
21
- import { execFile } from "node:child_process";
22
- import * as crypto from "node:crypto";
23
- import * as fs from "node:fs";
24
- import * as os from "node:os";
25
- import * as path from "node:path";
26
- import { promisify } from "node:util";
27
- import { ThinkingLevel } from "@gajae-code/agent-core";
28
- import type { ImageContent, TextContent } from "@gajae-code/ai";
29
- import { NotificationServer } from "@gajae-code/natives";
30
- import { logger, postmortem } from "@gajae-code/utils";
31
- import { Settings } from "../config/settings";
32
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../extensibility/extensions";
33
- import type {
34
- WorkflowGateEmitter,
35
- WorkflowGateTerminalController,
36
- } from "../modes/shared/agent-wire/unattended-session";
37
- import { parseThinkingLevel } from "../thinking";
38
- import type {
39
- AskAnswerRequest,
40
- AskAnswerSourceResult,
41
- AskRemoteControl,
42
- AskRemoteInteraction,
43
- AskRemoteReceipt,
44
- AskSelectedAckOutcome,
45
- AskSettlement,
46
- AskSettlementResult,
47
- } from "../tools";
48
- import { registerAskAnswerSource, registerWorkflowGateEmitterListener } from "../tools/ask-answer-registry";
49
- import { registerTelegramFileSink } from "./attachment-registry";
50
- import {
51
- getNotificationConfig,
52
- isSessionNotificationsEnabled,
53
- isTelegramConfigured,
54
- type NotificationConfig,
55
- sessionTag,
56
- } from "./config";
57
- import { imageAttachmentsFromMessage, notificationActionPayload, summaryFromMessage } from "./helpers";
58
- import { ensureTelegramDaemonRunning } from "./telegram-daemon";
59
-
60
- // ===========================================================================
61
- // Session lifecycle control protocol (TypeScript mirror of the Rust wire
62
- // contract in `crates/gjc-notifications/src/lifecycle.rs`).
63
- //
64
- // These describe the frames exchanged over the daemon-owned, session-independent
65
- // control endpoint for remote session create / close / resume. Field names are
66
- // camelCase on the wire; `type`/`kind` discriminators are snake_case. The Rust
67
- // ingress authenticates and forwards; the daemon (TypeScript) owns all policy,
68
- // spawn orchestration, idempotency, rate limiting, audit, and UX.
69
- // ===========================================================================
70
-
71
- /** Where a `session_create` should run. Discriminated by `kind`. */
72
- export type SessionCreateTarget =
73
- | { kind: "existing_path"; path: string }
74
- | { kind: "worktree"; repo: string; branch: string }
75
- | { kind: "plain_dir"; path: string };
76
-
77
- /** Identifies the session a `session_close` targets. */
78
- export interface SessionCloseTarget {
79
- sessionId: string;
80
- /** Expected GJC-managed tmux session name (defense-in-depth match). */
81
- tmuxSession?: string;
82
- /** Expected `@gjc-session-state-file` tag (defense-in-depth match). */
83
- sessionStateFile?: string;
84
- }
85
-
86
- /** Identifies the session a `session_resume` targets. */
87
- export interface SessionResumeTarget {
88
- sessionIdOrPrefix: string;
89
- /** Optional repo/working-dir hint to disambiguate matches. */
90
- path?: string;
91
- }
92
-
93
- /** Create a new session. */
94
- export interface SessionCreateFrame {
95
- type: "session_create";
96
- requestId: string;
97
- /** Deterministic lifecycle marker preallocated by the daemon before spawn. */
98
- lifecycleRequestId: string;
99
- /** Session id the daemon preallocated and propagates to the child. */
100
- intendedSessionId: string;
101
- /** Telegram update id (idempotency key on the daemon side). */
102
- updateId: number;
103
- chatId: string;
104
- /** Control-endpoint token authorizing this frame. */
105
- token: string;
106
- target: SessionCreateTarget;
107
- /** Reference to the daemon-written, once-consumed startup-prompt file. */
108
- startupPromptRef?: string;
109
- /** Model profile preset to activate for the spawned session (--mpreset). */
110
- modelPreset?: string;
111
- }
112
-
113
- /** Close (hard-kill, history preserved) a session. */
114
- export interface SessionCloseFrame {
115
- type: "session_close";
116
- requestId: string;
117
- updateId: number;
118
- chatId: string;
119
- token: string;
120
- target: SessionCloseTarget;
121
- /** Required force-only close flag; false/omitted is rejected by daemon policy. */
122
- force?: boolean;
123
- }
124
-
125
- /** Resume a session (reattach if alive, else cold-restart from history). */
126
- export interface SessionResumeFrame {
127
- type: "session_resume";
128
- requestId: string;
129
- updateId: number;
130
- chatId: string;
131
- token: string;
132
- target: SessionResumeTarget;
133
- startupPromptRef?: string;
134
- }
135
-
136
- /** Any client -> ingress lifecycle request frame. */
137
- export type SessionLifecycleRequest = SessionCreateFrame | SessionCloseFrame | SessionResumeFrame;
138
-
139
- /** Terminal status of a lifecycle request. */
140
- export type LifecycleStatus = "ok" | "error";
141
-
142
- /** A connected session's per-session endpoint, returned to the control client. */
143
- export interface LifecycleEndpoint {
144
- url: string;
145
- token: string;
146
- }
147
-
148
- /** The Telegram topic/thread a session is surfaced in. */
149
- export interface LifecycleTopic {
150
- chatId: string;
151
- threadId: string;
152
- }
153
-
154
- /** How a create request was correlated to its spawned session. */
155
- export type MatchedBy = "spawn_marker" | "session_ready";
156
-
157
- /** Response to a successful `session_create`. */
158
- export interface SessionCreateResponseFrame {
159
- type: "session_create_response";
160
- requestId: string;
161
- status: LifecycleStatus;
162
- lifecycleRequestId: string;
163
- sessionId: string;
164
- matchedBy: MatchedBy;
165
- endpoint: LifecycleEndpoint;
166
- topic: LifecycleTopic;
167
- target: SessionCreateTarget;
168
- }
169
-
170
- /** Response to a successful `session_close`. */
171
- export interface SessionCloseResponseFrame {
172
- type: "session_close_response";
173
- requestId: string;
174
- status: LifecycleStatus;
175
- sessionId: string;
176
- processGone: boolean;
177
- historyPreserved: boolean;
178
- endpointStale: boolean;
179
- }
180
-
181
- /** Whether a resume reattached to a live session or cold-restarted a dead one. */
182
- export type ResumeMode = "reattached" | "cold_restarted";
183
-
184
- /** Response to a successful `session_resume`. */
185
- export interface SessionResumeResponseFrame {
186
- type: "session_resume_response";
187
- requestId: string;
188
- status: LifecycleStatus;
189
- sessionId: string;
190
- mode: ResumeMode;
191
- endpoint: LifecycleEndpoint;
192
- topic: LifecycleTopic;
193
- }
194
-
195
- /** Machine-readable reason a lifecycle request failed. */
196
- export type LifecycleErrorReason =
197
- | "unauthorized"
198
- | "rate_limited"
199
- | "duplicate_conflict"
200
- | "invalid_target"
201
- | "ambiguous_target"
202
- | "spawn_failed"
203
- | "discovery_timeout"
204
- | "readiness_timeout"
205
- | "close_refused"
206
- | "not_found"
207
- | "terminal_uncertain";
208
-
209
- /** A candidate returned with an `ambiguous_target` resume error. */
210
- export interface ResumeCandidate {
211
- sessionId: string;
212
- path?: string;
213
- /** Last-activity epoch-millis (session history file mtime), if known. */
214
- mtimeMs?: number;
215
- }
216
-
217
- /** A structured lifecycle error frame. */
218
- export interface SessionLifecycleErrorFrame {
219
- type: "session_lifecycle_error";
220
- requestId: string;
221
- status: LifecycleStatus;
222
- reason: LifecycleErrorReason;
223
- message: string;
224
- candidates?: ResumeCandidate[];
225
- }
226
-
227
- /** Any ingress -> client lifecycle response frame. */
228
- export type SessionLifecycleResponse =
229
- | SessionCreateResponseFrame
230
- | SessionCloseResponseFrame
231
- | SessionResumeResponseFrame
232
- | SessionLifecycleErrorFrame;
233
-
234
- /**
235
- * Replayable per-session readiness signal (mirror of the Rust `session_ready`
236
- * frame). Buffered and replayed to late clients so WS-open alone never implies
237
- * the session is live and surfaced.
238
- */
239
- export interface SessionReadyFrame {
240
- type: "session_ready";
241
- sessionId: string;
242
- lifecycleRequestId?: string;
243
- startupPromptRef?: string;
244
- repo?: string;
245
- branch?: string;
246
- title?: string;
247
- }
248
-
249
- /** Resolve the git dir for `cwd`, handling worktrees where `.git` is a file. */
250
- function gitDir(cwd: string): string | undefined {
251
- const dot = path.join(cwd, ".git");
252
- try {
253
- if (fs.statSync(dot).isDirectory()) return dot;
254
- const m = fs
255
- .readFileSync(dot, "utf8")
256
- .trim()
257
- .match(/^gitdir:\s*(.+)$/);
258
- if (m) return path.resolve(cwd, m[1]);
259
- } catch {}
260
- return undefined;
261
- }
262
-
263
- /** Best-effort current branch from `.git/HEAD` (no git spawn). */
264
- function readGitBranch(cwd: string): string | undefined {
265
- const gd = gitDir(cwd);
266
- if (!gd) return undefined;
267
- try {
268
- const head = fs.readFileSync(path.join(gd, "HEAD"), "utf8").trim();
269
- const m = head.match(/^ref:\s*refs\/heads\/(.+)$/);
270
- return m ? m[1] : head.slice(0, 12);
271
- } catch {
272
- return undefined;
273
- }
274
- }
275
-
276
- /** Resolve the shared git dir (the main repo's `.git`) for a possibly-linked worktree. */
277
- function gitCommonDir(gd: string): string {
278
- try {
279
- const raw = fs.readFileSync(path.join(gd, "commondir"), "utf8").trim();
280
- if (raw) return path.resolve(gd, raw);
281
- } catch {}
282
- return gd;
283
- }
284
-
285
- /**
286
- * Best-effort real repository name (no git spawn): resolves the main worktree
287
- * root directory so linked worktrees report the repo (e.g. `gajae-code`)
288
- * instead of the worktree directory (e.g. `feat-foo-01047f11`).
289
- */
290
- export function readGitRepoName(cwd: string): string | undefined {
291
- const gd = gitDir(cwd);
292
- if (!gd) return undefined;
293
- const commonDir = gitCommonDir(gd);
294
- // Strip the trailing `.git` to land on the main worktree root directory.
295
- const repoRoot = path.basename(commonDir) === ".git" ? path.dirname(commonDir) : commonDir;
296
- const name = path.basename(repoRoot);
297
- return name && name !== ".git" ? name : undefined;
298
- }
299
-
300
- /** Build the one-time identity header fields for a session thread. */
301
- function buildIdentity(
302
- cwd: string,
303
- sessionName?: string,
304
- ): {
305
- repo: string;
306
- branch: string;
307
- machine: string;
308
- title?: string;
309
- } {
310
- const repo = readGitRepoName(cwd) ?? (path.basename(cwd) || cwd);
311
- const branch = readGitBranch(cwd) ?? "(detached)";
312
- // Send repo/branch and the raw session title separately; the consumer
313
- // composes the topic name ("{repo}/{branch}" before the session title is
314
- // auto-generated, then "{repo}/{branch} - {session title}" once it exists).
315
- return { repo, branch, machine: os.hostname(), title: sessionName };
316
- }
317
-
318
- /** Compact cwd label for remote session identity; never emits the full host path by default. */
319
- function compactCwd(cwd: string): string | undefined {
320
- const home = os.homedir();
321
- const resolved = path.resolve(cwd);
322
- if (resolved === home) return "~";
323
- const base = path.basename(resolved);
324
- return base || path.parse(resolved).root || undefined;
325
- }
326
-
327
- const execFileAsync = promisify(execFile);
328
-
329
- /** Best-effort working-tree diff stat for the context update (no throw). */
330
- async function readGitDiffStat(cwd: string): Promise<string | undefined> {
331
- try {
332
- const { stdout } = await execFileAsync("git", ["-C", cwd, "diff", "--stat", "--no-color"], {
333
- timeout: 3000,
334
- maxBuffer: 256 * 1024,
335
- });
336
- const trimmed = stdout.trim();
337
- return trimmed ? trimmed.slice(0, 1500) : undefined;
338
- } catch {
339
- return undefined;
340
- }
341
- }
342
-
343
- interface PendingInteractiveAsk {
344
- resolve: (result: AskAnswerSourceResult) => void;
345
- options: string[];
346
- controls: readonly AskRemoteControl[];
347
- reissue: () => boolean;
348
- }
349
-
350
- interface UnattendedGatePresentation {
351
- gateId: string;
352
- sessionId: string;
353
- question: string;
354
- options: string[];
355
- controls: readonly AskRemoteControl[];
356
- multi: boolean;
357
- allowEmpty: boolean;
358
- navigationLabel?: "Next" | "Done";
359
- selectedOptions: string[];
360
- }
361
-
362
- /** Keeps transport interaction ids separate from durable workflow gate ids. */
363
- class GatePresentationRegistry {
364
- private readonly presentations = new Map<string, UnattendedGatePresentation>();
365
- private readonly routes = new Map<string, string>();
366
-
367
- constructor(
368
- private readonly server: NotificationServer,
369
- private readonly redact: () => boolean,
370
- private readonly tag: string,
371
- ) {}
372
-
373
- retain(presentation: UnattendedGatePresentation): void {
374
- this.presentations.set(presentation.gateId, presentation);
375
- }
376
-
377
- routeFor(actionId: string): string | undefined {
378
- return this.routes.get(actionId);
379
- }
380
-
381
- presentationFor(actionId: string): UnattendedGatePresentation | undefined {
382
- const gateId = this.routes.get(actionId);
383
- return gateId ? this.presentations.get(gateId) : undefined;
384
- }
385
-
386
- toggle(actionId: string, label: string): boolean {
387
- const presentation = this.presentationFor(actionId);
388
- if (!presentation?.multi || !presentation.options.includes(label)) return false;
389
- this.closeInteraction(actionId, "toggle");
390
- const selected = new Set(presentation.selectedOptions);
391
- if (selected.has(label)) selected.delete(label);
392
- else selected.add(label);
393
- presentation.selectedOptions = [...selected];
394
- this.reissue(presentation.gateId);
395
- return true;
396
- }
397
-
398
- reissue(gateId: string): string | undefined {
399
- const presentation = this.presentations.get(gateId);
400
- if (!presentation) return undefined;
401
- const actionId = `gate-interaction:${crypto.randomUUID()}`;
402
- this.routes.set(actionId, gateId);
403
- try {
404
- this.server.registerAsk(
405
- JSON.stringify(
406
- notificationActionPayload(
407
- {
408
- id: actionId,
409
- kind: "ask",
410
- sessionId: presentation.sessionId,
411
- question:
412
- presentation.selectedOptions.length > 0
413
- ? `(${presentation.selectedOptions.length} selected) ${presentation.question}`
414
- : presentation.question,
415
- options: presentation.options,
416
- controls: presentation.multi
417
- ? [
418
- {
419
- id: "navigation_forward",
420
- kind: "navigation",
421
- label: presentation.navigationLabel ?? "Done",
422
- enabled: presentation.allowEmpty || presentation.selectedOptions.length > 0,
423
- },
424
- ]
425
- : [],
426
- },
427
- { redact: this.redact(), sessionTag: this.tag },
428
- ),
429
- ),
430
- true,
431
- );
432
- return actionId;
433
- } catch (error) {
434
- this.routes.delete(actionId);
435
- logger.warn(`notifications: registerAsk (gate interaction) failed: ${String(error)}`);
436
- return undefined;
437
- }
438
- }
439
-
440
- closeInteraction(actionId: string, reason: string): void {
441
- this.routes.delete(actionId);
442
- try {
443
- this.server.resolveLocal(actionId, undefined);
444
- } catch {
445
- // The native claim close already terminalizes this action when applicable.
446
- }
447
- void reason;
448
- }
449
-
450
- complete(gateId: string): void {
451
- for (const [actionId, routeGateId] of this.routes) {
452
- if (routeGateId !== gateId) continue;
453
- this.closeInteraction(actionId, "gate_complete");
454
- }
455
- this.presentations.delete(gateId);
456
- }
457
-
458
- cancel(gateId: string, reason: string): void {
459
- this.complete(gateId);
460
- void reason;
461
- }
462
-
463
- dispose(): void {
464
- for (const gateId of [...this.presentations.keys()]) this.cancel(gateId, "session_shutdown");
465
- }
466
- }
467
-
468
- interface SessionRuntime {
469
- server: NotificationServer;
470
- idleSeq: number;
471
- /** Interactive asks awaiting a remote answer, by action id. */
472
- pendingInteractive: Map<string, PendingInteractiveAsk>;
473
- /** Deregisters this session's ask answer source. */
474
- disposeAnswerSource: () => void;
475
- /** Deregisters this session's Telegram file sink. */
476
- disposeFileSink: () => void;
477
- /** Deregisters this session's unattended workflow-gate listener. */
478
- disposeGateListener: () => void;
479
- /** Deregisters canonical workflow-gate terminal cleanup. */
480
- disposeGateTerminalController: () => void;
481
- disposeAckRecoveryParticipant: () => void;
482
- disposeGateEmitterListener: () => void;
483
- workflowGate?: WorkflowGateEmitter;
484
- gatePresentations?: GatePresentationRegistry;
485
- redact: boolean;
486
- verbosity: "lean" | "verbose";
487
- sessionTag: string;
488
- /** Whether the agent loop is currently running (drives the typing indicator). */
489
- busy: boolean;
490
- /** Inbound Telegram update ids injected but not yet consumed by a turn. */
491
- pendingInbound: Set<number>;
492
- /** Latest assistant text of the in-flight turn (from message_update). */
493
- currentTurnText?: string;
494
- /** Assistant text already flushed before an ask this turn (turn-scoped dedupe
495
- * so turn_end does not re-emit the pre-ask lead-in). Reset each turn. */
496
- preAskFlushedText?: string;
497
- /** Live streaming: opt-in flag, monotonic per-turn ref, and emit throttle state. */
498
- stream: boolean;
499
- turnSeq?: number;
500
- liveRef?: string;
501
- lastLiveAt?: number;
502
- lastLiveText?: string;
503
- /** True between turn_end and the next turn_start: drops late async message_update
504
- * frames so a stale live edit can never be emitted after the finalized turn. */
505
- turnClosed?: boolean;
506
- /** Cancels the postmortem cleanup that emits `session_closed` on process teardown. */
507
- cancelPostmortemCleanup: () => void;
508
- }
509
-
510
- interface ResolvedSettings {
511
- settings: Settings | undefined;
512
- cfg: NotificationConfig;
513
- settingsAvailable: boolean;
514
- }
515
-
516
- const TELEGRAM_FILE_REDACTION_ERROR = "Telegram file attachments are disabled while notifications redaction is on.";
517
-
518
- const defaultConfig: NotificationConfig = {
519
- enabled: false,
520
- botToken: undefined,
521
- chatId: undefined,
522
- discord: {
523
- botToken: undefined,
524
- channelId: undefined,
525
- },
526
- slack: {
527
- botToken: undefined,
528
- channelId: undefined,
529
- },
530
- redact: false,
531
- verbosity: "lean",
532
- sessionScope: "all",
533
- idleTimeoutMs: 60_000,
534
- rich: { enabled: true },
535
- richDraft: { enabled: false },
536
- topics: {},
537
- };
538
-
539
- export function notificationsEnabled(): boolean {
540
- return process.env.GJC_NOTIFICATIONS === "1" || Boolean(process.env.GJC_NOTIFICATIONS_TOKEN);
541
- }
542
-
543
- // Live streaming (opt-in): emit throttled non-finalized `turn_stream` frames as
544
- // the assistant message streams so remote clients can edit ONE message live. The
545
- // finalized frame (turn_end) carries the same messageRef and stays authoritative,
546
- // so a dropped live frame self-heals. Off unless GJC_NOTIFICATIONS_STREAM=1.
547
- function streamingEnabled(): boolean {
548
- return process.env.GJC_NOTIFICATIONS_STREAM === "1";
549
- }
550
- function streamIntervalMs(): number {
551
- return Math.max(200, Number(process.env.GJC_NOTIFICATIONS_STREAM_INTERVAL_MS) || 500);
552
- }
553
- // Max chars of a turn's assistant text carried by the FINALIZED turn_stream (and
554
- // the pre-ask capture). Finalized turns default to the bounded full-turn ceiling
555
- // because split-capable clients such as the Telegram daemon schedule each
556
- // splitTelegramHtml chunk through the shared rate-limit pool. Operators who want
557
- // glanceable summaries can lower this with GJC_NOTIFICATIONS_TURN_MAX. The value
558
- // is always clamped to a finite [280, TURN_TEXT_MAX_CEILING] range so the cap can
559
- // never be unbounded. Live frames are intentionally NOT raised — they stay one
560
- // editable preview message rather than fanning a long in-progress turn across
561
- // sends.
562
- const TURN_TEXT_MAX_CEILING = 40_000;
563
- function turnTextMax(): number {
564
- const raw = Number(process.env.GJC_NOTIFICATIONS_TURN_MAX);
565
- if (!Number.isFinite(raw) || raw <= 0) return TURN_TEXT_MAX_CEILING;
566
- return Math.min(TURN_TEXT_MAX_CEILING, Math.max(280, raw));
567
- }
568
- function resolveSettings(settingsOverride?: Settings): ResolvedSettings {
569
- if (settingsOverride)
570
- return { settings: settingsOverride, cfg: getNotificationConfig(settingsOverride), settingsAvailable: true };
571
- try {
572
- const settings = Settings.instance;
573
- return { settings, cfg: getNotificationConfig(settings), settingsAvailable: true };
574
- } catch {
575
- return { settings: undefined, cfg: defaultConfig, settingsAvailable: false };
576
- }
577
- }
578
-
579
- function resolveToken(): string {
580
- return process.env.GJC_NOTIFICATIONS_TOKEN ?? crypto.randomBytes(24).toString("base64url");
581
- }
582
-
583
- function parseAnswer(answerJson: string): unknown {
584
- try {
585
- return JSON.parse(answerJson);
586
- } catch {
587
- return answerJson;
588
- }
589
- }
590
-
591
- /** Map a client answer to the option LABEL the local UI would return (or free text). */
592
- function mapAnswerToLabel(answerJson: string, options: string[]): string | undefined {
593
- const answer = parseAnswer(answerJson);
594
- if (typeof answer === "number") return options[answer];
595
- if (typeof answer === "string") return answer;
596
- if (answer && typeof answer === "object") {
597
- const sel = (answer as { selected?: unknown; custom?: unknown }).selected;
598
- if (Array.isArray(sel) && sel.length > 0) {
599
- const first = sel[0];
600
- return typeof first === "number" ? options[first] : String(first);
601
- }
602
- const custom = (answer as { custom?: unknown }).custom;
603
- if (typeof custom === "string") return custom;
604
- }
605
- return undefined;
606
- }
607
-
608
- /** Workflow-gate answer shape (unattended mode). */
609
- interface GateAnswer {
610
- selected: string[];
611
- other?: boolean;
612
- custom?: string;
613
- }
614
-
615
- /**
616
- * Discriminated result of mapping a client answer to a workflow-gate answer.
617
- * `ok: false` means the reply is invalid and the caller must close the exact
618
- * claim/receipt and reissue the interaction rather than durably accepting it.
619
- */
620
- type GateAnswerResult = { ok: true; answer: GateAnswer } | { ok: false; reason: string };
621
-
622
- /**
623
- * Map a client answer to the workflow-gate answer shape (unattended mode).
624
- *
625
- * The protocol defines a numeric reply as an option index, so a number outside
626
- * `options` is invalid (issue #2030): it must NOT be converted into a free-text
627
- * `Other` that passes the ask schema and triggers a misleading success ack.
628
- * Only JSON strings enter the free-text/Other path.
629
- */
630
- export function mapAnswerToGate(answerJson: string, options: string[]): GateAnswerResult {
631
- const answer = parseAnswer(answerJson);
632
- if (typeof answer === "number") {
633
- const label = options[answer];
634
- return label === undefined
635
- ? { ok: false, reason: "numeric_selector_out_of_range" }
636
- : { ok: true, answer: { selected: [label] } };
637
- }
638
- if (typeof answer === "string") {
639
- return {
640
- ok: true,
641
- answer: options.includes(answer) ? { selected: [answer] } : { selected: [], other: true, custom: answer },
642
- };
643
- }
644
- if (answer && typeof answer === "object") {
645
- const obj = answer as { selected?: unknown; custom?: unknown };
646
- const selected = Array.isArray(obj.selected)
647
- ? obj.selected.map(s => (typeof s === "number" ? (options[s] ?? String(s)) : String(s)))
648
- : [];
649
- const custom = typeof obj.custom === "string" ? obj.custom : undefined;
650
- return { ok: true, answer: { selected, other: custom !== undefined, custom } };
651
- }
652
- return { ok: true, answer: { selected: [] } };
653
- }
654
-
655
- interface NotificationControlCommandPayload {
656
- name?: unknown;
657
- action?: unknown;
658
- level?: unknown;
659
- instructions?: unknown;
660
- }
661
-
662
- function parseControlCommandPayload(json: string | undefined): NotificationControlCommandPayload | undefined {
663
- if (!json) return undefined;
664
- try {
665
- const parsed = JSON.parse(json) as unknown;
666
- return parsed && typeof parsed === "object" ? (parsed as NotificationControlCommandPayload) : undefined;
667
- } catch {
668
- return undefined;
669
- }
670
- }
671
-
672
- function formatCompactTokenCount(value: number | null | undefined): string {
673
- if (value == null) return "unknown";
674
- if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1))}m`;
675
- if (value >= 1_000) return `${Number((value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1))}k`;
676
- return value.toLocaleString();
677
- }
678
-
679
- function formatContextUsageLine(ctx: ExtensionContext): string {
680
- const usage = ctx.getContextUsage();
681
- if (!usage) return "Context usage unavailable.";
682
- const tokens = formatCompactTokenCount(usage.tokens);
683
- const window = formatCompactTokenCount(usage.contextWindow);
684
- const pct = usage.percent == null ? "unknown" : `${usage.percent.toFixed(1)}%`;
685
- return `Context: ${tokens}/${window} ${pct}`;
686
- }
687
-
688
- function formatLocalUsage(ctx: ExtensionContext): string {
689
- const stats = ctx.sessionManager.getUsageStatistics();
690
- return [
691
- "Usage",
692
- `Input tokens: ${stats.input}`,
693
- `Output tokens: ${stats.output}`,
694
- `Cache read tokens: ${stats.cacheRead}`,
695
- `Cache write tokens: ${stats.cacheWrite}`,
696
- `Premium requests: ${stats.premiumRequests}`,
697
- `Cost: $${stats.cost.toFixed(6)}`,
698
- ].join("\n");
699
- }
700
-
701
- function cycleTelegramThinking(api: ExtensionAPI): ThinkingLevel | undefined {
702
- const levels = [
703
- ThinkingLevel.Off,
704
- ThinkingLevel.Minimal,
705
- ThinkingLevel.Low,
706
- ThinkingLevel.Medium,
707
- ThinkingLevel.High,
708
- ThinkingLevel.XHigh,
709
- ThinkingLevel.Max,
710
- ];
711
- const current = api.getThinkingLevel() ?? ThinkingLevel.Off;
712
- const currentIndex = levels.indexOf(current as (typeof levels)[number]);
713
- const next = levels[(currentIndex + 1) % levels.length];
714
- if (!next) return undefined;
715
- api.setThinkingLevel(next);
716
- return api.getThinkingLevel() ?? next;
717
- }
718
-
719
- export async function executeNotificationControlCommand(
720
- command: NotificationControlCommandPayload | undefined,
721
- ctx: ExtensionContext,
722
- api: ExtensionAPI,
723
- ): Promise<{ status: "ok" | "error" | "unavailable"; message: string }> {
724
- if (!command || typeof command.name !== "string") return { status: "error", message: "Invalid control command." };
725
- switch (command.name) {
726
- case "reasoning": {
727
- const current = api.getThinkingLevel() ?? ThinkingLevel.Off;
728
- if (command.action === "status") return { status: "ok", message: `Reasoning effort: ${current}` };
729
- if (command.action === "cycle") {
730
- const next = cycleTelegramThinking(api);
731
- return next
732
- ? { status: "ok", message: `Reasoning effort set to ${next}.` }
733
- : { status: "unavailable", message: "Reasoning effort unavailable for this session." };
734
- }
735
- if (command.action === "set" && typeof command.level === "string") {
736
- const parsed = parseThinkingLevel(command.level);
737
- if (!parsed) return { status: "error", message: "Invalid reasoning effort." };
738
- api.setThinkingLevel(parsed);
739
- return { status: "ok", message: `Reasoning effort set to ${api.getThinkingLevel() ?? ThinkingLevel.Off}.` };
740
- }
741
- return { status: "error", message: "Invalid reasoning command." };
742
- }
743
- case "usage":
744
- return { status: "ok", message: formatLocalUsage(ctx) };
745
- case "context":
746
- return { status: "ok", message: formatContextUsageLine(ctx) };
747
- case "compact": {
748
- const before = ctx.getContextUsage()?.tokens;
749
- try {
750
- await ctx.compact(typeof command.instructions === "string" ? command.instructions : undefined);
751
- } catch (err) {
752
- return {
753
- status: "error",
754
- message: `Compaction failed: ${err instanceof Error ? err.message : String(err)}`,
755
- };
756
- }
757
- const after = ctx.getContextUsage()?.tokens;
758
- if (before != null && after != null)
759
- return {
760
- status: "ok",
761
- message: `Compaction complete. Tokens: ${before} -> ${after} (saved ${before - after}).`,
762
- };
763
- return { status: "ok", message: "Compaction complete." };
764
- }
765
- default:
766
- return { status: "error", message: "Unknown control command." };
767
- }
768
- }
769
-
770
- function selectedAckOutcome(value: { status: string; messageId?: number; reason?: string }): AskSelectedAckOutcome {
771
- if (value.status === "delivered" && typeof value.messageId === "number") {
772
- return { status: "delivered", messageId: value.messageId };
773
- }
774
- if (value.status === "failed") {
775
- switch (value.reason) {
776
- case "unsupported":
777
- case "no_participant":
778
- case "ambiguous_participant":
779
- case "route_missing":
780
- case "expired":
781
- case "cancelled":
782
- case "telegram_rejected":
783
- case "session_closed":
784
- return { status: "failed", reason: value.reason };
785
- default:
786
- return { status: "failed", reason: "session_closed" };
787
- }
788
- }
789
- switch (value.reason) {
790
- case "transport_ambiguous":
791
- case "origin_disconnected":
792
- case "host_timeout":
793
- case "shutdown":
794
- return { status: "unknown", reason: value.reason };
795
- default:
796
- return { status: "unknown", reason: "host_timeout" };
797
- }
798
- }
799
-
800
- async function requestLiveSelectedAck(
801
- native: {
802
- requestAskSelectedAck(
803
- replyReceiptId: string,
804
- requestJson: string,
805
- ): Promise<{ status: string; messageId?: number; reason?: string }>;
806
- },
807
- input: { replyReceiptId: string; actionId: string; commitKey: string; deadlineAt: number },
808
- ): Promise<AskSelectedAckOutcome> {
809
- const requestId = `ack:${crypto.randomUUID()}`;
810
- try {
811
- return selectedAckOutcome(
812
- await native.requestAskSelectedAck(
813
- input.replyReceiptId,
814
- JSON.stringify({
815
- mode: "live",
816
- requestId,
817
- commitKey: input.commitKey,
818
- actionId: input.actionId,
819
- deadlineAt: input.deadlineAt,
820
- }),
821
- ),
822
- );
823
- } catch (error) {
824
- logger.warn(`notifications: Selected acknowledgement failed: ${String(error)}`);
825
- return { status: "unknown", reason: "host_timeout" };
826
- }
827
- }
828
-
829
- async function requestRecoveredSelectedAck(
830
- native: {
831
- requestRecoveredAskSelectedAck(
832
- requestJson: string,
833
- ): Promise<{ status: string; messageId?: number; reason?: string }>;
834
- },
835
- input: { sessionId: string; actionId: string; commitKey: string; deadlineAt: number },
836
- ): Promise<AskSelectedAckOutcome> {
837
- try {
838
- return selectedAckOutcome(
839
- await native.requestRecoveredAskSelectedAck(
840
- JSON.stringify({
841
- mode: "recovery",
842
- requestId: `ack:${crypto.randomUUID()}`,
843
- commitKey: input.commitKey,
844
- sessionId: input.sessionId,
845
- actionId: input.actionId,
846
- deadlineAt: input.deadlineAt,
847
- }),
848
- ),
849
- );
850
- } catch (error) {
851
- logger.warn(`notifications: recovered Selected acknowledgement failed: ${String(error)}`);
852
- return { status: "unknown", reason: "host_timeout" };
853
- }
854
- }
855
-
856
- /** Register the interactive `ask` answer source for a session (the ask tool
857
- * races the local UI against a remote reply). Returns the deregister disposer. */
858
- function registerInteractiveAnswerSource(
859
- id: string,
860
- server: NotificationServer,
861
- pendingInteractive: Map<string, PendingInteractiveAsk>,
862
- getRedact: () => boolean,
863
- tag: string,
864
- ): () => void {
865
- return registerAskAnswerSource(id, {
866
- awaitAnswer(question, options, signal) {
867
- const result = this.awaitAnswerRequest?.({ question, options, interaction: "selector", controls: [] }, signal);
868
- if (!result) return Promise.resolve(undefined);
869
- return result.then(answer => {
870
- if (!answer || typeof answer === "string") return answer;
871
- return answer.interaction.kind === "value" ? answer.interaction.value : undefined;
872
- });
873
- },
874
- awaitAnswerRequest(request: AskAnswerRequest, signal?: AbortSignal): Promise<AskAnswerSourceResult> {
875
- if (signal?.aborted) return Promise.resolve(undefined);
876
- const register = (askId: string): boolean => {
877
- try {
878
- server.registerAsk(
879
- JSON.stringify(
880
- notificationActionPayload(
881
- {
882
- id: askId,
883
- kind: "ask",
884
- sessionId: id,
885
- question: request.question,
886
- options: request.options,
887
- controls: request.controls,
888
- },
889
- { redact: getRedact(), sessionTag: tag },
890
- ),
891
- ),
892
- true,
893
- );
894
- return true;
895
- } catch (error) {
896
- logger.warn(`notifications: registerAsk failed: ${String(error)}`);
897
- return false;
898
- }
899
- };
900
- let activeAskId = `ask:${crypto.randomUUID()}`;
901
- if (!register(activeAskId)) return Promise.resolve(undefined);
902
- return new Promise<AskAnswerSourceResult>(resolve => {
903
- const pending: PendingInteractiveAsk = {
904
- resolve,
905
- options: request.options,
906
- controls: request.controls,
907
- reissue: () => {
908
- const nextAskId = `ask:${crypto.randomUUID()}`;
909
- if (!register(nextAskId)) return false;
910
- activeAskId = nextAskId;
911
- pendingInteractive.set(nextAskId, pending);
912
- return true;
913
- },
914
- };
915
- pendingInteractive.set(activeAskId, pending);
916
- signal?.addEventListener("abort", () => {
917
- if (!pendingInteractive.delete(activeAskId)) return;
918
- try {
919
- server.resolveLocal(activeAskId, undefined);
920
- } catch {}
921
- resolve(undefined);
922
- });
923
- });
924
- },
925
- });
926
- }
927
-
928
- /** Extract the session id from a `<timestamp>_<uuid>.jsonl` session file path. */
929
- function sessionIdFromFile(file: string | undefined): string | undefined {
930
- if (!file) return undefined;
931
- const base = path.basename(file).replace(/\.jsonl$/, "");
932
- const underscore = base.indexOf("_");
933
- return underscore >= 0 ? base.slice(underscore + 1) : undefined;
934
- }
935
-
936
- export function createNotificationsExtension(api: ExtensionAPI, options: { settings?: Settings } = {}): void {
937
- const runtimes = new Map<string, SessionRuntime>();
938
- const disabledSessions = new Set<string>();
939
- const sessionId = (ctx: ExtensionContext): string => ctx.sessionManager.getSessionId();
940
- const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
941
-
942
- async function stopSession(id: string): Promise<boolean> {
943
- const rt = runtimes.get(id);
944
- if (!rt) return false;
945
- runtimes.delete(id);
946
- try {
947
- rt.cancelPostmortemCleanup();
948
- } catch {}
949
- try {
950
- rt.disposeAnswerSource();
951
- } catch {}
952
- try {
953
- rt.disposeFileSink();
954
- } catch {}
955
- try {
956
- rt.disposeGateListener();
957
- } catch {}
958
- try {
959
- rt.disposeGateTerminalController();
960
- } catch {}
961
- try {
962
- rt.disposeAckRecoveryParticipant();
963
- } catch {}
964
- try {
965
- rt.disposeGateEmitterListener();
966
- } catch {}
967
- rt.gatePresentations?.dispose();
968
- // Resolve any still-pending interactive asks so the ask tool is not left hanging.
969
- for (const pending of rt.pendingInteractive.values()) pending.resolve(undefined);
970
- rt.pendingInteractive.clear();
971
- let closeFrameSent = false;
972
- try {
973
- rt.server.pushFrame(JSON.stringify({ type: "session_closed", sessionId: id }));
974
- closeFrameSent = true;
975
- } catch (e) {
976
- logger.warn(`notifications: session_closed failed: ${String(e)}`);
977
- }
978
- if (closeFrameSent) await sleep(100);
979
- try {
980
- rt.server.stop();
981
- } catch (e) {
982
- logger.warn(`notifications: stop failed: ${String(e)}`);
983
- }
984
- return true;
985
- }
986
-
987
- function isEnabledForSession(id: string, cfg: NotificationConfig): boolean {
988
- return isSessionNotificationsEnabled({ cfg, env: process.env, sessionDisabled: disabledSessions.has(id) });
989
- }
990
-
991
- function isNotificationEligibleContext(ctx: ExtensionContext): boolean {
992
- return ctx.sessionMetadata?.kind !== "sub";
993
- }
994
-
995
- async function startSession(ctx: ExtensionContext): Promise<"started" | "already" | "disabled" | "failed"> {
996
- const id = sessionId(ctx);
997
- const { settings, cfg, settingsAvailable } = resolveSettings(options.settings);
998
- if (!isNotificationEligibleContext(ctx) || !isEnabledForSession(id, cfg)) return "disabled";
999
- if (runtimes.has(id)) return "already";
1000
-
1001
- const stateRoot = path.join(ctx.cwd, ".gjc", "state");
1002
- const gateOptions = new Map<string, string[]>();
1003
- const pendingInteractive = new Map<string, PendingInteractiveAsk>();
1004
- const tag = sessionTag(id);
1005
- const redact = cfg.redact;
1006
- const verbosity = cfg.verbosity;
1007
- let runtime: SessionRuntime | undefined;
1008
-
1009
- // The SDK can always answer now (interactive via the answer source, or the
1010
- // unattended gate), so the endpoint advertises a resolver.
1011
- const server = new NotificationServer(id, resolveToken(), stateRoot, true);
1012
- const gatePresentations = new GatePresentationRegistry(server, () => runtime?.redact ?? redact, tag);
1013
-
1014
- server.onReply((err, reply) => {
1015
- if (err || !reply) return;
1016
- const native = server as unknown as {
1017
- resolveClaim(receiptId: string, answerJson?: string, idempotencyKey?: string): void;
1018
- closeClaimInvalid(receiptId: string, reason: string): void;
1019
- requestAskSelectedAck(
1020
- receiptId: string,
1021
- requestJson: string,
1022
- ): Promise<{ status: string; messageId?: number; reason?: string }>;
1023
- };
1024
- const pending = pendingInteractive.get(reply.id);
1025
- if (pending) {
1026
- pendingInteractive.delete(reply.id);
1027
- let interaction: AskRemoteInteraction | undefined;
1028
- try {
1029
- const answer = JSON.parse(reply.answerJson) as unknown;
1030
- if (typeof answer === "object" && answer && "controlId" in answer) {
1031
- const controlId = (answer as { controlId?: unknown }).controlId;
1032
- if (
1033
- controlId === "navigation_forward" &&
1034
- pending.controls.some(control => control.id === controlId && control.enabled)
1035
- ) {
1036
- interaction = { kind: "control", controlId };
1037
- }
1038
- } else {
1039
- const value = mapAnswerToLabel(reply.answerJson, pending.options);
1040
- if (value !== undefined) interaction = { kind: "value", value };
1041
- }
1042
- } catch {}
1043
- if (!interaction) {
1044
- try {
1045
- native.closeClaimInvalid(reply.replyReceiptId, "invalid_answer");
1046
- } catch {}
1047
- if (!pending.reissue()) pending.resolve(undefined);
1048
- return;
1049
- }
1050
- let settled: Promise<AskSettlementResult> | undefined;
1051
- const receipt: AskRemoteReceipt = {
1052
- source: "remote",
1053
- interaction,
1054
- settle(settlement: AskSettlement): Promise<AskSettlementResult> {
1055
- if (settled) return settled;
1056
- settled = Promise.resolve().then(async () => {
1057
- if (settlement.kind === "invalid") {
1058
- native.closeClaimInvalid(reply.replyReceiptId, settlement.reason);
1059
- return { kind: "invalid_closed" };
1060
- }
1061
- if (settlement.kind === "resolve_without_commit") {
1062
- native.resolveClaim(reply.replyReceiptId, reply.answerJson, reply.idempotencyKey ?? undefined);
1063
- return { kind: "resolved_without_commit" };
1064
- }
1065
- const ack = await requestLiveSelectedAck(native, {
1066
- replyReceiptId: reply.replyReceiptId,
1067
- actionId: reply.id,
1068
- commitKey: `${reply.id}:${reply.idempotencyKey ?? reply.replyReceiptId}`,
1069
- deadlineAt: Date.now() + 8_000,
1070
- });
1071
- native.resolveClaim(reply.replyReceiptId, reply.answerJson, reply.idempotencyKey ?? undefined);
1072
- return { kind: "committed", ack };
1073
- });
1074
- return settled;
1075
- },
1076
- };
1077
- pending.resolve(receipt);
1078
- return;
1079
- }
1080
- const gate = runtime?.workflowGate;
1081
- const unattended =
1082
- gate?.isUnattended?.() === true &&
1083
- typeof gate.onGateEmitted === "function" &&
1084
- typeof gate.resolveGate === "function";
1085
- const gateId = gatePresentations.routeFor(reply.id);
1086
- if (unattended && gateId && gate?.resolveGateFromNotification) {
1087
- const presentation = gatePresentations.presentationFor(reply.id);
1088
- const rawAnswer = parseAnswer(reply.answerJson);
1089
- if (presentation?.multi) {
1090
- const option =
1091
- typeof rawAnswer === "number"
1092
- ? presentation.options[rawAnswer]
1093
- : typeof rawAnswer === "string" && presentation.options.includes(rawAnswer)
1094
- ? rawAnswer
1095
- : undefined;
1096
- if (option !== undefined) {
1097
- native.resolveClaim(reply.replyReceiptId, reply.answerJson, reply.idempotencyKey ?? undefined);
1098
- if (!gatePresentations.toggle(reply.id, option)) gatePresentations.reissue(gateId);
1099
- return;
1100
- }
1101
- }
1102
- let answer: unknown;
1103
- if (
1104
- presentation?.multi &&
1105
- typeof rawAnswer === "object" &&
1106
- rawAnswer !== null &&
1107
- (rawAnswer as { controlId?: unknown }).controlId === "navigation_forward"
1108
- ) {
1109
- if (!presentation.allowEmpty && presentation.selectedOptions.length === 0) {
1110
- native.closeClaimInvalid(reply.replyReceiptId, "invalid_control");
1111
- gatePresentations.closeInteraction(reply.id, "invalid_control");
1112
- gatePresentations.reissue(gateId);
1113
- return;
1114
- }
1115
- answer = { selected: presentation.selectedOptions };
1116
- } else if (
1117
- typeof rawAnswer === "object" &&
1118
- rawAnswer !== null &&
1119
- (rawAnswer as { action?: unknown }).action === "clarify"
1120
- ) {
1121
- answer = rawAnswer;
1122
- } else if (presentation?.multi && typeof rawAnswer === "string") {
1123
- answer = { selected: presentation.selectedOptions, other: true, custom: rawAnswer };
1124
- } else {
1125
- const mapped = mapAnswerToGate(reply.answerJson, gateOptions.get(gateId) ?? []);
1126
- if (!mapped.ok) {
1127
- // A numeric selector outside options is invalid (issue #2030): close the
1128
- // exact claim/receipt and reissue the interaction — never a success ack.
1129
- native.closeClaimInvalid(reply.replyReceiptId, mapped.reason);
1130
- gatePresentations.closeInteraction(reply.id, mapped.reason);
1131
- gatePresentations.reissue(gateId);
1132
- return;
1133
- }
1134
- answer = mapped.answer;
1135
- }
1136
- void gate
1137
- .resolveGateFromNotification(
1138
- { gate_id: gateId, answer, idempotency_key: reply.idempotencyKey ?? undefined },
1139
- {
1140
- interactionActionId: reply.id,
1141
- replyReceiptId: reply.replyReceiptId,
1142
- answerJson: reply.answerJson,
1143
- idempotencyKey: reply.idempotencyKey ?? undefined,
1144
- resolveClaim: () =>
1145
- native.resolveClaim(reply.replyReceiptId, reply.answerJson, reply.idempotencyKey ?? undefined),
1146
- closeClaimInvalid: reason => {
1147
- native.closeClaimInvalid(reply.replyReceiptId, reason);
1148
- gatePresentations.closeInteraction(reply.id, reason);
1149
- gatePresentations.reissue(gateId);
1150
- },
1151
- requestSelectedAck: input =>
1152
- requestLiveSelectedAck(native, {
1153
- replyReceiptId: input.replyReceiptId,
1154
- actionId: input.actionId,
1155
- commitKey: input.commitKey,
1156
- deadlineAt: input.daemonDeadlineAt,
1157
- }),
1158
- },
1159
- )
1160
- .catch(error => logger.warn(`notifications: resolveGateFromNotification failed: ${String(error)}`));
1161
- return;
1162
- }
1163
- try {
1164
- server.closeClaimInvalid(reply.replyReceiptId, "unknown_action");
1165
- } catch (error) {
1166
- logger.warn(`notifications: closeClaimInvalid failed: ${String(error)}`);
1167
- }
1168
- });
1169
-
1170
- // Inbound free-text injection / in-thread config command from a session
1171
- // thread (forwarded by the daemon over the WS, fail-closed at the daemon).
1172
- server.onInbound((err, inbound) => {
1173
- if (err || !inbound) return;
1174
- if (inbound.kind === "user_message") {
1175
- // Inject as a user turn (steers/continues the agent; the resulting
1176
- // turn streams back via the turn_end handler even when not idle).
1177
- // Record the update id so it can be acked as "consumed" on the next
1178
- // turn_start, and steer (vs start a fresh turn) when already busy.
1179
- const text = inbound.text ?? "";
1180
- const images = inbound.images ?? [];
1181
- if (!text && images.length === 0) return;
1182
- if (runtime && typeof inbound.updateId === "number") runtime.pendingInbound.add(inbound.updateId);
1183
- const content: string | (TextContent | ImageContent)[] =
1184
- images.length > 0
1185
- ? [
1186
- ...(text ? [{ type: "text", text } as TextContent] : []),
1187
- ...images.map(
1188
- img =>
1189
- ({ type: "image", data: img.data, mimeType: img.mime ?? "image/jpeg" }) as ImageContent,
1190
- ),
1191
- ]
1192
- : text;
1193
- try {
1194
- api.sendUserMessage(content, runtime?.busy ? { deliverAs: "steer" } : undefined);
1195
- } catch (e) {
1196
- logger.warn(`notifications: sendUserMessage failed: ${String(e)}`);
1197
- }
1198
- return;
1199
- }
1200
- if (inbound.kind === "config_command") {
1201
- if (!runtime) return;
1202
- const update: {
1203
- type: "config_update";
1204
- sessionId: string;
1205
- verbosity?: "lean" | "verbose";
1206
- redact?: boolean;
1207
- } = {
1208
- type: "config_update",
1209
- sessionId: id,
1210
- };
1211
- if (inbound.verbosity === "lean" || inbound.verbosity === "verbose") {
1212
- runtime.verbosity = inbound.verbosity;
1213
- update.verbosity = inbound.verbosity;
1214
- }
1215
- if (typeof inbound.redact === "boolean") {
1216
- runtime.redact = inbound.redact;
1217
- update.redact = inbound.redact;
1218
- }
1219
- if (update.verbosity !== undefined || update.redact !== undefined) {
1220
- try {
1221
- runtime.server.pushFrame(JSON.stringify(update));
1222
- } catch (e) {
1223
- logger.warn(`notifications: config_update failed: ${String(e)}`);
1224
- }
1225
- }
1226
- }
1227
- if (inbound.kind === "control_command") {
1228
- if (!runtime || !inbound.requestId) return;
1229
- void executeNotificationControlCommand(parseControlCommandPayload(inbound.commandJson), ctx, api)
1230
- .then(result => {
1231
- runtime?.server.pushFrame(
1232
- JSON.stringify({
1233
- type: "control_command_result",
1234
- sessionId: id,
1235
- requestId: inbound.requestId,
1236
- updateId: inbound.updateId,
1237
- status: result.status,
1238
- message: result.message,
1239
- }),
1240
- );
1241
- })
1242
- .catch(err => {
1243
- try {
1244
- runtime?.server.pushFrame(
1245
- JSON.stringify({
1246
- type: "control_command_result",
1247
- sessionId: id,
1248
- requestId: inbound.requestId,
1249
- updateId: inbound.updateId,
1250
- status: "error",
1251
- message: `Control command failed: ${err instanceof Error ? err.message : String(err)}`,
1252
- }),
1253
- );
1254
- } catch (pushErr) {
1255
- logger.warn(`notifications: control_command_result failed: ${String(pushErr)}`);
1256
- }
1257
- });
1258
- }
1259
- });
1260
-
1261
- try {
1262
- const endpoint = await server.start();
1263
-
1264
- // Interactive answer source: the ask tool races the local UI against this.
1265
- const disposeAnswerSource = registerInteractiveAnswerSource(
1266
- id,
1267
- server,
1268
- pendingInteractive,
1269
- () => runtime?.redact ?? redact,
1270
- tag,
1271
- );
1272
- const disposeFileSink = registerTelegramFileSink(id, async file => {
1273
- if (runtime?.redact ?? redact) {
1274
- return { ok: false, error: TELEGRAM_FILE_REDACTION_ERROR };
1275
- }
1276
-
1277
- try {
1278
- const data = await fs.promises.readFile(file.path);
1279
- server.pushFrame(
1280
- JSON.stringify({
1281
- type: "file_attachment",
1282
- sessionId: id,
1283
- name: path.basename(file.path),
1284
- data: data.toString("base64"),
1285
- caption: file.caption,
1286
- }),
1287
- );
1288
- return { ok: true };
1289
- } catch (e) {
1290
- return { ok: false, error: e instanceof Error ? e.message : String(e) };
1291
- }
1292
- });
1293
-
1294
- runtime = {
1295
- server,
1296
- idleSeq: 0,
1297
- pendingInteractive,
1298
- disposeAnswerSource,
1299
- disposeFileSink,
1300
- disposeGateListener: () => {},
1301
- disposeGateTerminalController: () => {},
1302
- disposeAckRecoveryParticipant: () => {},
1303
- disposeGateEmitterListener: () => {},
1304
- workflowGate: undefined,
1305
- gatePresentations,
1306
- cancelPostmortemCleanup: () => {},
1307
- redact,
1308
- verbosity,
1309
- stream: streamingEnabled(),
1310
- sessionTag: tag,
1311
- busy: false,
1312
- pendingInbound: new Set<number>(),
1313
- };
1314
- runtimes.set(id, runtime);
1315
- const activeRuntime = runtime;
1316
- // A native terminal close (SIGHUP), SIGTERM, Ctrl+C exit, or fatal error
1317
- // skips AgentSession.dispose(), so the `session_shutdown` extension event
1318
- // never fires and the daemon-side topic would be orphaned. postmortem
1319
- // awaits registered cleanups on those paths, so send the graceful
1320
- // `session_closed` frame from there too. stopSession() cancels this
1321
- // registration on every other teardown path, so it never double-fires.
1322
- runtime.cancelPostmortemCleanup = postmortem.register(`notifications-session-closed:${id}`, async () => {
1323
- await stopSession(id);
1324
- });
1325
- logger.info(`notifications: serving session ${id} at ${endpoint.url}`);
1326
-
1327
- if (settingsAvailable && settings && isTelegramConfigured(cfg)) {
1328
- try {
1329
- await ensureTelegramDaemonRunning({ settings, cwd: ctx.cwd, sessionId: id });
1330
- } catch (e) {
1331
- logger.warn(`notifications: failed to ensure Telegram daemon: ${String(e)}`);
1332
- }
1333
- }
1334
-
1335
- // One-time identity header (repo/branch/machine/session) pinned at the top
1336
- // of the session thread by the daemon.
1337
- try {
1338
- server.pushFrame(
1339
- JSON.stringify({
1340
- type: "identity_header",
1341
- sessionId: id,
1342
- ...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
1343
- }),
1344
- );
1345
- } catch (e) {
1346
- logger.warn(`notifications: identity_header failed: ${String(e)}`);
1347
- }
1348
-
1349
- // Workflow-gate installation occurs after session_start in RPC/bridge modes.
1350
- // Attach dynamically so those sessions receive the same Telegram gate surface.
1351
- const attachWorkflowGate = (gate: WorkflowGateEmitter | undefined): void => {
1352
- if (activeRuntime.workflowGate === gate) return;
1353
- activeRuntime.disposeGateListener();
1354
- activeRuntime.disposeGateTerminalController();
1355
- activeRuntime.disposeAckRecoveryParticipant();
1356
- activeRuntime.disposeGateListener = () => {};
1357
- activeRuntime.disposeGateTerminalController = () => {};
1358
- activeRuntime.disposeAckRecoveryParticipant = () => {};
1359
- activeRuntime.workflowGate = undefined;
1360
- gateOptions.clear();
1361
- gatePresentations.dispose();
1362
- if (typeof gate?.onGateEmitted !== "function" || typeof gate.resolveGate !== "function") {
1363
- return;
1364
- }
1365
- activeRuntime.workflowGate = gate;
1366
- if (gate.registerGateTerminalController) {
1367
- const controller: WorkflowGateTerminalController = {
1368
- completeGateInteractions: gateId => gatePresentations.complete(gateId),
1369
- cancelGateInteractions: (gateId, reason) => gatePresentations.cancel(gateId, reason),
1370
- };
1371
- try {
1372
- activeRuntime.disposeGateTerminalController = gate.registerGateTerminalController(controller);
1373
- } catch (error) {
1374
- logger.warn(`notifications: gate terminal controller unavailable: ${String(error)}`);
1375
- }
1376
- }
1377
- activeRuntime.disposeGateListener = gate.onGateEmitted(g => {
1378
- const options = (g.options ?? []).map(o => String((o as { label?: unknown }).label ?? ""));
1379
- gateOptions.set(g.gate_id, options);
1380
- const promptCtx = g.context as { prompt?: unknown; title?: unknown } | undefined;
1381
- const question =
1382
- (typeof promptCtx?.prompt === "string" && promptCtx.prompt) ||
1383
- (typeof promptCtx?.title === "string" && promptCtx.title) ||
1384
- "Question";
1385
- const stageState =
1386
- typeof g.context?.stage_state === "object" && g.context.stage_state !== null
1387
- ? (g.context.stage_state as Record<string, unknown>)
1388
- : {};
1389
- gatePresentations.retain({
1390
- gateId: g.gate_id,
1391
- sessionId: id,
1392
- question,
1393
- options,
1394
- controls: [],
1395
- multi: stageState.multi === true,
1396
- allowEmpty: stageState.allow_empty === true,
1397
- navigationLabel: stageState.navigation_label === "Next" ? "Next" : "Done",
1398
- selectedOptions: [],
1399
- });
1400
- gatePresentations.reissue(g.gate_id);
1401
- });
1402
- if (gate.setAckRecoveryParticipant) {
1403
- const native = server as unknown as {
1404
- requestRecoveredAskSelectedAck(
1405
- requestJson: string,
1406
- ): Promise<{ status: string; messageId?: number; reason?: string }>;
1407
- };
1408
- gate.setAckRecoveryParticipant({
1409
- requestRecoveredAskSelectedAck: input =>
1410
- requestRecoveredSelectedAck(native, {
1411
- sessionId: input.sessionId,
1412
- actionId: input.actionId,
1413
- commitKey: input.commitKey,
1414
- deadlineAt: input.deadlineAt,
1415
- }),
1416
- });
1417
- activeRuntime.disposeAckRecoveryParticipant = () => gate.setAckRecoveryParticipant?.(null);
1418
- }
1419
- };
1420
- activeRuntime.disposeGateEmitterListener = registerWorkflowGateEmitterListener(id, attachWorkflowGate);
1421
- if (ctx.workflowGate) attachWorkflowGate(ctx.workflowGate);
1422
- return "started";
1423
- } catch (e) {
1424
- logger.warn(`notifications: failed to start server: ${String(e)}`);
1425
- return "failed";
1426
- }
1427
- }
1428
-
1429
- api.registerCommand("notify", {
1430
- description: "Control notifications for this session (on, off, status).",
1431
- async handler(args: string, ctx: ExtensionCommandContext): Promise<void> {
1432
- const id = sessionId(ctx);
1433
- const command = args.trim().split(/\s+/, 1)[0]?.toLowerCase() || "status";
1434
- const resolved = resolveSettings(options.settings);
1435
- const enabledWithoutLocalOff = isSessionNotificationsEnabled({
1436
- cfg: resolved.cfg,
1437
- env: process.env,
1438
- sessionDisabled: false,
1439
- });
1440
-
1441
- if (command === "off") {
1442
- disabledSessions.add(id);
1443
- const stopped = await stopSession(id);
1444
- ctx.ui.notify(
1445
- stopped
1446
- ? "Notifications disabled for this session."
1447
- : "Notifications already disabled for this session.",
1448
- "info",
1449
- );
1450
- return;
1451
- }
1452
-
1453
- if (command === "on") {
1454
- if (!isNotificationEligibleContext(ctx)) {
1455
- ctx.ui.notify("Notifications are disabled for subagent sessions.", "warning");
1456
- return;
1457
- }
1458
- if (process.env.GJC_NOTIFICATIONS === "0") {
1459
- ctx.ui.notify(
1460
- "Notifications remain disabled: GJC_NOTIFICATIONS=0 is an authoritative opt-out.",
1461
- "warning",
1462
- );
1463
- return;
1464
- }
1465
- if (!enabledWithoutLocalOff) {
1466
- ctx.ui.notify(
1467
- "Notifications are not configured. Run `gjc notify setup` or set GJC_NOTIFICATIONS=1.",
1468
- "warning",
1469
- );
1470
- return;
1471
- }
1472
- disabledSessions.delete(id);
1473
- const result = await startSession(ctx);
1474
- ctx.ui.notify(
1475
- result === "started"
1476
- ? "Notifications enabled for this session."
1477
- : result === "already"
1478
- ? "Notifications already enabled for this session."
1479
- : result === "failed"
1480
- ? "Notifications failed to start for this session."
1481
- : "Notifications are not configured. Run `gjc notify setup` or set GJC_NOTIFICATIONS=1.",
1482
- result === "failed" ? "error" : result === "disabled" ? "warning" : "info",
1483
- );
1484
- return;
1485
- }
1486
-
1487
- if (command !== "status") {
1488
- ctx.ui.notify("Usage: /notify status | /notify on | /notify off", "warning");
1489
- return;
1490
- }
1491
-
1492
- const running = runtimes.has(id);
1493
- const locallyDisabled = disabledSessions.has(id);
1494
- const enabled = isEnabledForSession(id, resolved.cfg);
1495
- const runtime = runtimes.get(id);
1496
- ctx.ui.notify(
1497
- `Notifications ${running ? "running" : enabled ? "enabled" : "disabled"} for this session; redaction ${(runtime?.redact ?? resolved.cfg.redact) ? "on" : "off"}; verbosity ${runtime?.verbosity ?? resolved.cfg.verbosity}${locallyDisabled ? "; locally off" : ""}.`,
1498
- "info",
1499
- );
1500
- },
1501
- });
1502
-
1503
- api.on("session_start", async (_event, ctx) => {
1504
- await startSession(ctx);
1505
- });
1506
-
1507
- // A session id change within the same process needs reason-aware handling.
1508
- // `/new` and fork CONTINUE the same terminal thread (e.g. plan "approve and
1509
- // execute" clears into a fresh session), so re-key the existing runtime
1510
- // old→new WITHOUT recreating the NotificationServer: the server, its endpoint
1511
- // discovery file, and the daemon's forum topic are all keyed by the original
1512
- // session id and the daemon routes by socket, so the existing topic is reused
1513
- // and the next identity frame renames it in place instead of spawning a new
1514
- // thread. `resume`, by contrast, loads a DIFFERENT, already-persisted session
1515
- // that owns its own topic — tear the previous runtime down and start fresh
1516
- // under the resumed id so the daemon attaches to (or recreates) that
1517
- // session's own discovery + topic rather than hijacking this terminal's.
1518
- api.on("session_switch", async (event, ctx) => {
1519
- const newId = sessionId(ctx);
1520
- const prevId = sessionIdFromFile(event.previousSessionFile);
1521
- if (!prevId || prevId === newId) return;
1522
-
1523
- if (event.reason === "resume") {
1524
- stopSession(prevId);
1525
- await startSession(ctx);
1526
- return;
1527
- }
1528
-
1529
- // `/new` / fork: re-key in place and rename the existing topic.
1530
- if (disabledSessions.delete(prevId)) disabledSessions.add(newId);
1531
- const rt = runtimes.get(prevId);
1532
- if (!rt || runtimes.has(newId)) return;
1533
- runtimes.delete(prevId);
1534
- runtimes.set(newId, rt);
1535
- // Re-bind the interactive ask answer source: the ask tool resolves the
1536
- // source by the current session id, which just changed.
1537
- try {
1538
- rt.cancelPostmortemCleanup();
1539
- rt.disposeAnswerSource();
1540
- rt.disposeFileSink();
1541
- } catch {}
1542
- // Follow the id change so a later process teardown closes the re-keyed
1543
- // session (the old closure captured the retired id).
1544
- rt.cancelPostmortemCleanup = postmortem.register(`notifications-session-closed:${newId}`, async () => {
1545
- await stopSession(newId);
1546
- });
1547
- rt.disposeAnswerSource = registerInteractiveAnswerSource(
1548
- newId,
1549
- rt.server,
1550
- rt.pendingInteractive,
1551
- () => rt.redact,
1552
- rt.sessionTag,
1553
- );
1554
- rt.disposeFileSink = registerTelegramFileSink(newId, async file => {
1555
- if (rt.redact) {
1556
- return { ok: false, error: TELEGRAM_FILE_REDACTION_ERROR };
1557
- }
1558
-
1559
- try {
1560
- const data = await fs.promises.readFile(file.path);
1561
- rt.server.pushFrame(
1562
- JSON.stringify({
1563
- type: "file_attachment",
1564
- sessionId: newId,
1565
- name: path.basename(file.path),
1566
- data: data.toString("base64"),
1567
- caption: file.caption,
1568
- }),
1569
- );
1570
- return { ok: true };
1571
- } catch (e) {
1572
- return { ok: false, error: e instanceof Error ? e.message : String(e) };
1573
- }
1574
- });
1575
- // Rename the existing topic now when the new session already has a name; a
1576
- // fresh unnamed session is renamed on its next agent_end re-assert, which
1577
- // avoids a transient rename to bare "repo/branch".
1578
- if (ctx.sessionManager.getSessionName()) {
1579
- try {
1580
- rt.server.pushFrame(
1581
- JSON.stringify({
1582
- type: "identity_header",
1583
- sessionId: newId,
1584
- ...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
1585
- }),
1586
- );
1587
- } catch (e) {
1588
- logger.warn(`notifications: identity_header (switch) failed: ${String(e)}`);
1589
- }
1590
- }
1591
- });
1592
-
1593
- // Drive the live typing indicator: mark busy when the agent loop starts so
1594
- // the daemon shows "typing…" in the thread while the agent is thinking,
1595
- // before any turn output exists. Cleared on `agent_end` below.
1596
- api.on("agent_start", (_event, ctx) => {
1597
- const id = sessionId(ctx);
1598
- const rt = runtimes.get(id);
1599
- if (!rt) return;
1600
- rt.busy = true;
1601
- try {
1602
- rt.server.pushFrame(JSON.stringify({ type: "activity", sessionId: id, state: "busy" }));
1603
- } catch (e) {
1604
- logger.warn(`notifications: activity (busy) failed: ${String(e)}`);
1605
- }
1606
- });
1607
-
1608
- // Each turn that starts has absorbed any messages injected from the thread,
1609
- // so ack them as "consumed": the daemon flips the queued reaction on the
1610
- // originating Telegram message to the consumed (double-check) reaction.
1611
- api.on("turn_start", (_event, ctx) => {
1612
- const id = sessionId(ctx);
1613
- const rt = runtimes.get(id);
1614
- if (!rt) return;
1615
- // A new turn is live: re-open the live-stream window (see turnClosed).
1616
- rt.turnClosed = false;
1617
- if (rt.pendingInbound.size === 0) return;
1618
- for (const updateId of rt.pendingInbound) {
1619
- try {
1620
- rt.server.pushFrame(JSON.stringify({ type: "inbound_ack", sessionId: id, updateId, state: "consumed" }));
1621
- } catch (e) {
1622
- logger.warn(`notifications: inbound_ack failed: ${String(e)}`);
1623
- }
1624
- }
1625
- rt.pendingInbound.clear();
1626
- });
1627
-
1628
- // Idle fires on `agent_end` (the agent loop settling to await the user), NOT
1629
- // per `turn_end`. turn_end fires once per turn iteration, so a single
1630
- // user-visible idle previously produced many idle pings (the flood); agent_end
1631
- // fires exactly once per settle, yielding exactly one idle notification.
1632
- api.on("agent_end", (_event, ctx) => {
1633
- const id = sessionId(ctx);
1634
- const rt = runtimes.get(id);
1635
- if (!rt) return;
1636
- const seq = rt.idleSeq++;
1637
- // Clear the typing indicator: the agent loop has settled.
1638
- rt.busy = false;
1639
- try {
1640
- rt.server.pushFrame(JSON.stringify({ type: "activity", sessionId: id, state: "idle" }));
1641
- } catch (e) {
1642
- logger.warn(`notifications: activity (idle) failed: ${String(e)}`);
1643
- }
1644
- // Re-assert the identity header so the daemon renames the topic once the
1645
- // session title has been auto-generated ("{repo}/{branch} - {title}"). The
1646
- // daemon only renames when the title actually changed.
1647
- try {
1648
- rt.server.pushFrame(
1649
- JSON.stringify({
1650
- type: "identity_header",
1651
- sessionId: id,
1652
- ...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
1653
- }),
1654
- );
1655
- } catch {}
1656
- try {
1657
- rt.server.noteIdle(
1658
- JSON.stringify(
1659
- notificationActionPayload(
1660
- {
1661
- id: `idle:${id}#${seq}`,
1662
- kind: "idle",
1663
- sessionId: id,
1664
- summary: undefined,
1665
- },
1666
- { redact: rt.redact, sessionTag: rt.sessionTag },
1667
- ),
1668
- ),
1669
- );
1670
- } catch (e) {
1671
- logger.warn(`notifications: noteIdle failed: ${String(e)}`);
1672
- }
1673
-
1674
- // On idle, stream a context update with metadata (token/model usage +
1675
- // working-tree diff) unless redaction is on. The agent's last message is
1676
- // NOT repeated here — it is already streamed once via `turn_stream`.
1677
- if (!rt.redact && rt.verbosity === "verbose") {
1678
- const usage = (
1679
- ctx as { getContextUsage?: () => { tokens: number | null; contextWindow: number } | undefined }
1680
- ).getContextUsage?.();
1681
- const model = (ctx as { getModel?: () => { id?: string } | undefined }).getModel?.();
1682
- const tokenUsage = usage && usage.tokens != null ? `${usage.tokens}/${usage.contextWindow}` : undefined;
1683
- const modelId = model?.id;
1684
- void readGitDiffStat(ctx.cwd).then(diff => {
1685
- const cwd = compactCwd(ctx.cwd);
1686
- if (!diff && !tokenUsage && !modelId && !cwd) return;
1687
- try {
1688
- rt.server.pushFrame(
1689
- JSON.stringify({
1690
- type: "context_update",
1691
- sessionId: id,
1692
- tokenUsage,
1693
- model: modelId,
1694
- diff,
1695
- cwd,
1696
- }),
1697
- );
1698
- } catch (e) {
1699
- logger.warn(`notifications: context_update failed: ${String(e)}`);
1700
- }
1701
- });
1702
- }
1703
- });
1704
-
1705
- // Stream viable agent output per turn (the live thread mirror). Unlike idle,
1706
- // turn output is expected to be multiple messages — one per turn that
1707
- // produced assistant text. Tool-only turns yield no text and are skipped.
1708
- // Redaction suppresses streamed content (only the one-time identity header
1709
- // survives redaction). The daemon coalesces/throttles these via its shared
1710
- // rate-limit pool before sending to Telegram.
1711
- // Push the in-flight turn's assistant text as a finalized turn_stream, deduped
1712
- // against what was already flushed for this turn (the pre-ask lead-in).
1713
- const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined, finalAnswer: boolean): void => {
1714
- if (!text || text === rt.preAskFlushedText) return;
1715
- rt.preAskFlushedText = text;
1716
- // Decision A: a stream-enabled turn must finalize as an in-place edit of ONE
1717
- // live message, never a fresh (rich-promotable) send. If live frames were
1718
- // async-queued and none landed before this flush, allocate the per-turn ref
1719
- // now so the finalized frame always carries a messageRef → the daemon keeps it
1720
- // editable (HTML edit) and never rich-promotes a streamed final.
1721
- if (finalAnswer && rt.stream && rt.liveRef === undefined) {
1722
- rt.turnSeq = (rt.turnSeq ?? 0) + 1;
1723
- rt.liveRef = String(rt.turnSeq);
1724
- }
1725
- try {
1726
- rt.server.pushFrame(
1727
- JSON.stringify({
1728
- type: "turn_stream",
1729
- sessionId: id,
1730
- phase: "finalized",
1731
- finalAnswer,
1732
- text,
1733
- ...(rt.liveRef ? { messageRef: rt.liveRef } : {}),
1734
- }),
1735
- );
1736
- } catch (e) {
1737
- logger.warn(`notifications: pushFrame (turn) failed: ${String(e)}`);
1738
- }
1739
- };
1740
-
1741
- // Emit the assistant text that precedes an ask BEFORE the ask's action_needed
1742
- // is broadcast, so the remote (e.g. Telegram) shows the lead-in first instead
1743
- // of only after the ask resolves at turn_end. The text is captured on
1744
- // message_end (which, like tool_execution_start, is on the awaited extension
1745
- // path and ordered before it — unlike message_update, which is queued async),
1746
- // then flushed here before the ask tool's execute calls registerAsk.
1747
- api.on("tool_execution_start", (event, ctx) => {
1748
- if (event.toolName !== "ask") return;
1749
- const id = sessionId(ctx);
1750
- const rt = runtimes.get(id);
1751
- if (!rt || rt.redact) return;
1752
- flushTurnText(rt, id, rt.currentTurnText, false);
1753
- });
1754
-
1755
- api.on("turn_end", (event, ctx) => {
1756
- const id = sessionId(ctx);
1757
- const rt = runtimes.get(id);
1758
- if (!rt) return;
1759
- const text = rt.redact ? undefined : summaryFromMessage(event.message, turnTextMax());
1760
- if (text) flushTurnText(rt, id, text, true);
1761
- // Reset per-turn streaming state so the next turn starts fresh and a later
1762
- // turn with identical text is not falsely deduped.
1763
- rt.currentTurnText = undefined;
1764
- rt.preAskFlushedText = undefined;
1765
- rt.liveRef = undefined;
1766
- // Close the live-stream window: any message_update queued after turn_end is
1767
- // dropped so it can never emit a stale live edit past the finalized turn.
1768
- rt.turnClosed = true;
1769
- rt.lastLiveAt = undefined;
1770
- rt.lastLiveText = undefined;
1771
- });
1772
-
1773
- // Live streaming (opt-in): push throttled in-progress assistant text as
1774
- // non-finalized turn_stream frames so remote clients edit one message as the
1775
- // turn streams. The finalized frame (turn_end) carries the same messageRef and
1776
- // lands the authoritative text. Suppressed under redaction.
1777
- api.on("message_update", (event, ctx) => {
1778
- const id = sessionId(ctx);
1779
- const rt = runtimes.get(id);
1780
- if (!rt?.stream || rt.redact || rt.turnClosed) return;
1781
- if ((event.message as { role?: unknown }).role !== "assistant") return;
1782
- if (rt.liveRef === undefined) {
1783
- rt.turnSeq = (rt.turnSeq ?? 0) + 1;
1784
- rt.liveRef = String(rt.turnSeq);
1785
- }
1786
- const now = Date.now();
1787
- if (now - (rt.lastLiveAt ?? 0) < streamIntervalMs()) return;
1788
- const text = summaryFromMessage(event.message, 3500);
1789
- if (!text || text === rt.lastLiveText) return;
1790
- rt.lastLiveAt = now;
1791
- rt.lastLiveText = text;
1792
- try {
1793
- rt.server.pushFrame(
1794
- JSON.stringify({ type: "turn_stream", sessionId: id, phase: "live", text, messageRef: rt.liveRef }),
1795
- );
1796
- } catch (e) {
1797
- logger.warn(`notifications: pushFrame (live) failed: ${String(e)}`);
1798
- }
1799
- });
1800
-
1801
- // Stream agent-produced images (computer/browser/tool screenshots) as
1802
- // image_attachment frames; suppressed when redaction is on.
1803
- api.on("message_end", (event, ctx) => {
1804
- const id = sessionId(ctx);
1805
- const rt = runtimes.get(id);
1806
- if (!rt || rt.redact) return;
1807
- // Capture the in-flight ASSISTANT text here (message_end is on the awaited
1808
- // extension path and ordered before tool_execution_start) so the pre-ask
1809
- // flush can emit it before the ask prompt. Role-scoped: message_end also
1810
- // fires for the user prompt, which must never be mirrored back as turn output.
1811
- if ((event.message as { role?: unknown }).role === "assistant") {
1812
- const turnText = summaryFromMessage(event.message, turnTextMax());
1813
- if (turnText) rt.currentTurnText = turnText;
1814
- }
1815
- for (const img of imageAttachmentsFromMessage(event.message)) {
1816
- try {
1817
- rt.server.pushFrame(
1818
- JSON.stringify({
1819
- type: "image_attachment",
1820
- sessionId: id,
1821
- source: img.source,
1822
- mime: img.mime,
1823
- data: img.data,
1824
- }),
1825
- );
1826
- } catch (e) {
1827
- logger.warn(`notifications: image_attachment failed: ${String(e)}`);
1828
- }
1829
- }
1830
- });
1831
-
1832
- api.on("session_shutdown", async (_event, ctx) => {
1833
- await stopSession(sessionId(ctx));
1834
- });
1835
- }