@gajae-code/coding-agent 0.11.1 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (404) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/types/cli/config-cli.d.ts +11 -1
  3. package/dist/types/cli/notify-cli.d.ts +1 -0
  4. package/dist/types/cli/session-picker.d.ts +2 -1
  5. package/dist/types/cli/setup-cli.d.ts +1 -1
  6. package/dist/types/commands/mcp-serve.d.ts +33 -0
  7. package/dist/types/commands/mcp.d.ts +1 -1
  8. package/dist/types/commands/notify.d.ts +0 -2
  9. package/dist/types/commands/sdk.d.ts +1 -1
  10. package/dist/types/config/atomic-yaml-patch.d.ts +18 -0
  11. package/dist/types/config/file-lock.d.ts +14 -4
  12. package/dist/types/config/keybindings.d.ts +42 -1
  13. package/dist/types/config/model-auth.d.ts +3 -0
  14. package/dist/types/config/model-bindings-applier.d.ts +13 -0
  15. package/dist/types/config/model-discovery-manager.d.ts +49 -0
  16. package/dist/types/config/model-equivalence.d.ts +20 -0
  17. package/dist/types/config/model-registry.d.ts +16 -15
  18. package/dist/types/config/model-resolver.d.ts +28 -18
  19. package/dist/types/config/models-config-schema.d.ts +11 -0
  20. package/dist/types/config/settings-schema.d.ts +56 -1
  21. package/dist/types/config/settings.d.ts +14 -11
  22. package/dist/types/coordinator-mcp/policy.d.ts +5 -0
  23. package/dist/types/coordinator-mcp/question-gate-codec.d.ts +129 -0
  24. package/dist/types/coordinator-mcp/question-state.d.ts +356 -0
  25. package/dist/types/coordinator-mcp/server.d.ts +4 -0
  26. package/dist/types/discovery/helpers.d.ts +0 -6
  27. package/dist/types/edit/index.d.ts +3 -0
  28. package/dist/types/eval/py/env.d.ts +9 -0
  29. package/dist/types/extensibility/extensions/loader.d.ts +2 -1
  30. package/dist/types/extensibility/extensions/runner.d.ts +2 -2
  31. package/dist/types/extensibility/extensions/types.d.ts +34 -3
  32. package/dist/types/extensibility/hooks/runner.d.ts +1 -1
  33. package/dist/types/extensibility/shared-events.d.ts +2 -0
  34. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +10 -1
  35. package/dist/types/gjc-runtime/deep-interview-state.d.ts +44 -0
  36. package/dist/types/gjc-runtime/goal-mode-request.d.ts +11 -1
  37. package/dist/types/gjc-runtime/launch-worktree.d.ts +4 -2
  38. package/dist/types/gjc-runtime/linux-proc.d.ts +29 -0
  39. package/dist/types/gjc-runtime/session-resolution.d.ts +2 -2
  40. package/dist/types/gjc-runtime/state-migrations.d.ts +1 -1
  41. package/dist/types/gjc-runtime/state-writer.d.ts +2 -1
  42. package/dist/types/gjc-runtime/team-launch.d.ts +59 -0
  43. package/dist/types/gjc-runtime/team-notify.d.ts +45 -0
  44. package/dist/types/gjc-runtime/team-runtime.d.ts +29 -121
  45. package/dist/types/gjc-runtime/team-store.d.ts +160 -0
  46. package/dist/types/gjc-runtime/team-workers.d.ts +78 -0
  47. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +8 -0
  48. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +13 -0
  49. package/dist/types/gjc-runtime/ultragoal-evidence.d.ts +38 -0
  50. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +7 -0
  51. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +23 -0
  52. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +55 -33
  53. package/dist/types/gjc-runtime/workflow-cli-common.d.ts +8 -0
  54. package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -0
  55. package/dist/types/goals/continuation-timeout-guard.d.ts +22 -0
  56. package/dist/types/goals/runtime.d.ts +1 -0
  57. package/dist/types/goals/state.d.ts +8 -0
  58. package/dist/types/harness-control-plane/lifecycle-reconciliation.d.ts +17 -0
  59. package/dist/types/harness-control-plane/phase-rollup.d.ts +4 -7
  60. package/dist/types/harness-control-plane/receipt-ingest.d.ts +5 -0
  61. package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
  62. package/dist/types/hindsight/state.d.ts +11 -4
  63. package/dist/types/lsp/lspmux.d.ts +5 -3
  64. package/dist/types/lsp/path-trust.d.ts +1 -0
  65. package/dist/types/main.d.ts +3 -2
  66. package/dist/types/memories/storage.d.ts +1 -0
  67. package/dist/types/modes/action-registry.d.ts +29 -0
  68. package/dist/types/modes/components/chrome.d.ts +14 -0
  69. package/dist/types/modes/components/command-palette.d.ts +27 -0
  70. package/dist/types/modes/components/custom-provider-wizard.d.ts +1 -0
  71. package/dist/types/modes/components/index.d.ts +2 -1
  72. package/dist/types/modes/components/pet-selector.d.ts +1 -1
  73. package/dist/types/modes/components/plan-preview-overlay.d.ts +43 -0
  74. package/dist/types/modes/components/queue-mode-selector.d.ts +1 -1
  75. package/dist/types/modes/components/queue-pane.d.ts +12 -0
  76. package/dist/types/modes/components/read-tool-group.d.ts +6 -0
  77. package/dist/types/modes/components/session-observer-overlay.d.ts +3 -4
  78. package/dist/types/modes/components/sessions-dashboard.d.ts +32 -0
  79. package/dist/types/modes/components/settings-selector.d.ts +1 -1
  80. package/dist/types/modes/components/show-images-selector.d.ts +1 -1
  81. package/dist/types/modes/components/status-line/types.d.ts +1 -1
  82. package/dist/types/modes/components/status-line.d.ts +1 -94
  83. package/dist/types/modes/components/tasks-pane.d.ts +15 -0
  84. package/dist/types/modes/components/theme-selector.d.ts +1 -1
  85. package/dist/types/modes/components/tool-execution.d.ts +15 -0
  86. package/dist/types/modes/components/tool-status-header.d.ts +111 -0
  87. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +42 -0
  88. package/dist/types/modes/controllers/goal-mode-controller.d.ts +62 -0
  89. package/dist/types/modes/controllers/input-controller.d.ts +8 -1
  90. package/dist/types/modes/controllers/mode-gate.d.ts +8 -0
  91. package/dist/types/modes/controllers/plan-mode-controller.d.ts +65 -0
  92. package/dist/types/modes/controllers/selector-controller.d.ts +15 -3
  93. package/dist/types/modes/controllers/stt-controller.d.ts +26 -0
  94. package/dist/types/modes/interactive-mode.d.ts +19 -17
  95. package/dist/types/modes/jobs-observer.d.ts +2 -0
  96. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +4 -11
  97. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +20 -2
  98. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +31 -0
  99. package/dist/types/modes/tasks-aggregator.d.ts +39 -0
  100. package/dist/types/modes/transcript-item-registry.d.ts +60 -0
  101. package/dist/types/modes/types.d.ts +20 -13
  102. package/dist/types/modes/utils/ui-helpers.d.ts +2 -2
  103. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  104. package/dist/types/sdk/broker/broker.d.ts +78 -12
  105. package/dist/types/sdk/broker/lifecycle-codec.d.ts +4 -0
  106. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +15 -1
  107. package/dist/types/sdk/broker/lifecycle.d.ts +6 -2
  108. package/dist/types/sdk/broker/process-incarnation.d.ts +2 -0
  109. package/dist/types/sdk/broker/session-index.d.ts +0 -1
  110. package/dist/types/sdk/broker/state-version.d.ts +2 -0
  111. package/dist/types/sdk/bus/chat-effect-journal.d.ts +1 -0
  112. package/dist/types/sdk/bus/config-commands.d.ts +1 -1
  113. package/dist/types/sdk/bus/config.d.ts +11 -0
  114. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  115. package/dist/types/sdk/bus/html-format.d.ts +3 -2
  116. package/dist/types/sdk/bus/index.d.ts +27 -0
  117. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  118. package/dist/types/sdk/bus/notification-service.d.ts +20 -0
  119. package/dist/types/sdk/bus/rate-limit-pool.d.ts +13 -2
  120. package/dist/types/sdk/bus/recent-activity.d.ts +21 -12
  121. package/dist/types/sdk/bus/rich-document.d.ts +7 -0
  122. package/dist/types/sdk/bus/rich-render.d.ts +18 -41
  123. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +9 -4
  124. package/dist/types/sdk/bus/telegram-daemon.d.ts +46 -3
  125. package/dist/types/sdk/bus/threaded-inbound.d.ts +1 -1
  126. package/dist/types/sdk/bus/threaded-render.d.ts +13 -0
  127. package/dist/types/sdk/bus/topic-registry.d.ts +24 -2
  128. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  129. package/dist/types/sdk/host/host.d.ts +2 -0
  130. package/dist/types/sdk/index.d.ts +1 -0
  131. package/dist/types/sdk/session-directory.d.ts +56 -0
  132. package/dist/types/session/agent-session.d.ts +82 -47
  133. package/dist/types/session/agent-storage.d.ts +7 -0
  134. package/dist/types/session/fallback-chain-controller.d.ts +10 -2
  135. package/dist/types/session/internal/managed-session-scope.d.ts +102 -0
  136. package/dist/types/session/internal/managed-session-storage.d.ts +38 -0
  137. package/dist/types/session/messages.d.ts +16 -0
  138. package/dist/types/session/session-manager-internal.d.ts +14 -0
  139. package/dist/types/session/session-manager.d.ts +82 -12
  140. package/dist/types/session/session-storage.d.ts +43 -3
  141. package/dist/types/session/volatile-context-pruning.d.ts +15 -0
  142. package/dist/types/skill-state/{deep-interview-mutation-guard.d.ts → workflow-mutation-guard.d.ts} +24 -8
  143. package/dist/types/skill-state/workflow-state-contract.d.ts +1 -3
  144. package/dist/types/slash-commands/builtin-registry.d.ts +4 -1
  145. package/dist/types/system-prompt.d.ts +2 -11
  146. package/dist/types/task/executor.d.ts +3 -1
  147. package/dist/types/thinking.d.ts +7 -0
  148. package/dist/types/tool-discovery/tool-index.d.ts +1 -50
  149. package/dist/types/tools/ask.d.ts +64 -2
  150. package/dist/types/tools/bash.d.ts +3 -0
  151. package/dist/types/tools/browser/dead-tab-recovery.d.ts +24 -0
  152. package/dist/types/tools/browser/tab-supervisor.d.ts +21 -0
  153. package/dist/types/tools/cron.d.ts +2 -0
  154. package/dist/types/tools/fetch.d.ts +1 -0
  155. package/dist/types/tools/index.d.ts +30 -15
  156. package/dist/types/tools/output-meta.d.ts +1 -0
  157. package/dist/types/tools/read.d.ts +3 -0
  158. package/dist/types/tools/search-tool-bm25.d.ts +3 -4
  159. package/dist/types/utils/event-bus.d.ts +1 -1
  160. package/dist/types/web/search/index.d.ts +3 -1
  161. package/package.json +21 -9
  162. package/scripts/build-sdk-package-smoke.ts +34 -1
  163. package/scripts/generate-hotkeys-docs.ts +74 -0
  164. package/scripts/generate-sdk-operation-inventory.ts +64 -20
  165. package/scripts/verify-gjc-sdk-canonicalization.ts +182 -2
  166. package/src/async/job-manager.ts +49 -6
  167. package/src/cli/config-cli.ts +75 -2
  168. package/src/cli/notify-cli.ts +92 -26
  169. package/src/cli/session-picker.ts +20 -3
  170. package/src/cli/setup-cli.ts +1 -1
  171. package/src/cli.ts +1 -1
  172. package/src/commands/config.ts +1 -1
  173. package/src/commands/harness.ts +34 -106
  174. package/src/commands/launch.ts +6 -4
  175. package/src/commands/mcp-serve.ts +85 -8
  176. package/src/commands/mcp.ts +2 -2
  177. package/src/commands/notify.ts +8 -4
  178. package/src/commands/sdk.ts +61 -40
  179. package/src/commands/ultragoal.ts +4 -1
  180. package/src/config/atomic-yaml-patch.ts +80 -13
  181. package/src/config/file-lock.ts +105 -43
  182. package/src/config/keybindings.ts +64 -20
  183. package/src/config/model-auth.ts +9 -0
  184. package/src/config/model-bindings-applier.ts +139 -0
  185. package/src/config/model-discovery-manager.ts +224 -0
  186. package/src/config/model-equivalence.ts +66 -0
  187. package/src/config/model-registry.ts +169 -302
  188. package/src/config/model-resolver.ts +361 -369
  189. package/src/config/models-config-schema.ts +5 -4
  190. package/src/config/settings-schema.ts +159 -2
  191. package/src/config/settings.ts +458 -482
  192. package/src/coordinator-mcp/policy.ts +76 -11
  193. package/src/coordinator-mcp/question-gate-codec.ts +274 -0
  194. package/src/coordinator-mcp/question-state.ts +778 -0
  195. package/src/coordinator-mcp/server.ts +1702 -238
  196. package/src/debug/index.ts +1 -1
  197. package/src/defaults/gjc/skills/deep-interview/SKILL.md +13 -1
  198. package/src/defaults/gjc/skills/team/SKILL.md +16 -0
  199. package/src/discovery/helpers.ts +1 -50
  200. package/src/edit/index.ts +64 -0
  201. package/src/eval/py/env.ts +26 -0
  202. package/src/eval/py/kernel.ts +5 -3
  203. package/src/extensibility/extensions/loader.ts +9 -1
  204. package/src/extensibility/extensions/runner.ts +12 -3
  205. package/src/extensibility/extensions/types.ts +39 -1
  206. package/src/extensibility/gjc-plugins/installer.ts +16 -16
  207. package/src/extensibility/hooks/runner.ts +2 -2
  208. package/src/extensibility/runtime-skill-discovery.ts +33 -13
  209. package/src/extensibility/shared-events.ts +2 -0
  210. package/src/gjc-runtime/deep-interview-recorder.ts +63 -3
  211. package/src/gjc-runtime/deep-interview-runtime.ts +76 -27
  212. package/src/gjc-runtime/deep-interview-state.ts +317 -5
  213. package/src/gjc-runtime/goal-mode-request.ts +55 -8
  214. package/src/gjc-runtime/launch-tmux.ts +2 -5
  215. package/src/gjc-runtime/launch-worktree.ts +5 -5
  216. package/src/gjc-runtime/linux-proc.ts +71 -0
  217. package/src/gjc-runtime/ralplan-runtime.ts +74 -90
  218. package/src/gjc-runtime/session-resolution.ts +18 -3
  219. package/src/gjc-runtime/session-state-sidecar.ts +6 -107
  220. package/src/gjc-runtime/state-migrations.ts +1 -1
  221. package/src/gjc-runtime/state-runtime.ts +320 -249
  222. package/src/gjc-runtime/state-writer.ts +36 -40
  223. package/src/gjc-runtime/team-launch.ts +319 -0
  224. package/src/gjc-runtime/team-notify.ts +326 -0
  225. package/src/gjc-runtime/team-runtime.ts +1099 -1482
  226. package/src/gjc-runtime/team-store.ts +828 -0
  227. package/src/gjc-runtime/team-workers.ts +962 -0
  228. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +2 -11
  229. package/src/gjc-runtime/tmux-owner-isolation.ts +14 -9
  230. package/src/gjc-runtime/tmux-sessions.ts +4 -21
  231. package/src/gjc-runtime/ultragoal-change-set.ts +172 -0
  232. package/src/gjc-runtime/ultragoal-evidence.ts +874 -0
  233. package/src/gjc-runtime/ultragoal-guard.ts +29 -5
  234. package/src/gjc-runtime/ultragoal-pipeline.ts +306 -0
  235. package/src/gjc-runtime/ultragoal-runtime.ts +84 -1311
  236. package/src/gjc-runtime/workflow-cli-common.ts +30 -0
  237. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  238. package/src/gjc-runtime/workflow-manifest.ts +18 -0
  239. package/src/goals/continuation-timeout-guard.ts +79 -0
  240. package/src/goals/runtime.ts +7 -5
  241. package/src/goals/state.ts +18 -0
  242. package/src/harness-control-plane/lifecycle-reconciliation.ts +64 -0
  243. package/src/harness-control-plane/phase-rollup.ts +4 -7
  244. package/src/harness-control-plane/receipt-ingest.ts +5 -0
  245. package/src/harness-control-plane/receipt-spool.ts +51 -7
  246. package/src/harness-control-plane/session-lease.ts +12 -130
  247. package/src/hindsight/backend.ts +26 -27
  248. package/src/hindsight/state.ts +101 -32
  249. package/src/hooks/skill-state.ts +17 -11
  250. package/src/internal-urls/docs-index.generated.ts +21 -18
  251. package/src/lsp/client.ts +1 -1
  252. package/src/lsp/config.ts +145 -26
  253. package/src/lsp/index.ts +1 -1
  254. package/src/lsp/lspmux.ts +39 -10
  255. package/src/lsp/path-trust.ts +81 -0
  256. package/src/main.ts +28 -8
  257. package/src/memories/index.ts +1 -0
  258. package/src/memories/storage.ts +6 -2
  259. package/src/modes/action-registry.ts +165 -0
  260. package/src/modes/components/chrome.ts +40 -0
  261. package/src/modes/components/command-palette.ts +168 -0
  262. package/src/modes/components/custom-editor.ts +1 -1
  263. package/src/modes/components/custom-provider-wizard.ts +51 -14
  264. package/src/modes/components/index.ts +2 -1
  265. package/src/modes/components/pet-selector.ts +11 -17
  266. package/src/modes/components/plan-preview-overlay.ts +295 -0
  267. package/src/modes/components/queue-mode-selector.ts +10 -27
  268. package/src/modes/components/queue-pane.ts +79 -0
  269. package/src/modes/components/read-tool-group.ts +13 -0
  270. package/src/modes/components/session-observer-overlay.ts +207 -772
  271. package/src/modes/components/sessions-dashboard.ts +181 -0
  272. package/src/modes/components/settings-selector.ts +1 -1
  273. package/src/modes/components/show-images-selector.ts +10 -24
  274. package/src/modes/components/status-line/types.ts +1 -1
  275. package/src/modes/components/status-line.ts +1 -969
  276. package/src/modes/components/tasks-pane.ts +73 -0
  277. package/src/modes/components/theme-selector.ts +11 -31
  278. package/src/modes/components/tool-execution.ts +22 -0
  279. package/src/modes/components/tool-status-header.ts +1056 -0
  280. package/src/modes/components/transcript-viewer-overlay.ts +384 -0
  281. package/src/modes/controllers/btw-controller.ts +1 -0
  282. package/src/modes/controllers/event-controller.ts +3 -2
  283. package/src/modes/controllers/extension-ui-controller.ts +24 -1
  284. package/src/modes/controllers/goal-mode-controller.ts +474 -0
  285. package/src/modes/controllers/input-controller.ts +764 -229
  286. package/src/modes/controllers/mode-gate.ts +24 -0
  287. package/src/modes/controllers/plan-mode-controller.ts +517 -0
  288. package/src/modes/controllers/selector-controller.ts +225 -10
  289. package/src/modes/controllers/stt-controller.ts +125 -0
  290. package/src/modes/interactive-mode.ts +289 -1150
  291. package/src/modes/jobs-observer.ts +3 -0
  292. package/src/modes/runtime-init.ts +8 -0
  293. package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -106
  294. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +71 -16
  295. package/src/modes/shared/agent-wire/workflow-gate-types.ts +177 -0
  296. package/src/modes/tasks-aggregator.ts +199 -0
  297. package/src/modes/transcript-item-registry.ts +229 -0
  298. package/src/modes/types.ts +45 -18
  299. package/src/modes/utils/ui-helpers.ts +17 -9
  300. package/src/prompts/agent-fragments/ralplan-persistence.md +6 -0
  301. package/src/prompts/agent-fragments/restricted-bash.md +1 -0
  302. package/src/prompts/agents/architect.md +2 -8
  303. package/src/prompts/agents/critic.md +2 -8
  304. package/src/prompts/agents/executor.md +3 -0
  305. package/src/prompts/agents/planner.md +4 -12
  306. package/src/prompts/system/custom-system-prompt.md +1 -23
  307. package/src/prompts/system/plan-mode-approved.md +8 -0
  308. package/src/prompts/system/system-prompt.md +17 -96
  309. package/src/prompts/tools/bash.md +4 -4
  310. package/src/prompts/tools/browser.md +15 -52
  311. package/src/prompts/tools/find.md +2 -2
  312. package/src/prompts/tools/hashline.md +13 -55
  313. package/src/prompts/tools/read.md +3 -6
  314. package/src/prompts/tools/search-tool-bm25.md +0 -2
  315. package/src/prompts/tools/search.md +2 -4
  316. package/src/prompts/tools/skill-discovery.md +1 -2
  317. package/src/prompts/tools/task.md +1 -1
  318. package/src/runtime-mcp/manager.ts +1 -1
  319. package/src/sdk/broker/broker.ts +206 -78
  320. package/src/sdk/broker/discovery.ts +1 -1
  321. package/src/sdk/broker/ensure.ts +3 -1
  322. package/src/sdk/broker/lifecycle-codec.ts +11 -0
  323. package/src/sdk/broker/lifecycle-ledger.ts +371 -74
  324. package/src/sdk/broker/lifecycle.ts +1399 -142
  325. package/src/sdk/broker/process-incarnation.ts +5 -13
  326. package/src/sdk/broker/session-index.ts +145 -56
  327. package/src/sdk/broker/state-version.ts +21 -0
  328. package/src/sdk/broker/transport.ts +1 -1
  329. package/src/sdk/bus/chat-daemon-cli.ts +2 -0
  330. package/src/sdk/bus/chat-daemon-control.ts +8 -28
  331. package/src/sdk/bus/chat-effect-journal.ts +10 -2
  332. package/src/sdk/bus/config-commands.ts +5 -4
  333. package/src/sdk/bus/config.ts +98 -0
  334. package/src/sdk/bus/conversation-store.ts +5 -29
  335. package/src/sdk/bus/daemon-paths.ts +2 -0
  336. package/src/sdk/bus/html-format.ts +20 -3
  337. package/src/sdk/bus/index.ts +778 -93
  338. package/src/sdk/bus/lifecycle-control-runtime.ts +91 -33
  339. package/src/sdk/bus/natives-augment.d.ts +26 -1
  340. package/src/sdk/bus/notification-service.ts +168 -20
  341. package/src/sdk/bus/rate-limit-pool.ts +58 -14
  342. package/src/sdk/bus/recent-activity.ts +188 -74
  343. package/src/sdk/bus/rich-document.ts +258 -0
  344. package/src/sdk/bus/rich-render.ts +62 -70
  345. package/src/sdk/bus/telegram-daemon-cli.ts +15 -59
  346. package/src/sdk/bus/telegram-daemon-contract.ts +9 -4
  347. package/src/sdk/bus/telegram-daemon.ts +1389 -237
  348. package/src/sdk/bus/threaded-inbound.ts +8 -3
  349. package/src/sdk/bus/threaded-render.ts +57 -1
  350. package/src/sdk/bus/topic-registry.ts +120 -13
  351. package/src/sdk/host/control/dispatch.ts +14 -2
  352. package/src/sdk/host/control/operations.ts +6 -1
  353. package/src/sdk/host/host.ts +31 -1
  354. package/src/sdk/host/query/handlers.ts +20 -22
  355. package/src/sdk/host/query/revision-store.ts +80 -57
  356. package/src/sdk/index.ts +1 -0
  357. package/src/sdk/protocol/operation-inventory.generated.json +943 -1907
  358. package/src/sdk/session-directory.ts +141 -0
  359. package/src/sdk/session.ts +458 -479
  360. package/src/session/agent-session.ts +1700 -583
  361. package/src/session/agent-storage.ts +18 -0
  362. package/src/session/cache-economics.ts +25 -8
  363. package/src/session/fallback-chain-controller.ts +53 -3
  364. package/src/session/internal/managed-session-scope.ts +2226 -0
  365. package/src/session/internal/managed-session-storage.ts +453 -0
  366. package/src/session/messages.ts +91 -2
  367. package/src/session/session-manager-internal.ts +33 -0
  368. package/src/session/session-manager.ts +1112 -333
  369. package/src/session/session-storage.ts +444 -39
  370. package/src/session/volatile-context-pruning.ts +78 -0
  371. package/src/setup/credential-auto-import.ts +2 -1
  372. package/src/setup/hermes/templates/operator-instructions.v1.md +1 -1
  373. package/src/setup/provider-onboarding.ts +36 -61
  374. package/src/setup/provider-presets.json +5 -0
  375. package/src/skill-state/active-state.ts +3 -13
  376. package/src/skill-state/{deep-interview-mutation-guard.ts → workflow-mutation-guard.ts} +47 -29
  377. package/src/skill-state/workflow-state-contract.ts +6 -41
  378. package/src/slash-commands/builtin-registry.ts +46 -11
  379. package/src/system-prompt.ts +37 -44
  380. package/src/task/agents.ts +19 -1
  381. package/src/task/executor.ts +90 -6
  382. package/src/task/index.ts +36 -21
  383. package/src/thinking.ts +21 -6
  384. package/src/tool-discovery/tool-index.ts +2 -134
  385. package/src/tools/ask.ts +157 -21
  386. package/src/tools/ast-edit.ts +1 -1
  387. package/src/tools/bash.ts +38 -0
  388. package/src/tools/browser/dead-tab-recovery.ts +78 -0
  389. package/src/tools/browser/tab-supervisor.ts +282 -36
  390. package/src/tools/computer.ts +10 -3
  391. package/src/tools/cron.ts +30 -16
  392. package/src/tools/eval.ts +1 -2
  393. package/src/tools/fetch.ts +11 -4
  394. package/src/tools/index.ts +76 -24
  395. package/src/tools/output-meta.ts +13 -5
  396. package/src/tools/read.ts +37 -1
  397. package/src/tools/resource-gc.ts +2 -2
  398. package/src/tools/search-tool-bm25.ts +11 -74
  399. package/src/utils/event-bus.ts +9 -4
  400. package/src/web/search/index.ts +3 -3
  401. package/dist/types/discovery/plugin-dir-roots.d.ts +0 -15
  402. package/dist/types/runtime-mcp/discoverable-tool-metadata.d.ts +0 -7
  403. package/src/discovery/plugin-dir-roots.ts +0 -28
  404. package/src/runtime-mcp/discoverable-tool-metadata.ts +0 -25
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { spawn as childProcessSpawn } from "node:child_process";
2
3
  import * as crypto from "node:crypto";
3
4
  import * as fs from "node:fs";
@@ -21,6 +22,7 @@ import {
21
22
  buildButtonGrid,
22
23
  buildCompactChoiceGrid,
23
24
  code,
25
+ markdownToTelegramHtml,
24
26
  splitTelegramHtml,
25
27
  TELEGRAM_MESSAGE_LIMIT,
26
28
  TELEGRAM_PARSE_MODE,
@@ -54,7 +56,12 @@ import { RateLimitPool } from "./rate-limit-pool";
54
56
  import { listRecentSessions } from "./recent-activity";
55
57
  import { ReplySentStore } from "./reply-sent-store";
56
58
  import { DraftStreamState, deliverDraft, shouldStreamDraft } from "./rich-draft";
57
- import { deliverRichActionWithFallback, deliverRichWithFallback, shouldPromoteRich } from "./rich-render";
59
+ import {
60
+ deliverRichActionWithFallback,
61
+ deliverRichWithFallback,
62
+ isBtwRichEligible,
63
+ shouldPromoteRich,
64
+ } from "./rich-render";
58
65
  import {
59
66
  type AliasTable,
60
67
  buildActionMarkdown,
@@ -80,6 +87,11 @@ export interface DaemonState {
80
87
  startedAt: number;
81
88
  heartbeatAt: number;
82
89
  roots: string[];
90
+ /**
91
+ * Present only for the Windows source-launch handoff. `pid` starts as this
92
+ * short-lived launcher PID and may be rebound exactly once to the daemon PID.
93
+ */
94
+ launcherPid?: number;
83
95
  version: 1;
84
96
  /**
85
97
  * Operational daemon generation of the process that owns the lock, distinct
@@ -117,6 +129,8 @@ export interface TelegramDaemonDeps {
117
129
  opts: { detached: boolean; stdio: "ignore"; logPath?: string },
118
130
  ) => SpawnResult;
119
131
  execPath?: string;
132
+ /** Injectable platform seam for source-linked Windows daemon spawning. */
133
+ platform?: NodeJS.Platform;
120
134
  randomId?: () => string;
121
135
  /**
122
136
  * Signal delivery + poll timing for the stale-generation reload handoff in
@@ -136,6 +150,8 @@ export const CLIENT_PING_PONG_CAPABILITY = "client_ping_pong";
136
150
  /** Capability required for typed controls and semantic Selected acknowledgement frames. */
137
151
  export const ASK_SELECTED_ACK_CAPABILITY = "ask_selected_ack_v1";
138
152
  export const ASK_CONTROLS_CAPABILITY = "ask_controls_v1";
153
+ /** Capability required for tool lifecycle and reasoning-summary frames. */
154
+ export const TOOL_ACTIVITY_CAPABILITY = "tool_activity_v1";
139
155
 
140
156
  const nodeFs: TelegramDaemonFs = fs.promises as unknown as TelegramDaemonFs;
141
157
 
@@ -177,7 +193,50 @@ const ORPHAN_TOPIC_GRACE_MS = 60_000;
177
193
  const CONSUMED_REACTION = "✅";
178
194
  const MODEL_CALLBACK_PREFIX = "m:";
179
195
  const MODEL_CHOICE_TTL_MS = 10 * 60 * 1_000;
196
+ const BTW_PENDING_TTL_MS = 300_000;
197
+ const BTW_MAX_PENDING = 256;
198
+ const BTW_SHUTDOWN_JOIN_MS = 1_000;
199
+ const BTW_USAGE_TEXT = "Usage: /btw <question>";
200
+ const BTW_CAPACITY_TEXT = "Too many /btw questions are pending. Wait for one to finish and try again.";
201
+ export const BTW_QUESTION_MAX_UNICODE_SCALARS = 4_096;
202
+ export const BTW_QUESTION_MAX_UTF8_BYTES = 16_384;
203
+ const BTW_QUESTION_LIMIT_TEXT = "Question must be at most 4096 Unicode scalar values and 16384 UTF-8 bytes.";
204
+ type ParsedBtwCommand = { kind: "question"; question: string } | { kind: "ignored" };
205
+ class ThreadedModeCapabilityRefusal extends Error {}
206
+
207
+ /** Only an explicit Bot API capability refusal may enable flat private-chat delivery. */
208
+ function isThreadedModeCapabilityRefusal(response: unknown): boolean {
209
+ if (!response || typeof response !== "object") return false;
210
+ const { ok, description } = response as { ok?: unknown; description?: unknown };
211
+ return (
212
+ ok === false &&
213
+ typeof description === "string" &&
214
+ /(?:threaded mode|forum topics? (?:is|are) (?:disabled|not enabled)|(?:not allowed|not permitted|cannot|can't) create forum topics?)/i.test(
215
+ description,
216
+ )
217
+ );
218
+ }
180
219
 
220
+ function parseBtwCommand(text: string, botUsername?: string): ParsedBtwCommand | undefined {
221
+ const trimmed = text.trim();
222
+ const [rawCommand] = trimmed.split(/\s+/, 1);
223
+ if (!rawCommand) return undefined;
224
+ const command = rawCommand.toLowerCase();
225
+ if (command === "/btw") return { kind: "question", question: trimmed.slice(rawCommand.length).trim() };
226
+ const match = /^\/btw@([^@]+)$/.exec(command);
227
+ if (!match) return command.startsWith("/btw") ? { kind: "question", question: "" } : undefined;
228
+ if (!botUsername || match[1] !== botUsername.toLowerCase()) return { kind: "ignored" };
229
+ return { kind: "question", question: trimmed.slice(rawCommand.length).trim() };
230
+ }
231
+ function isValidBtwQuestion(question: string): boolean {
232
+ if (Buffer.byteLength(question, "utf8") > BTW_QUESTION_MAX_UTF8_BYTES) return false;
233
+ let scalarCount = 0;
234
+ for (const _scalar of question) {
235
+ scalarCount++;
236
+ if (scalarCount > BTW_QUESTION_MAX_UNICODE_SCALARS) return false;
237
+ }
238
+ return true;
239
+ }
181
240
  const MODEL_BUTTON_LABEL_MAX_BYTES = 48;
182
241
  const SENSITIVE_MODEL_LABEL =
183
242
  /(?:\b(?:https?|wss?):\/\/|\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b|\b(?:api[-_ ]?key|access[-_ ]?token|bearer|secret|password|account(?:\s*id)?|email|exception|stack trace)\b|\b(?:sk|pk|rk)-[A-Za-z0-9_-]{12,}\b)/i;
@@ -220,13 +279,38 @@ function splitTelegramPlainText(text: string, max = TELEGRAM_MESSAGE_LIMIT): str
220
279
  if (out) chunks.push(out);
221
280
  return chunks;
222
281
  }
282
+ export function endpointAuthorityDigest(url: string, token: string, connectionIdentity?: string): string {
283
+ // Discovery supplies the authenticated endpoint; normalize presentation-only
284
+ // URL differences before deriving authority, but never trust a client frame.
285
+ const parsed = new URL(url);
286
+ parsed.hash = "";
287
+ parsed.search = "";
288
+ parsed.hostname = parsed.hostname.toLowerCase();
289
+ if (
290
+ ((parsed.protocol === "http:" || parsed.protocol === "ws:") && parsed.port === "80") ||
291
+ ((parsed.protocol === "https:" || parsed.protocol === "wss:") && parsed.port === "443")
292
+ )
293
+ parsed.port = "";
294
+ return crypto
295
+ .createHash("sha256")
296
+ .update(`${parsed.toString()}\0${token}\0${connectionIdentity ?? ""}`, "utf8")
297
+ .digest("hex");
298
+ }
299
+
223
300
  function endpointGenerationKey(url: string, token: string): string {
224
- return `${url}\0${token}`;
301
+ return endpointAuthorityDigest(url, token);
225
302
  }
226
303
 
227
304
  function topicRenameApplied(response: unknown): boolean {
228
305
  return !!response && typeof response === "object" && (response as { ok?: unknown }).ok === true;
229
306
  }
307
+ function topicDeleteSettled(response: unknown): boolean {
308
+ if (!response || typeof response !== "object") return false;
309
+ const result = response as { ok?: unknown; description?: unknown };
310
+ if (result.ok === true) return true;
311
+ if (typeof result.description !== "string") return false;
312
+ return /(?:TOPIC_ID_INVALID|message thread not found)/i.test(result.description);
313
+ }
230
314
 
231
315
  /**
232
316
  * Whether `err` is a transient network failure worth retrying. Telegram API
@@ -307,6 +391,7 @@ export function startDaemonLifecycleControl(input: {
307
391
  const deps = buildOrchestratorDeps({
308
392
  pairedChatId: input.pairedChatId,
309
393
  agentNotificationsDir: daemonPaths(input.agentDir).dir,
394
+ agentDir: input.agentDir,
310
395
  sessionsRoot: path.join(input.agentDir, "sessions"),
311
396
  auditRedactionKey: input.auditRedactionKey,
312
397
  env: input.env,
@@ -346,6 +431,24 @@ async function tryOpenWx(fsImpl: TelegramDaemonFs, file: string): Promise<boolea
346
431
  throw error;
347
432
  }
348
433
  }
434
+ function validDaemonPid(pid: unknown): pid is number {
435
+ return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0;
436
+ }
437
+
438
+ async function tryCreateOwnershipLock(
439
+ fsImpl: TelegramDaemonFs,
440
+ file: string,
441
+ initialization: { pid: number; startedAt: number },
442
+ ): Promise<boolean> {
443
+ try {
444
+ await fsImpl.writeFile(file, `${JSON.stringify(initialization)}\n`, { mode: 0o600, flag: "wx" });
445
+ await fsImpl.chmod(file, 0o600).catch(() => undefined);
446
+ return true;
447
+ } catch (error) {
448
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
449
+ throw error;
450
+ }
451
+ }
349
452
 
350
453
  export async function registerNotificationRoot(input: {
351
454
  settings: Settings;
@@ -417,6 +520,10 @@ function notificationRootForCwd(cwd: string): string {
417
520
  return path.join(cwd, ".gjc", "state");
418
521
  }
419
522
 
523
+ function validBotToken(token: unknown): token is string {
524
+ return typeof token === "string" && token.length > 0;
525
+ }
526
+
420
527
  function ownerIdentityMatches(state: DaemonState, tokenFingerprint: string, chatId: string): boolean {
421
528
  return state.tokenFingerprint === tokenFingerprint && state.chatId === chatId;
422
529
  }
@@ -431,7 +538,9 @@ function liveOwnerUsesDifferentIdentity(input: {
431
538
  return Boolean(
432
539
  state &&
433
540
  state.version === DAEMON_VERSION &&
541
+ state.stoppedAt === undefined &&
434
542
  !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
543
+ validDaemonPid(state.pid) &&
435
544
  input.pidAlive(state.pid),
436
545
  );
437
546
  }
@@ -447,8 +556,10 @@ export function isFreshLiveOwner(input: {
447
556
  return Boolean(
448
557
  state &&
449
558
  state.version === DAEMON_VERSION &&
559
+ state.stoppedAt === undefined &&
450
560
  ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
451
561
  input.now - state.heartbeatAt <= HEARTBEAT_TTL_MS &&
562
+ validDaemonPid(state.pid) &&
452
563
  input.pidAlive(state.pid),
453
564
  );
454
565
  }
@@ -463,6 +574,10 @@ export async function acquireDaemonOwnership(input: {
463
574
  pid?: number;
464
575
  pidAlive?: (pid: number) => boolean;
465
576
  randomId?: () => string;
577
+ /** Permit one Windows source launcher PID to daemon PID handoff. */
578
+ allowPidRebind?: boolean;
579
+ /** A caller-supplied opaque owner identity, used when the launcher PID is not durable. */
580
+ ownerId?: string;
466
581
  }): Promise<{
467
582
  acquired: boolean;
468
583
  ownerId?: string;
@@ -474,142 +589,192 @@ export async function acquireDaemonOwnership(input: {
474
589
  const fsImpl = input.fs ?? nodeFs;
475
590
  const now = input.now ?? Date.now;
476
591
  const pid = input.pid ?? process.pid;
592
+ if (!validDaemonPid(pid)) return { acquired: false, attached: true };
477
593
  const pidAlive = input.pidAlive ?? defaultPidAlive;
478
594
  const paths = daemonPaths(input.settings.getAgentDir());
479
595
  await ensureDir(fsImpl, paths.dir);
480
- const ownerId = input.randomId?.() ?? `${pid}-${now().toString(36)}-${Math.random().toString(36).slice(2)}`;
596
+ const ownerId =
597
+ input.ownerId ?? input.randomId?.() ?? `${pid}-${now().toString(36)}-${Math.random().toString(36).slice(2)}`;
481
598
  const roots = input.roots ?? (await readJson<{ roots?: string[] }>(fsImpl, paths.roots))?.roots ?? [];
482
-
483
- // A fresh, identity-matching live owner running an OLDER generation than this
484
- // build cannot serve our newer wire frames; signal a reload instead of a
485
- // silent attach. Newer/equal generations attach as before (no downgrade).
486
- const attachDecision = (
487
- state: DaemonState | undefined,
488
- ): { acquired: false; attached: boolean; reloadRequired?: boolean } | undefined => {
489
- if (
490
- !isFreshLiveOwner({
491
- state,
492
- now: now(),
599
+ return withFileLock(paths.ownership, async () => {
600
+ const lockIsPositivelyStale = async (): Promise<boolean> => {
601
+ let initialization: { pid?: unknown; startedAt?: unknown } | undefined;
602
+ try {
603
+ initialization = await readJson<{ pid?: unknown; startedAt?: unknown }>(fsImpl, paths.lock);
604
+ } catch {
605
+ // Legacy daemons wrote an empty lock file. With no live state to
606
+ // identify an owner, malformed metadata must not wedge startup.
607
+ return true;
608
+ }
609
+ if (!initialization) return true;
610
+ if (
611
+ !validDaemonPid(initialization.pid) ||
612
+ typeof initialization.startedAt !== "number" ||
613
+ !Number.isFinite(initialization.startedAt)
614
+ )
615
+ return true;
616
+ return now() - initialization.startedAt > HEARTBEAT_TTL_MS && !pidAlive(initialization.pid);
617
+ };
618
+ const ownershipIsPositivelyStale = async (state: DaemonState | undefined): Promise<boolean> => {
619
+ if (state?.stoppedAt !== undefined) return true;
620
+ if (state && validDaemonPid(state.pid)) return !pidAlive(state.pid);
621
+ return await lockIsPositivelyStale();
622
+ };
623
+ const writeState = async (): Promise<void> => {
624
+ const timestamp = now();
625
+ await writeJsonAtomic(fsImpl, paths.state, {
626
+ pid,
627
+ ownerId,
493
628
  tokenFingerprint: input.tokenFingerprint,
494
629
  chatId: input.chatId,
495
- pidAlive,
496
- })
497
- ) {
498
- return undefined;
499
- }
500
- return (state?.generation ?? 0) < DAEMON_GENERATION
501
- ? { acquired: false, attached: false, reloadRequired: true }
502
- : { acquired: false, attached: true };
503
- };
504
- const existing = await readJson<DaemonState>(fsImpl, paths.state);
505
- if (
506
- liveOwnerUsesDifferentIdentity({
507
- state: existing,
508
- tokenFingerprint: input.tokenFingerprint,
509
- chatId: input.chatId,
510
- pidAlive,
511
- })
512
- ) {
513
- return { acquired: false, blocked: true, reason: "identity_mismatch" };
514
- }
515
- const existingDecision = attachDecision(existing);
516
- if (existingDecision) return existingDecision;
517
- if (await tryOpenWx(fsImpl, paths.lock)) {
518
- await writeJsonAtomic(fsImpl, paths.state, {
519
- pid,
520
- ownerId,
521
- tokenFingerprint: input.tokenFingerprint,
522
- chatId: input.chatId,
523
- startedAt: now(),
524
- heartbeatAt: now(),
525
- roots,
526
- version: DAEMON_VERSION,
527
- generation: DAEMON_GENERATION,
528
- } satisfies DaemonState);
529
- return { acquired: true, ownerId };
530
- }
531
- const afterLock = await readJson<DaemonState>(fsImpl, paths.state);
532
- if (
533
- liveOwnerUsesDifferentIdentity({
534
- state: afterLock,
535
- tokenFingerprint: input.tokenFingerprint,
536
- chatId: input.chatId,
537
- pidAlive,
538
- })
539
- ) {
540
- return { acquired: false, blocked: true, reason: "identity_mismatch" };
541
- }
542
- const afterLockDecision = attachDecision(afterLock);
543
- if (afterLockDecision) return afterLockDecision;
544
- if (!afterLock) return { acquired: false, attached: true };
545
- if (!(await tryOpenWx(fsImpl, paths.steal))) return { acquired: false, attached: true };
546
- try {
547
- const rechecked = await readJson<DaemonState>(fsImpl, paths.state);
548
- const recheckedDecision = attachDecision(rechecked);
549
- if (recheckedDecision) return recheckedDecision;
550
- if (
630
+ startedAt: timestamp,
631
+ heartbeatAt: timestamp,
632
+ roots,
633
+ version: DAEMON_VERSION,
634
+ generation: DAEMON_GENERATION,
635
+ ...(input.allowPidRebind ? { launcherPid: pid } : {}),
636
+ } satisfies DaemonState);
637
+ };
638
+ const attachDecision = (
639
+ state: DaemonState | undefined,
640
+ ): { acquired: false; attached: boolean; reloadRequired?: boolean } | undefined => {
641
+ if (
642
+ !isFreshLiveOwner({
643
+ state,
644
+ now: now(),
645
+ tokenFingerprint: input.tokenFingerprint,
646
+ chatId: input.chatId,
647
+ pidAlive,
648
+ })
649
+ ) {
650
+ return undefined;
651
+ }
652
+ return (state?.generation ?? 0) < DAEMON_GENERATION
653
+ ? { acquired: false, attached: false, reloadRequired: true }
654
+ : { acquired: false, attached: true };
655
+ };
656
+ const rejectLiveDifferentIdentity = (state: DaemonState | undefined) =>
551
657
  liveOwnerUsesDifferentIdentity({
552
- state: rechecked,
658
+ state,
553
659
  tokenFingerprint: input.tokenFingerprint,
554
660
  chatId: input.chatId,
555
661
  pidAlive,
556
- })
557
- ) {
558
- return { acquired: false, blocked: true, reason: "identity_mismatch" };
662
+ });
663
+ const existing = await readJson<DaemonState>(fsImpl, paths.state);
664
+ if (rejectLiveDifferentIdentity(existing)) {
665
+ return { acquired: false, blocked: true, reason: "identity_mismatch" } as const;
559
666
  }
560
- if (rechecked && pidAlive(rechecked.pid)) {
561
- return { acquired: false, attached: true };
667
+ const existingDecision = attachDecision(existing);
668
+ if (existingDecision) return existingDecision;
669
+ const createdAt = now();
670
+ if (await tryCreateOwnershipLock(fsImpl, paths.lock, { pid, startedAt: createdAt })) {
671
+ await writeState();
672
+ return { acquired: true, ownerId };
562
673
  }
563
- await fsImpl.unlink(paths.lock).catch(() => undefined);
564
- if (!(await tryOpenWx(fsImpl, paths.lock))) return { acquired: false, attached: true };
565
- await writeJsonAtomic(fsImpl, paths.state, {
566
- pid,
567
- ownerId,
568
- tokenFingerprint: input.tokenFingerprint,
569
- chatId: input.chatId,
570
- startedAt: now(),
571
- heartbeatAt: now(),
572
- roots,
573
- version: DAEMON_VERSION,
574
- generation: DAEMON_GENERATION,
575
- } satisfies DaemonState);
576
- return { acquired: true, ownerId };
577
- } finally {
578
- await fsImpl.unlink(paths.steal).catch(() => undefined);
579
- }
674
+ const afterLock = await readJson<DaemonState>(fsImpl, paths.state);
675
+ if (rejectLiveDifferentIdentity(afterLock)) {
676
+ return { acquired: false, blocked: true, reason: "identity_mismatch" } as const;
677
+ }
678
+ const afterLockDecision = attachDecision(afterLock);
679
+ if (afterLockDecision) return afterLockDecision;
680
+ if (!afterLock && !(await lockIsPositivelyStale())) return { acquired: false, attached: true };
681
+ if (!(await tryOpenWx(fsImpl, paths.steal))) return { acquired: false, attached: true };
682
+ try {
683
+ const rechecked = await readJson<DaemonState>(fsImpl, paths.state);
684
+ const recheckedDecision = attachDecision(rechecked);
685
+ if (recheckedDecision) return recheckedDecision;
686
+ if (rejectLiveDifferentIdentity(rechecked)) {
687
+ return { acquired: false, blocked: true, reason: "identity_mismatch" } as const;
688
+ }
689
+ if (
690
+ rechecked &&
691
+ rechecked.stoppedAt === undefined &&
692
+ validDaemonPid(rechecked.pid) &&
693
+ pidAlive(rechecked.pid)
694
+ ) {
695
+ return { acquired: false, attached: true };
696
+ }
697
+ if (!rechecked && !(await lockIsPositivelyStale())) {
698
+ return { acquired: false, attached: true };
699
+ }
700
+ if (!(await ownershipIsPositivelyStale(rechecked))) {
701
+ return { acquired: false, attached: true };
702
+ }
703
+ await fsImpl.unlink(paths.lock).catch(() => undefined);
704
+ if (!(await tryCreateOwnershipLock(fsImpl, paths.lock, { pid, startedAt: createdAt }))) {
705
+ return { acquired: false, attached: true };
706
+ }
707
+ await writeState();
708
+ return { acquired: true, ownerId };
709
+ } finally {
710
+ await fsImpl.unlink(paths.steal).catch(() => undefined);
711
+ }
712
+ });
580
713
  }
581
714
 
582
715
  export async function renewDaemonHeartbeat(input: {
583
716
  settings: Settings;
584
717
  ownerId: string;
718
+ /** Bind a daemon heartbeat to the Telegram identity that acquired the lock. */
719
+ tokenFingerprint: string;
720
+ chatId: string;
721
+ /** Require the current daemon PID after the launcher-to-daemon PID rebind. */
722
+ pid: number;
585
723
  fs?: TelegramDaemonFs;
586
724
  now?: () => number;
587
- pid?: number;
588
725
  }): Promise<boolean> {
589
726
  const fsImpl = input.fs ?? nodeFs;
590
727
  const paths = daemonPaths(input.settings.getAgentDir());
591
- const state = await readJson<DaemonState>(fsImpl, paths.state);
592
- if (!state || state.ownerId !== input.ownerId) return false;
593
- await writeJsonAtomic(fsImpl, paths.state, {
594
- ...state,
595
- pid: input.pid ?? state.pid,
596
- heartbeatAt: (input.now ?? Date.now)(),
728
+ return withFileLock(paths.ownership, async () => {
729
+ const state = await readJson<DaemonState>(fsImpl, paths.state);
730
+ if (
731
+ !state ||
732
+ state.stoppedAt !== undefined ||
733
+ typeof input.tokenFingerprint !== "string" ||
734
+ typeof input.chatId !== "string" ||
735
+ !Number.isSafeInteger(input.pid) ||
736
+ input.pid <= 0 ||
737
+ state.ownerId !== input.ownerId ||
738
+ !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId)
739
+ )
740
+ return false;
741
+ if (!validDaemonPid(state.pid)) return false;
742
+ if (state.pid !== input.pid && (!validDaemonPid(state.launcherPid) || state.pid !== state.launcherPid))
743
+ return false;
744
+ await writeJsonAtomic(fsImpl, paths.state, {
745
+ ...state,
746
+ pid: input.pid,
747
+ heartbeatAt: (input.now ?? Date.now)(),
748
+ });
749
+ return true;
597
750
  });
598
- return true;
599
751
  }
600
752
 
601
753
  export async function releaseDaemonOwnership(input: {
602
754
  settings: Settings;
603
755
  ownerId: string;
756
+ /** Bind release to the Telegram identity that acquired the lock. */
757
+ tokenFingerprint: string;
758
+ chatId: string;
759
+ /** Require the current daemon PID after the launcher-to-daemon PID rebind. */
760
+ pid: number;
604
761
  fs?: TelegramDaemonFs;
605
762
  now?: () => number;
606
763
  }): Promise<void> {
607
764
  const fsImpl = input.fs ?? nodeFs;
608
765
  const paths = daemonPaths(input.settings.getAgentDir());
609
- const state = await readJson<DaemonState>(fsImpl, paths.state);
610
- if (state?.ownerId !== input.ownerId) return;
611
- await writeJsonAtomic(fsImpl, paths.state, { ...state, stoppedAt: (input.now ?? Date.now)() });
612
- await fsImpl.unlink(paths.lock).catch(() => undefined);
766
+ await withFileLock(paths.ownership, async () => {
767
+ const state = await readJson<DaemonState>(fsImpl, paths.state);
768
+ if (
769
+ !state ||
770
+ state.ownerId !== input.ownerId ||
771
+ !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) ||
772
+ state.pid !== input.pid
773
+ )
774
+ return;
775
+ await writeJsonAtomic(fsImpl, paths.state, { ...state, stoppedAt: (input.now ?? Date.now)() });
776
+ await fsImpl.unlink(paths.lock).catch(() => undefined);
777
+ });
613
778
  }
614
779
 
615
780
  /** Read the persisted daemon ownership state (or undefined when absent). */
@@ -727,6 +892,15 @@ export async function spawnTelegramDaemonOwner(
727
892
  ): Promise<TelegramSpawnOwnerResult> {
728
893
  const agentDir = input.settings.getAgentDir();
729
894
  const execPath = deps.execPath ?? process.execPath;
895
+ const runtimeInfo = resolveGjcRuntimeSpawnInfo(execPath);
896
+ // On Windows, a source-linked Bun/Node detached child can begin after its
897
+ // short-lived CLI parent has exited. Keep the owner id opaque so the
898
+ // daemon-internal launcher does not mistake that parent PID for its owner;
899
+ // the daemon rebinds state.pid and validates token/chat below.
900
+ const ownerId =
901
+ runtimeInfo.mode === "source" && (deps.platform ?? process.platform) === "win32"
902
+ ? `daemon-${deps.randomId?.() ?? crypto.randomUUID()}`
903
+ : undefined;
730
904
  const ownership = await acquireDaemonOwnership({
731
905
  settings: input.settings,
732
906
  roots: input.roots,
@@ -736,9 +910,11 @@ export async function spawnTelegramDaemonOwner(
736
910
  now: deps.now,
737
911
  pid: deps.pid,
738
912
  pidAlive: deps.pidAlive,
739
- randomId: deps.randomId,
913
+ randomId: ownerId ? undefined : deps.randomId,
914
+ ownerId,
915
+ allowPidRebind: true,
740
916
  });
741
- // One source of truth for runtime detection + spawn args (no duplicate resolve).
917
+ // Build args from the shared runtime resolver.
742
918
  const { command, args, runtime } = buildTelegramDaemonSpawnArgs({
743
919
  execPath,
744
920
  ownerId: ownership.ownerId ?? "",
@@ -1131,6 +1307,7 @@ export interface TelegramDaemonOptions {
1131
1307
  clearTimeoutImpl?: typeof clearTimeout;
1132
1308
  setIntervalImpl?: typeof setInterval;
1133
1309
  clearIntervalImpl?: typeof clearInterval;
1310
+ btw?: { enabled: boolean };
1134
1311
  idleTimeoutMs?: number;
1135
1312
  scanIntervalMs?: number;
1136
1313
  pid?: number;
@@ -1174,10 +1351,13 @@ interface SessionSocket {
1174
1351
  logicalSessionId: string;
1175
1352
  token: string;
1176
1353
  endpointKey: string;
1354
+ endpointDigest: string;
1355
+ hostGeneration: number;
1177
1356
  ws: WebSocket;
1178
1357
  pending: Map<string, { sessionId: string; actionId: string }>;
1179
1358
  /** True once the server advertised the `client_ping_pong` capability. */
1180
1359
  capable: boolean;
1360
+ ephemeralCapable: boolean;
1181
1361
  /** Timestamp (via opts.now) of the last received pong; seeds the TTL window. */
1182
1362
  lastPongAt: number;
1183
1363
  /** Nonce of the most recent in-flight ping, if any. */
@@ -1205,6 +1385,12 @@ interface RenderedModelChoice {
1205
1385
  label: string;
1206
1386
  }
1207
1387
 
1388
+ interface TopicAuthorityLease {
1389
+ sessionId: string;
1390
+ topicId: string;
1391
+ authorityEpoch: number;
1392
+ }
1393
+
1208
1394
  interface PendingThreadedFrame {
1209
1395
  send: ThreadedSend;
1210
1396
  msg: Record<string, unknown>;
@@ -1214,6 +1400,15 @@ type SelectedAckOutcome =
1214
1400
  | { status: "delivered"; messageId: number }
1215
1401
  | { status: "failed"; reason: "route_missing" | "expired" | "cancelled" | "telegram_rejected" }
1216
1402
  | { status: "unknown"; reason: "transport_ambiguous" | "shutdown" };
1403
+ type BtwQueuedDeliveryOutcome = "accepted" | "not_delivered" | "uncertain" | "stale" | "partial_accepted";
1404
+
1405
+ interface BtwQueuedDelivery {
1406
+ pending: PendingBtwTurn;
1407
+ body: Record<string, unknown>;
1408
+ signal: AbortSignal;
1409
+ isAuthoritative: () => boolean;
1410
+ finish: (outcome: BtwQueuedDeliveryOutcome) => void;
1411
+ }
1217
1412
 
1218
1413
  interface SelectedAckQueueItem {
1219
1414
  pendingKey: string;
@@ -1229,8 +1424,92 @@ interface SelectedAckQueueItem {
1229
1424
 
1230
1425
  interface TelegramQueuePayload {
1231
1426
  send: ThreadedSend;
1232
- topicId?: string;
1427
+ topicLease?: TopicAuthorityLease;
1233
1428
  selectedAck?: SelectedAckQueueItem;
1429
+ btwDelivery?: BtwQueuedDelivery;
1430
+ }
1431
+
1432
+ interface PendingBtwTurn {
1433
+ /** Immutable transport/topic ownership key. */
1434
+ transportSessionId: string;
1435
+ /** Logical session id supplied to the session endpoint. */
1436
+ logicalSessionId: string;
1437
+ endpointDigest: string;
1438
+ generation: number;
1439
+ question: string;
1440
+ messageId: number;
1441
+ threadId: string;
1442
+ updateId: number;
1443
+ expiresAt: number;
1444
+ }
1445
+ interface PendingBtwDelivery {
1446
+ pending: PendingBtwTurn;
1447
+ /** The owning session changed while this terminal Bot API call was in flight. */
1448
+ invalidated: boolean;
1449
+ /** Whether a definitive authority loss should emit the session-unavailable reply. */
1450
+ terminalizeOnInvalidation: boolean;
1451
+ controller: AbortController;
1452
+ finished: Promise<void>;
1453
+ finish: () => void;
1454
+ }
1455
+
1456
+ class TelegramEffectSupervisor {
1457
+ #stopping = false;
1458
+ readonly #abort = new AbortController();
1459
+ readonly #terminalContext = new AsyncLocalStorage<boolean>();
1460
+ readonly #pending = new Set<Promise<unknown>>();
1461
+
1462
+ call(
1463
+ api: BotApi,
1464
+ method: string,
1465
+ body: unknown,
1466
+ opts?: { signal?: AbortSignal; noRetry?: boolean },
1467
+ ): Promise<unknown> {
1468
+ const terminal = this.#terminalContext.getStore() === true;
1469
+ if (this.#stopping && !terminal)
1470
+ return Promise.reject(Object.assign(new Error("Daemon is stopping"), { name: "AbortError" }));
1471
+ const signal = terminal
1472
+ ? opts?.signal
1473
+ : opts?.signal
1474
+ ? AbortSignal.any([this.#abort.signal, opts.signal])
1475
+ : this.#abort.signal;
1476
+ return this.track(api.call(method, body, { ...opts, signal }));
1477
+ }
1478
+
1479
+ track<T>(effect: Promise<T>): Promise<T> {
1480
+ this.#pending.add(effect);
1481
+ void effect.then(
1482
+ () => this.#pending.delete(effect),
1483
+ () => this.#pending.delete(effect),
1484
+ );
1485
+ return effect;
1486
+ }
1487
+
1488
+ get stopping(): boolean {
1489
+ return this.#stopping;
1490
+ }
1491
+
1492
+ beginShutdown(): void {
1493
+ this.#stopping = true;
1494
+ this.#abort.abort("daemon_shutdown");
1495
+ }
1496
+
1497
+ allowTerminal<T>(effect: () => Promise<T>): Promise<T> {
1498
+ return this.#terminalContext.run(true, () => this.track(effect()));
1499
+ }
1500
+
1501
+ async join(deadlineMs: number): Promise<boolean> {
1502
+ const expiresAt = Date.now() + deadlineMs;
1503
+ while (this.#pending.size > 0) {
1504
+ const remaining = expiresAt - Date.now();
1505
+ if (remaining <= 0) return false;
1506
+ await Promise.race([
1507
+ Promise.allSettled([...this.#pending]),
1508
+ new Promise<void>(resolve => setTimeout(resolve, remaining)),
1509
+ ]);
1510
+ }
1511
+ return true;
1512
+ }
1234
1513
  }
1235
1514
 
1236
1515
  export class TelegramNotificationDaemon {
@@ -1249,12 +1528,22 @@ export class TelegramNotificationDaemon {
1249
1528
  private running = false;
1250
1529
  private readonly fsImpl: TelegramDaemonFs;
1251
1530
  private readonly botApi: BotApi;
1531
+ private readonly effects = new TelegramEffectSupervisor();
1252
1532
  private readonly topics = new TopicRegistry();
1253
1533
  /** Serializes registry snapshots so an older atomic write cannot overwrite newer rename state. */
1254
1534
  private topicsPersistQueue: Promise<void> = Promise.resolve();
1255
1535
  /** Daemon edit attempts that can race an accepted user service message. */
1256
1536
  private readonly daemonRenameAttempts = new Map<string, number>();
1257
1537
  private readonly selectedAckPending = new Map<string, SelectedAckQueueItem>();
1538
+ #pendingBtwTurns = new Map<string, PendingBtwTurn>();
1539
+ #btwTerminalDeliveries = new Map<string, PendingBtwDelivery>();
1540
+ #btwTerminalTombstones = new Map<
1541
+ string,
1542
+ { sessionId: string; updateId: number; messageId: number; threadId: string; expiresAt: number }
1543
+ >();
1544
+ #stoppingBtw = false;
1545
+ readonly #btwDeliveryAbort = new AbortController();
1546
+ readonly #deliveryAbort = new AbortController();
1258
1547
  private readonly pool: RateLimitPool<TelegramQueuePayload>;
1259
1548
  private readonly poller: TelegramUpdatePoller;
1260
1549
  private readonly dispatchState = new TelegramEventDispatchState();
@@ -1337,11 +1626,18 @@ export class TelegramNotificationDaemon {
1337
1626
  * ~25s getUpdates timeout. Safe to call from a signal handler.
1338
1627
  */
1339
1628
  requestStop(_reason?: "reload" | "stop" | "signal"): void {
1629
+ this.effects.beginShutdown();
1340
1630
  for (const item of new Set(this.selectedAckPending.values())) {
1341
1631
  if (item.state === "queued") this.pool.removeById(item.itemId);
1342
1632
  else item.controller?.abort();
1343
1633
  this.finishSelectedAck(item, { status: "unknown", reason: "shutdown" });
1344
1634
  }
1635
+ this.#stoppingBtw = true;
1636
+ for (const delivery of this.#btwTerminalDeliveries.values()) {
1637
+ delivery.invalidated = true;
1638
+ delivery.controller.abort("daemon_shutdown");
1639
+ }
1640
+ this.#deliveryAbort.abort();
1345
1641
  this.runtime.requestStop();
1346
1642
  this.running = false;
1347
1643
  }
@@ -1368,8 +1664,14 @@ export class TelegramNotificationDaemon {
1368
1664
  const deps = (this.opts.createLifecycleOrchestratorDeps ?? buildOrchestratorDeps)({
1369
1665
  pairedChatId: this.opts.chatId,
1370
1666
  agentNotificationsDir: daemonPaths(agentDir).dir,
1667
+ agentDir,
1371
1668
  sessionsRoot: path.join(agentDir, "sessions"),
1372
- auditRedactionKey: deriveLifecycleAuditRedactionKey(this.opts.botToken),
1669
+ auditRedactionKey: deriveLifecycleAuditRedactionKey(
1670
+ (() => {
1671
+ if (!validBotToken(this.opts.botToken)) throw new Error("invalid Telegram bot token");
1672
+ return this.opts.botToken;
1673
+ })(),
1674
+ ),
1373
1675
  });
1374
1676
  // Register the lifecycle-request handler BEFORE start(): the native
1375
1677
  // control server captures the callback at start time, so wiring must
@@ -1610,15 +1912,22 @@ export class TelegramNotificationDaemon {
1610
1912
  return true;
1611
1913
  }
1612
1914
  if (parsed.kind === "recent") {
1613
- const recent = listRecentSessions({
1614
- sessionsRoot: path.join(this.opts.settings.getAgentDir(), "sessions"),
1915
+ const recent = await listRecentSessions({
1916
+ cwd: process.cwd(),
1917
+ agentDir: this.opts.settings.getAgentDir(),
1615
1918
  limit: 10,
1616
1919
  includeInternal: false,
1920
+ allWorkspaces: true,
1617
1921
  });
1618
- const body = recent.length
1619
- ? recent.map(e => `• ${code(e.sessionId)}${e.path ? ` (${code(e.path)})` : ""}`).join("\n")
1620
- : "No recent sessions.";
1621
- await replyHtml(body);
1922
+ const body =
1923
+ recent.kind === "error"
1924
+ ? `Recent sessions could not be verified: ${recent.message}`
1925
+ : recent.entries.length
1926
+ ? recent.entries.map(e => `• ${code(e.sessionId)}${e.path ? ` (${code(e.path)})` : ""}`).join("\n")
1927
+ : "No recent sessions.";
1928
+ await replyHtml(
1929
+ recent.kind === "complete" && recent.warnings.length ? `${body}\n\n${recent.warnings.join("\n")}` : body,
1930
+ );
1622
1931
  return true;
1623
1932
  }
1624
1933
 
@@ -1657,7 +1966,7 @@ export class TelegramNotificationDaemon {
1657
1966
  this.fsImpl = opts.fs ?? nodeFs;
1658
1967
  this.replyStore = new ReplySentStore({ agentDir: opts.settings.getAgentDir(), fs: opts.fs });
1659
1968
  this.aliasTable = createAliasTable();
1660
- this.botApi =
1969
+ const rawBotApi =
1661
1970
  opts.botApi ??
1662
1971
  new TelegramBotTransport({
1663
1972
  botToken: opts.botToken,
@@ -1665,6 +1974,9 @@ export class TelegramNotificationDaemon {
1665
1974
  fetchImpl: opts.fetchImpl,
1666
1975
  setTimeoutImpl: opts.setTimeoutImpl,
1667
1976
  });
1977
+ this.botApi = {
1978
+ call: (method, body, callOpts) => this.effects.call(rawBotApi, method, body, callOpts),
1979
+ };
1668
1980
  this.runtime = new NotificationOperatorRuntime({
1669
1981
  now: opts.now,
1670
1982
  setTimeoutImpl: opts.setTimeoutImpl,
@@ -1673,7 +1985,7 @@ export class TelegramNotificationDaemon {
1673
1985
  clearIntervalImpl: opts.clearIntervalImpl,
1674
1986
  });
1675
1987
  this.sessionRouter = this.createSessionRouter();
1676
- this.pool = new RateLimitPool<{ send: ThreadedSend; topicId?: string }>({ now: opts.now });
1988
+ this.pool = new RateLimitPool<TelegramQueuePayload>({ now: opts.now });
1677
1989
  this.poller = new TelegramUpdatePoller({
1678
1990
  botApi: this.botApi,
1679
1991
  runtime: this.runtime,
@@ -1689,6 +2001,7 @@ export class TelegramNotificationDaemon {
1689
2001
  matches: msg => msg.type === "hello",
1690
2002
  handle: (session, msg) => {
1691
2003
  const caps = Array.isArray(msg.capabilities) ? msg.capabilities : [];
2004
+ if (caps.includes("ephemeral_turn_v1")) session.ephemeralCapable = true;
1692
2005
  if (caps.includes(CLIENT_PING_PONG_CAPABILITY)) {
1693
2006
  session.capable = true;
1694
2007
  this.startLiveness(session);
@@ -1728,8 +2041,11 @@ export class TelegramNotificationDaemon {
1728
2041
  finishImmediately({ status: "failed", reason: "route_missing" });
1729
2042
  return;
1730
2043
  }
1731
- const topicId = this.topics.get(session.sessionId)?.topicId;
1732
- if (mode === "recovery" && (!topicId || msg.sessionId !== session.sessionId)) {
2044
+ const topicLease = this.topicAuthorityLeaseFromRegistry(session.sessionId);
2045
+ if (
2046
+ this.topics.get(session.sessionId)?.authorityState === "delete_pending" ||
2047
+ (mode === "recovery" && (!topicLease || msg.sessionId !== session.sessionId))
2048
+ ) {
1733
2049
  finishImmediately({ status: "failed", reason: "route_missing" });
1734
2050
  return;
1735
2051
  }
@@ -1758,14 +2074,14 @@ export class TelegramNotificationDaemon {
1758
2074
  followers: [],
1759
2075
  };
1760
2076
  this.selectedAckPending.set(pendingKey, item);
1761
- this.pool.submit({
2077
+ this.submitPool({
1762
2078
  sessionId: session.sessionId,
1763
2079
  lane: "ask",
1764
2080
  itemId: item.itemId,
1765
2081
  deadlineAt,
1766
2082
  payload: {
1767
2083
  send: { method: "sendMessage", lane: "ask", text: "Selected!" },
1768
- topicId,
2084
+ topicLease,
1769
2085
  selectedAck: item,
1770
2086
  },
1771
2087
  });
@@ -1862,6 +2178,7 @@ export class TelegramNotificationDaemon {
1862
2178
  handle: async session => {
1863
2179
  this.busy.delete(session.sessionId);
1864
2180
  this.closedEndpointKeys.set(session.sessionId, session.endpointKey);
2181
+ await this.#terminalizeBtwTurnsForSession(session, true);
1865
2182
  await this.deleteTopic(session.sessionId);
1866
2183
  this.dropSession(session, "session_closed");
1867
2184
  },
@@ -1923,7 +2240,7 @@ export class TelegramNotificationDaemon {
1923
2240
  logger.warn(`notifications: failed to persist Telegram update id ${updateId}: ${String(err)}`);
1924
2241
  }
1925
2242
  }
1926
- private async rememberSeenUpdateIdForUnavailableNotice(updateId: number): Promise<boolean> {
2243
+ private async reserveSeenUpdateId(updateId: number): Promise<boolean> {
1927
2244
  if (!Number.isSafeInteger(updateId) || updateId < 0) return false;
1928
2245
  const candidate = new Set(this.dispatchState.seenUpdateIds);
1929
2246
  candidate.add(updateId);
@@ -1936,7 +2253,7 @@ export class TelegramNotificationDaemon {
1936
2253
  updateIds: [...candidate],
1937
2254
  });
1938
2255
  } catch {
1939
- logger.warn("notifications: unavailable-control notice state publication failed");
2256
+ logger.warn("notifications: Telegram update state publication failed");
1940
2257
  return false;
1941
2258
  }
1942
2259
  this.dispatchState.seenUpdateIds.clear();
@@ -1961,20 +2278,28 @@ export class TelegramNotificationDaemon {
1961
2278
  for (const file of files.filter(item => item.endsWith(".json"))) {
1962
2279
  const sessionId = path.basename(file, ".json");
1963
2280
  endpointSessionIds.add(sessionId);
1964
- if (this.sessions.has(sessionId)) continue;
1965
2281
  try {
1966
2282
  const endpoint = readEndpoint(path.join(dir, file));
1967
- // Skip endpoint files whose owning process is gone or that are
1968
- // explicitly stale (e.g. a hard-closed session): reconnecting
1969
- // would chase a dead, token-bearing record forever. Once the
1970
- // associated topic is past the grace window, reap it through the
1971
- // same best-effort delete path as graceful session shutdown.
2283
+ // Validate endpoint ownership even for an already-connected socket.
2284
+ // A hard-killed owner can leave both its endpoint file and socket map
2285
+ // entry behind; skipping the read in that case permanently preserves
2286
+ // the stale Telegram topic.
1972
2287
  const pidAlive = this.opts.pidAlive ?? defaultPidAlive;
1973
2288
  if (endpoint.stale || (endpoint.pid !== undefined && !pidAlive(endpoint.pid))) {
1974
- await this.deleteOrphanedTopic(sessionId);
2289
+ const connected = this.sessions.get(sessionId);
2290
+ if (connected) this.dropSession(connected, "endpoint_owner_dead");
2291
+ else this.#terminalizeBtwTurnsForTransportSession(sessionId);
2292
+ await this.observeOrphanedTopic(sessionId);
1975
2293
  continue;
1976
2294
  }
2295
+ if (this.topics.clearOrphaned(sessionId)) await this.persistTopics();
1977
2296
  const endpointKey = endpointGenerationKey(endpoint.url, endpoint.token);
2297
+ const connected = this.sessions.get(sessionId);
2298
+ if (connected) {
2299
+ if (connected.endpointKey !== endpointKey)
2300
+ this.connectSession(sessionId, endpoint.url, endpoint.token);
2301
+ continue;
2302
+ }
1978
2303
  if (this.closedEndpointKeys.get(sessionId) === endpointKey) continue;
1979
2304
  this.closedEndpointKeys.delete(sessionId);
1980
2305
  this.connectSession(sessionId, endpoint.url, endpoint.token);
@@ -1983,8 +2308,10 @@ export class TelegramNotificationDaemon {
1983
2308
  }
1984
2309
  if (allRootsReadable) {
1985
2310
  for (const sessionId of this.topics.sessionIds()) {
1986
- if (!this.sessions.has(sessionId) && !endpointSessionIds.has(sessionId))
1987
- await this.deleteOrphanedTopic(sessionId);
2311
+ if (!this.sessions.has(sessionId) && !endpointSessionIds.has(sessionId)) {
2312
+ this.#terminalizeBtwTurnsForTransportSession(sessionId);
2313
+ await this.observeOrphanedTopic(sessionId);
2314
+ }
1988
2315
  }
1989
2316
  }
1990
2317
  }
@@ -1993,9 +2320,17 @@ export class TelegramNotificationDaemon {
1993
2320
  const WS = this.opts.WebSocketImpl ?? WebSocket;
1994
2321
  const ws = new WS(`${url}/?token=${encodeURIComponent(token)}`);
1995
2322
  const endpointKey = endpointGenerationKey(url, token);
2323
+ const endpointDigest = endpointAuthorityDigest(url, token);
1996
2324
  this.closedEndpointKeys.delete(sessionId);
1997
2325
  const existing = this.sessions.get(sessionId);
1998
- if (existing) this.#clearModelChoiceAliasesForSocket(existing);
2326
+ if (existing) {
2327
+ this.dropSession(
2328
+ existing,
2329
+ existing.endpointDigest === endpointDigest ? "same_authority_replaced" : "authority_replaced",
2330
+ );
2331
+ } else {
2332
+ this.#terminalizeBtwTurnsForEndpointReplacement(sessionId, endpointDigest);
2333
+ }
1999
2334
  this.#clearModelChoiceAliases(sessionId);
2000
2335
 
2001
2336
  const session: SessionSocket = {
@@ -2004,9 +2339,12 @@ export class TelegramNotificationDaemon {
2004
2339
 
2005
2340
  token,
2006
2341
  endpointKey,
2342
+ endpointDigest,
2343
+ hostGeneration: 0,
2007
2344
  ws,
2008
2345
  pending: new Map(),
2009
2346
  capable: false,
2347
+ ephemeralCapable: false,
2010
2348
  lastPongAt: 0,
2011
2349
  awaitingNonce: undefined,
2012
2350
  pingTimer: undefined,
@@ -2028,7 +2366,13 @@ export class TelegramNotificationDaemon {
2028
2366
  JSON.stringify({
2029
2367
  type: "hello",
2030
2368
  protocolVersion: NOTIFICATION_PROTOCOL_VERSION,
2031
- capabilities: [CLIENT_PING_PONG_CAPABILITY, ASK_CONTROLS_CAPABILITY, ASK_SELECTED_ACK_CAPABILITY],
2369
+ capabilities: [
2370
+ CLIENT_PING_PONG_CAPABILITY,
2371
+ ASK_CONTROLS_CAPABILITY,
2372
+ ASK_SELECTED_ACK_CAPABILITY,
2373
+ TOOL_ACTIVITY_CAPABILITY,
2374
+ "ephemeral_turn_v1",
2375
+ ],
2032
2376
  }),
2033
2377
  );
2034
2378
  } catch {}
@@ -2106,6 +2450,11 @@ export class TelegramNotificationDaemon {
2106
2450
  clearIntervalImpl(session.pingTimer);
2107
2451
  session.pingTimer = undefined;
2108
2452
  }
2453
+ if (reason === "socket_closed" || reason === "same_authority_replaced") {
2454
+ this.#invalidateBtwDeliveriesForSession(session);
2455
+ } else {
2456
+ void this.#terminalizeBtwTurnsForSession(session).catch(() => undefined);
2457
+ }
2109
2458
  const isCurrentSession = this.sessions.get(session.sessionId) === session;
2110
2459
  if (isCurrentSession || reason === "session_closed") {
2111
2460
  this.deleteMessageRoutes(session.sessionId);
@@ -2128,6 +2477,280 @@ export class TelegramNotificationDaemon {
2128
2477
  }
2129
2478
  }
2130
2479
 
2480
+ #purgeBtwTombstones(): void {
2481
+ const now = this.opts.now?.() ?? Date.now();
2482
+ for (const [requestId, tombstone] of this.#btwTerminalTombstones)
2483
+ if (tombstone.expiresAt <= now) this.#btwTerminalTombstones.delete(requestId);
2484
+ }
2485
+ #finishQueuedBtwDeliveries(pending: PendingBtwTurn, outcome: BtwQueuedDeliveryOutcome): void {
2486
+ for (const item of this.pool.removeWhere(item => item.payload.btwDelivery?.pending === pending)) {
2487
+ item.payload.btwDelivery?.finish(outcome);
2488
+ }
2489
+ }
2490
+ #takeBtwTurn(requestId: string, pending: PendingBtwTurn): boolean {
2491
+ if (this.#pendingBtwTurns.get(requestId) !== pending) return false;
2492
+ this.#pendingBtwTurns.delete(requestId);
2493
+ this.#btwTerminalTombstones.set(requestId, {
2494
+ sessionId: pending.logicalSessionId,
2495
+ updateId: pending.updateId,
2496
+ messageId: pending.messageId,
2497
+ threadId: pending.threadId,
2498
+ expiresAt: (this.opts.now?.() ?? Date.now()) + BTW_PENDING_TTL_MS,
2499
+ });
2500
+ while (this.#pendingBtwTurns.size + this.#btwTerminalTombstones.size > BTW_MAX_PENDING)
2501
+ this.#btwTerminalTombstones.delete(this.#btwTerminalTombstones.keys().next().value!);
2502
+ return true;
2503
+ }
2504
+ #invalidateBtwDeliveriesForSession(session: SessionSocket): void {
2505
+ for (const delivery of this.#btwTerminalDeliveries.values()) {
2506
+ const pending = delivery.pending;
2507
+ if (
2508
+ pending.transportSessionId !== session.sessionId ||
2509
+ pending.logicalSessionId !== this.#logicalSessionId(session) ||
2510
+ pending.endpointDigest !== session.endpointDigest ||
2511
+ pending.generation !== session.hostGeneration
2512
+ )
2513
+ continue;
2514
+ delivery.invalidated = true;
2515
+ this.#finishQueuedBtwDeliveries(pending, "stale");
2516
+ }
2517
+ }
2518
+ #terminalizeBtwTurnsForEndpointReplacement(sessionId: string, endpointDigest: string): void {
2519
+ for (const [requestId, pending] of this.#pendingBtwTurns) {
2520
+ if (pending.transportSessionId !== sessionId || pending.endpointDigest === endpointDigest) continue;
2521
+ void this.#terminalizeBtwTurn(requestId, pending).catch(() => undefined);
2522
+ }
2523
+ }
2524
+ #terminalizeBtwTurnsForTransportSession(sessionId: string): void {
2525
+ for (const [requestId, pending] of this.#pendingBtwTurns) {
2526
+ if (pending.transportSessionId !== sessionId) continue;
2527
+ void this.#terminalizeBtwTurn(requestId, pending).catch(() => undefined);
2528
+ }
2529
+ }
2530
+ #terminalizeBtwTurnsForGenerationChange(session: SessionSocket): void {
2531
+ for (const [requestId, pending] of this.#pendingBtwTurns) {
2532
+ if (
2533
+ pending.transportSessionId !== session.sessionId ||
2534
+ pending.endpointDigest !== session.endpointDigest ||
2535
+ pending.logicalSessionId !== this.#logicalSessionId(session) ||
2536
+ pending.generation === session.hostGeneration
2537
+ )
2538
+ continue;
2539
+ void this.#terminalizeBtwTurn(requestId, pending).catch(() => undefined);
2540
+ }
2541
+ }
2542
+ #resumeBtwTurnsForSession(session: SessionSocket): void {
2543
+ if (!session.ephemeralCapable || session.hostGeneration < 1 || session.ws.readyState !== WebSocket.OPEN) return;
2544
+ const logicalSessionId = this.#logicalSessionId(session);
2545
+ const now = this.opts.now?.() ?? Date.now();
2546
+ for (const [requestId, pending] of this.#pendingBtwTurns) {
2547
+ if (pending.expiresAt <= now) {
2548
+ this.#pendingBtwTurns.delete(requestId);
2549
+ continue;
2550
+ }
2551
+ if (
2552
+ pending.transportSessionId !== session.sessionId ||
2553
+ pending.logicalSessionId !== logicalSessionId ||
2554
+ pending.endpointDigest !== session.endpointDigest ||
2555
+ pending.generation !== session.hostGeneration
2556
+ )
2557
+ continue;
2558
+ this.#sendPendingBtwTurn(session, requestId, pending);
2559
+ }
2560
+ }
2561
+ #sendPendingBtwTurn(session: SessionSocket, requestId: string, pending: PendingBtwTurn): boolean {
2562
+ try {
2563
+ session.ws.send(
2564
+ JSON.stringify({
2565
+ type: "ephemeral_turn",
2566
+ sessionId: pending.logicalSessionId,
2567
+ question: pending.question,
2568
+ token: session.token,
2569
+ requestId,
2570
+ updateId: pending.updateId,
2571
+ threadId: pending.threadId,
2572
+ messageId: pending.messageId,
2573
+ }),
2574
+ );
2575
+ return true;
2576
+ } catch {
2577
+ return false;
2578
+ }
2579
+ }
2580
+ async #terminalizeBtwTurnsForSession(session: SessionSocket, waitForInFlight = false): Promise<void> {
2581
+ const terminalizations: Promise<void>[] = [];
2582
+ for (const [requestId, pending] of this.#pendingBtwTurns) {
2583
+ if (
2584
+ pending.transportSessionId !== session.sessionId ||
2585
+ pending.logicalSessionId !== this.#logicalSessionId(session) ||
2586
+ pending.endpointDigest !== session.endpointDigest ||
2587
+ pending.generation !== session.hostGeneration
2588
+ )
2589
+ continue;
2590
+ const terminalization = this.#terminalizeBtwTurn(requestId, pending, false, waitForInFlight);
2591
+ if (waitForInFlight) terminalizations.push(terminalization);
2592
+ else void terminalization.catch(() => undefined);
2593
+ }
2594
+ if (waitForInFlight) await Promise.all(terminalizations);
2595
+ }
2596
+ async #terminalizeBtwTurn(
2597
+ requestId: string,
2598
+ pending: PendingBtwTurn,
2599
+ allowWhileStopping = false,
2600
+ waitForInFlight = false,
2601
+ signal?: AbortSignal,
2602
+ ): Promise<void> {
2603
+ const delivery = this.#btwTerminalDeliveries.get(requestId);
2604
+ if (delivery?.pending === pending) {
2605
+ delivery.invalidated = true;
2606
+ delivery.terminalizeOnInvalidation = true;
2607
+ this.#finishQueuedBtwDeliveries(pending, "stale");
2608
+ if (allowWhileStopping || waitForInFlight) await delivery.finished;
2609
+ return;
2610
+ }
2611
+ if (this.#stoppingBtw && !allowWhileStopping) return;
2612
+ if (!this.#takeBtwTurn(requestId, pending)) return;
2613
+ try {
2614
+ await this.#sendBtwMessage({
2615
+ threadId: pending.threadId,
2616
+ messageId: pending.messageId,
2617
+ text: "This /btw question stopped because the GJC session closed or changed. Reopen it and try again.",
2618
+ allowWhileStopping,
2619
+ signal,
2620
+ });
2621
+ } catch {
2622
+ logger.warn("notifications: /btw session-unavailable delivery failed");
2623
+ }
2624
+ }
2625
+ async #sendBtwMessage(input: {
2626
+ threadId: string;
2627
+ messageId: number;
2628
+ text: string;
2629
+ parseMode?: typeof TELEGRAM_PARSE_MODE;
2630
+ allowWhileStopping?: boolean;
2631
+ signal?: AbortSignal;
2632
+ }): Promise<unknown> {
2633
+ if ((!input.allowWhileStopping && this.#stoppingBtw) || this.#btwDeliveryAbort.signal.aborted) return undefined;
2634
+ const signals = [this.#btwDeliveryAbort.signal, AbortSignal.timeout(30_000)];
2635
+ if (input.signal) signals.unshift(input.signal);
2636
+ return this.botApi.call(
2637
+ "sendMessage",
2638
+ {
2639
+ chat_id: this.opts.chatId,
2640
+ message_thread_id: Number(input.threadId),
2641
+ reply_parameters: { message_id: input.messageId },
2642
+ text: input.text,
2643
+ ...(input.parseMode ? { parse_mode: input.parseMode } : {}),
2644
+ },
2645
+ {
2646
+ noRetry: true,
2647
+ signal: AbortSignal.any(signals),
2648
+ },
2649
+ );
2650
+ }
2651
+ async #queueBtwFallbackChunk(input: {
2652
+ requestId: string;
2653
+ chunkIndex: number;
2654
+ pending: PendingBtwTurn;
2655
+ body: Record<string, unknown>;
2656
+ signal: AbortSignal;
2657
+ isAuthoritative: () => boolean;
2658
+ }): Promise<BtwQueuedDeliveryOutcome> {
2659
+ if (input.signal.aborted) return "uncertain";
2660
+ const result = Promise.withResolvers<BtwQueuedDeliveryOutcome>();
2661
+ let settled = false;
2662
+ const finish = (outcome: BtwQueuedDeliveryOutcome): void => {
2663
+ if (settled) return;
2664
+ settled = true;
2665
+ result.resolve(outcome);
2666
+ };
2667
+ const itemId = `btw-delivery:${input.requestId}:${input.chunkIndex}`;
2668
+ const delivery: BtwQueuedDelivery = {
2669
+ pending: input.pending,
2670
+ body: input.body,
2671
+ signal: input.signal,
2672
+ isAuthoritative: input.isAuthoritative,
2673
+ finish,
2674
+ };
2675
+ const abort = (): void => {
2676
+ const removed = this.pool.removeById(itemId);
2677
+ if (removed?.payload.btwDelivery === delivery) finish("uncertain");
2678
+ };
2679
+ input.signal.addEventListener("abort", abort, { once: true });
2680
+ try {
2681
+ const handle = this.pool.submit({
2682
+ sessionId: input.pending.transportSessionId,
2683
+ lane: "finalized",
2684
+ itemId,
2685
+ payload: {
2686
+ send: { method: "sendMessage", lane: "finalized", text: String(input.body.text ?? "") },
2687
+ btwDelivery: delivery,
2688
+ },
2689
+ });
2690
+ if (input.signal.aborted) abort();
2691
+ else await this.flushPool();
2692
+ await handle.settled;
2693
+ return await result.promise;
2694
+ } catch {
2695
+ const removed = this.pool.removeById(itemId);
2696
+ if (removed?.payload.btwDelivery === delivery) finish("uncertain");
2697
+ return "uncertain";
2698
+ } finally {
2699
+ input.signal.removeEventListener("abort", abort);
2700
+ }
2701
+ }
2702
+
2703
+ async #drainBtwTurns(): Promise<void> {
2704
+ this.#stoppingBtw = true;
2705
+ const shutdownController = new AbortController();
2706
+ const shutdownDeadline = Promise.withResolvers<void>();
2707
+ const shutdownTimer = setTimeout(() => {
2708
+ shutdownController.abort("daemon_shutdown_timeout");
2709
+ shutdownDeadline.resolve();
2710
+ }, BTW_SHUTDOWN_JOIN_MS);
2711
+ for (const delivery of this.#btwTerminalDeliveries.values()) {
2712
+ delivery.invalidated = true;
2713
+ delivery.controller.abort("daemon_shutdown");
2714
+ }
2715
+ const deliveryJoins = [...this.#btwTerminalDeliveries.values()].map(delivery => delivery.finished);
2716
+
2717
+ const terminalizations: Promise<void>[] = [];
2718
+ for (const [requestId, pending] of this.#pendingBtwTurns) {
2719
+ if (shutdownController.signal.aborted) break;
2720
+ if (this.#btwTerminalDeliveries.has(requestId)) continue;
2721
+ const session = this.sessions.get(pending.transportSessionId);
2722
+ if (
2723
+ session &&
2724
+ session.ws.readyState === WebSocket.OPEN &&
2725
+ session.endpointDigest === pending.endpointDigest &&
2726
+ session.hostGeneration === pending.generation &&
2727
+ this.#logicalSessionId(session) === pending.logicalSessionId
2728
+ ) {
2729
+ try {
2730
+ session.ws.send(
2731
+ JSON.stringify({
2732
+ type: "ephemeral_turn_cancel",
2733
+ sessionId: pending.logicalSessionId,
2734
+ token: session.token,
2735
+ requestId,
2736
+ updateId: pending.updateId,
2737
+ messageId: pending.messageId,
2738
+ threadId: pending.threadId,
2739
+ reason: "daemon_shutdown",
2740
+ }),
2741
+ );
2742
+ } catch {}
2743
+ }
2744
+ terminalizations.push(this.#terminalizeBtwTurn(requestId, pending, true, false, shutdownController.signal));
2745
+ }
2746
+ await Promise.race([Promise.allSettled([...deliveryJoins, ...terminalizations]), shutdownDeadline.promise]);
2747
+ clearTimeout(shutdownTimer);
2748
+ shutdownController.abort("daemon_shutdown");
2749
+ this.#btwDeliveryAbort.abort();
2750
+ this.#btwTerminalTombstones.clear();
2751
+ for (const item of this.pool.removeWhere(() => true)) item.payload.btwDelivery?.finish("uncertain");
2752
+ }
2753
+
2131
2754
  private deleteMessageRoutes(sessionId: string, actionId?: string): void {
2132
2755
  for (const [messageId, route] of this.messageRoutes.entries()) {
2133
2756
  if (route.sessionId === sessionId && (actionId === undefined || route.actionId === actionId)) {
@@ -2169,6 +2792,14 @@ export class TelegramNotificationDaemon {
2169
2792
  return alias;
2170
2793
  }
2171
2794
 
2795
+ /**
2796
+ * Coalesce-key categories whose entries are concurrent and independently keyed
2797
+ * (one live message per key), so {@link recordLiveMessage} must NOT evict
2798
+ * same-category siblings. `tool:<toolCallId>` bubbles from parallel tools each
2799
+ * own their own message and finalize independently.
2800
+ */
2801
+ static readonly #CONCURRENT_LIVE_CATEGORIES = new Set(["tool"]);
2802
+
2172
2803
  private static readonly THREADED_FRAMES = new Set([
2173
2804
  "identity_header",
2174
2805
  "context_update",
@@ -2177,9 +2808,11 @@ export class TelegramNotificationDaemon {
2177
2808
  "file_attachment",
2178
2809
  "config_update",
2179
2810
  "control_command_result",
2811
+ "tool_activity",
2812
+ "reasoning_summary",
2180
2813
  ]);
2181
2814
 
2182
- /** Rekey model controls before a valid threaded frame can render or a silent config update can route output. */
2815
+ /** Rekey model controls before a valid server-threaded frame can render or route output. */
2183
2816
  #updateLogicalSessionForThreadedFrame(session: SessionSocket, msg: Record<string, unknown>): void {
2184
2817
  if (
2185
2818
  typeof msg.type !== "string" ||
@@ -2190,6 +2823,7 @@ export class TelegramNotificationDaemon {
2190
2823
  (msg.type !== "config_update" && !renderThreadedFrame(msg))
2191
2824
  )
2192
2825
  return;
2826
+ void this.#terminalizeBtwTurnsForSession(session).catch(() => undefined);
2193
2827
  this.#clearModelChoiceAliasesForSocket(session);
2194
2828
  this.#clearModelChoiceAliases(msg.sessionId);
2195
2829
  session.logicalSessionId = msg.sessionId;
@@ -2257,11 +2891,13 @@ export class TelegramNotificationDaemon {
2257
2891
  private topicOwnerForIdentity(msg: { repo?: unknown; branch?: unknown }): string | undefined {
2258
2892
  const identityKey = this.topicIdentityKey(msg);
2259
2893
  const remembered = identityKey ? this.topicOwnerByIdentity.get(identityKey) : undefined;
2260
- if (remembered && this.topics.get(remembered)) return remembered;
2894
+ const rememberedTopic = remembered ? this.topics.get(remembered) : undefined;
2895
+ if (remembered && rememberedTopic && rememberedTopic.authorityState !== "delete_pending") return remembered;
2261
2896
  if (!identityKey) return undefined;
2262
2897
  const base = this.topicIdentityBase(msg);
2263
2898
  for (const sessionId of this.topics.sessionIds()) {
2264
2899
  const topic = this.topics.get(sessionId);
2900
+ if (topic?.authorityState === "delete_pending") continue;
2265
2901
  const nameMatchesLegacyIdentity =
2266
2902
  base !== undefined && (topic?.name === base || topic?.name?.startsWith(`${base} - `));
2267
2903
  if (topic?.identityKey === identityKey || nameMatchesLegacyIdentity) {
@@ -2279,30 +2915,55 @@ export class TelegramNotificationDaemon {
2279
2915
  return !ownerId || ownerId === session.sessionId;
2280
2916
  }
2281
2917
 
2282
- private async submitThreadedFrame(sessionId: string, send: ThreadedSend, topicId: string): Promise<void> {
2283
- this.pool.submit({
2918
+ private async submitThreadedFrame(
2919
+ sessionId: string,
2920
+ send: ThreadedSend,
2921
+ topicLease: TopicAuthorityLease,
2922
+ ): Promise<void> {
2923
+ this.submitPool({
2284
2924
  sessionId,
2285
2925
  lane: send.lane,
2286
2926
  coalesceKey: send.coalesceKey,
2287
- payload: { send, topicId },
2927
+ payload: { send, topicLease },
2288
2928
  });
2289
2929
  await this.flushPool();
2290
2930
  }
2291
2931
 
2292
2932
  private async existingTopicForPrivateChat(sessionId: string): Promise<string | undefined> {
2933
+ return (await this.topicAuthorityLease(sessionId))?.topicId;
2934
+ }
2935
+
2936
+ private async topicAuthorityLease(sessionId: string): Promise<TopicAuthorityLease | undefined> {
2293
2937
  if (!(await this.pairedChatIsPrivate())) return undefined;
2294
- return this.topics.get(sessionId)?.topicId;
2938
+ return this.topicAuthorityLeaseFromRegistry(sessionId);
2939
+ }
2940
+
2941
+ private topicAuthorityLeaseFromRegistry(sessionId: string): TopicAuthorityLease | undefined {
2942
+ const topic = this.topics.get(sessionId);
2943
+ if (!topic || topic.authorityState === "delete_pending") return undefined;
2944
+ return { sessionId, topicId: topic.topicId, authorityEpoch: topic.authorityEpoch ?? 0 };
2945
+ }
2946
+
2947
+ private topicLeaseIsCurrent(lease: TopicAuthorityLease): boolean {
2948
+ const topic = this.topics.get(lease.sessionId);
2949
+ return (
2950
+ topic?.authorityState !== "delete_pending" &&
2951
+ topic?.topicId === lease.topicId &&
2952
+ (topic.authorityEpoch ?? 0) === lease.authorityEpoch
2953
+ );
2295
2954
  }
2296
2955
 
2297
2956
  /** Best-effort re-assertion for a durable user-owned topic name. */
2298
- private async reconcileUserTopicName(sessionId: string, topicId: string): Promise<void> {
2957
+ private async reconcileUserTopicName(topicLease: TopicAuthorityLease): Promise<void> {
2958
+ const { sessionId } = topicLease;
2299
2959
  if ((this.daemonRenameAttempts.get(sessionId) ?? 0) > 0) return;
2300
2960
  let userName = this.topics.userNameToReconcile(sessionId);
2301
2961
  while (userName) {
2302
2962
  try {
2963
+ if (!this.topicLeaseIsCurrent(topicLease)) return;
2303
2964
  const response = await this.botApi.call("editForumTopic", {
2304
2965
  chat_id: this.opts.chatId,
2305
- message_thread_id: Number(topicId),
2966
+ message_thread_id: Number(topicLease.topicId),
2306
2967
  name: userName,
2307
2968
  });
2308
2969
  if (!topicRenameApplied(response)) return;
@@ -2332,11 +2993,11 @@ export class TelegramNotificationDaemon {
2332
2993
  this.pendingThreadedFrames.set(sessionId, frames);
2333
2994
  }
2334
2995
 
2335
- private async flushPendingThreadedFrames(sessionId: string, topicId: string): Promise<void> {
2996
+ private async flushPendingThreadedFrames(sessionId: string, topicLease: TopicAuthorityLease): Promise<void> {
2336
2997
  const frames = this.pendingThreadedFrames.get(sessionId);
2337
2998
  if (!frames || frames.length === 0) return;
2338
2999
  this.pendingThreadedFrames.delete(sessionId);
2339
- for (const frame of frames) await this.submitThreadedFrame(sessionId, frame.send, topicId);
3000
+ for (const frame of frames) await this.submitThreadedFrame(sessionId, frame.send, topicLease);
2340
3001
  }
2341
3002
 
2342
3003
  /**
@@ -2348,17 +3009,20 @@ export class TelegramNotificationDaemon {
2348
3009
  private async ensureTopic(sessionId: string, name: string): Promise<string | undefined> {
2349
3010
  if (!(await this.pairedChatIsPrivate())) return undefined;
2350
3011
  const existing = this.topics.get(sessionId);
3012
+ if (existing?.authorityState === "delete_pending") return undefined;
2351
3013
  if (existing) return existing.topicId;
2352
3014
  try {
2353
3015
  const rec = await this.topics.getOrCreateTopic(
2354
3016
  sessionId,
2355
3017
  async () => {
2356
- const res = (await this.botApi.call("createForumTopic", {
3018
+ const res = await this.botApi.call("createForumTopic", {
2357
3019
  chat_id: this.opts.chatId,
2358
3020
  name,
2359
- })) as { result?: { message_thread_id?: number } };
2360
- const tid = res.result?.message_thread_id;
2361
- if (tid === undefined || tid === null) throw new Error("createForumTopic: no message_thread_id");
3021
+ });
3022
+ if (isThreadedModeCapabilityRefusal(res)) throw new ThreadedModeCapabilityRefusal();
3023
+ const tid = (res as { result?: { message_thread_id?: unknown } }).result?.message_thread_id;
3024
+ if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0)
3025
+ throw new Error("createForumTopic: invalid message_thread_id");
2362
3026
  return String(tid);
2363
3027
  },
2364
3028
  this.opts.now,
@@ -2370,50 +3034,62 @@ export class TelegramNotificationDaemon {
2370
3034
  );
2371
3035
  await this.persistTopics();
2372
3036
  return rec.topicId;
2373
- } catch {
2374
- return undefined;
3037
+ } catch (err) {
3038
+ if (err instanceof ThreadedModeCapabilityRefusal) return undefined;
3039
+ logger.warn(
3040
+ `notifications: Telegram topic creation failed: ${sanitizeDiagnostic(String(err), this.opts.botToken)}`,
3041
+ );
3042
+ throw err;
2375
3043
  }
2376
3044
  }
2377
3045
 
2378
3046
  private topicPastOrphanGrace(sessionId: string): boolean {
2379
- const record = this.topics.get(sessionId);
2380
- return record !== undefined && this.runtime.now() - record.createdAt >= ORPHAN_TOPIC_GRACE_MS;
3047
+ const orphanedAt = this.topics.get(sessionId)?.orphanedAt;
3048
+ return orphanedAt !== undefined && this.runtime.now() - orphanedAt >= ORPHAN_TOPIC_GRACE_MS;
2381
3049
  }
2382
3050
 
2383
- private async deleteOrphanedTopic(sessionId: string): Promise<void> {
3051
+ private async observeOrphanedTopic(sessionId: string): Promise<void> {
3052
+ if (this.topics.markOrphaned(sessionId, this.runtime.now())) await this.persistTopics();
2384
3053
  if (!this.topicPastOrphanGrace(sessionId)) return;
2385
3054
  await this.deleteTopic(sessionId);
2386
3055
  }
2387
3056
 
2388
3057
  /** Best-effort delete of a session topic once its local notification endpoint shuts down. */
2389
3058
  private async deleteTopic(sessionId: string): Promise<void> {
2390
- const record = this.topics.get(sessionId);
2391
- if (!record) return;
3059
+ let record = this.topics.beginDelete(sessionId);
3060
+ // Persist even an absent-record fence: it revokes a create that was admitted
3061
+ // before close but has not yet committed its topic record.
3062
+ await this.persistTopics();
3063
+ if (!record) {
3064
+ await this.topics.awaitInflight(sessionId);
3065
+ record = this.topics.get(sessionId);
3066
+ // A remotely accepted revoked create installs a delete_pending record while
3067
+ // its caller rejects; durably retain it before compensating remotely.
3068
+ await this.persistTopics();
3069
+ if (!record) return;
3070
+ }
3071
+ const removed = this.pool.removeWhere(item => item.sessionId === sessionId);
3072
+ for (const item of removed) {
3073
+ if (item.payload.selectedAck)
3074
+ this.finishSelectedAck(item.payload.selectedAck, { status: "failed", reason: "cancelled" });
3075
+ item.payload.btwDelivery?.finish("stale");
3076
+ }
2392
3077
  try {
2393
- // Drop queued sends for this session before deleting the topic; otherwise
2394
- // rate-limited frames can flush later into a deleted topic or across resume.
2395
- const removed = this.pool.removeWhere(item => item.sessionId === sessionId);
2396
- for (const item of removed) {
2397
- if (item.payload.selectedAck)
2398
- this.finishSelectedAck(item.payload.selectedAck, { status: "failed", reason: "cancelled" });
2399
- }
2400
3078
  await this.flushPool();
2401
3079
  const res = (await this.botApi.call("deleteForumTopic", {
2402
3080
  chat_id: this.opts.chatId,
2403
3081
  message_thread_id: Number(record.topicId),
2404
3082
  })) as { ok?: boolean };
2405
- if (res?.ok === false) return;
2406
- this.topics.delete(sessionId);
2407
- for (const k of [...this.liveMessages.keys()]) {
2408
- if (k.startsWith(`${sessionId}:`)) this.liveMessages.delete(k);
2409
- }
3083
+ if (!topicDeleteSettled(res)) return;
3084
+ this.topics.settleDelete(sessionId, record.topicId);
3085
+ for (const k of [...this.liveMessages.keys()]) if (k.startsWith(`${sessionId}:`)) this.liveMessages.delete(k);
2410
3086
  this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
2411
3087
  if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
2412
3088
  });
2413
3089
  this.pendingThreadedFrames.delete(sessionId);
2414
3090
  await this.persistTopics();
2415
3091
  } catch {
2416
- // Best-effort: missing Telegram topic permissions must not stop teardown.
3092
+ // Retain the persisted delete fence on transport ambiguity.
2417
3093
  }
2418
3094
  }
2419
3095
 
@@ -2554,12 +3230,19 @@ export class TelegramNotificationDaemon {
2554
3230
  }
2555
3231
 
2556
3232
  /** Drain the shared rate-limit pool and deliver each granted send to its topic. */
3233
+ private submitPool(item: Parameters<RateLimitPool<TelegramQueuePayload>["submit"]>[0]): boolean {
3234
+ if (this.effects.stopping) return false;
3235
+ this.pool.submit(item);
3236
+ return true;
3237
+ }
3238
+
2557
3239
  private async flushPoolInner(): Promise<void> {
2558
3240
  const { granted: batch, expired } = this.pool.drainWithExpired();
2559
3241
  for (const expiredItem of expired) {
2560
3242
  if (expiredItem.payload.selectedAck) {
2561
3243
  this.finishSelectedAck(expiredItem.payload.selectedAck, { status: "failed", reason: "expired" });
2562
3244
  }
3245
+ expiredItem.payload.btwDelivery?.finish("not_delivered");
2563
3246
  }
2564
3247
  // Within a batch a finalized frame supersedes any still-queued live frame for
2565
3248
  // the same streamed message (finalized outranks live), so drop the stale live
@@ -2583,20 +3266,53 @@ export class TelegramNotificationDaemon {
2583
3266
  );
2584
3267
  }
2585
3268
  for (const item of batch) {
3269
+ const btwDelivery = item.payload.btwDelivery;
3270
+ if (btwDelivery) {
3271
+ if (!btwDelivery.isAuthoritative()) {
3272
+ btwDelivery.finish("stale");
3273
+ this.pool.settle(item.itemId!, "removed");
3274
+ continue;
3275
+ }
3276
+ if (btwDelivery.signal.aborted) {
3277
+ btwDelivery.finish("uncertain");
3278
+ this.pool.settle(item.itemId!, "ambiguous");
3279
+ continue;
3280
+ }
3281
+ try {
3282
+ const response = await this.botApi.call("sendMessage", btwDelivery.body, {
3283
+ noRetry: true,
3284
+ signal: btwDelivery.signal,
3285
+ });
3286
+ const accepted = response && typeof response === "object" && (response as { ok?: unknown }).ok === true;
3287
+ const rejected = response && typeof response === "object" && (response as { ok?: unknown }).ok === false;
3288
+ btwDelivery.finish(accepted ? "accepted" : rejected ? "not_delivered" : "uncertain");
3289
+ this.pool.settle(item.itemId!, accepted ? "accepted" : rejected ? "rejected" : "ambiguous");
3290
+ } catch {
3291
+ btwDelivery.finish("uncertain");
3292
+ this.pool.settle(item.itemId!, "ambiguous");
3293
+ }
3294
+ continue;
3295
+ }
2586
3296
  const selectedAck = item.payload.selectedAck;
2587
3297
  if (selectedAck) {
2588
- const { topicId } = item.payload;
3298
+ const { topicLease } = item.payload;
2589
3299
  selectedAck.state = "dispatching";
2590
3300
  const controller = new AbortController();
2591
3301
  selectedAck.controller = controller;
2592
- const routeAvailable = !topicId || (await this.pairedChatIsPrivate());
2593
- if (this.selectedAckPending.get(selectedAck.pendingKey) !== selectedAck) continue;
3302
+ const routeAvailable =
3303
+ (await this.pairedChatIsPrivate()) && (!topicLease || this.topicLeaseIsCurrent(topicLease));
3304
+ if (this.selectedAckPending.get(selectedAck.pendingKey) !== selectedAck) {
3305
+ this.pool.settle(item.itemId!, "removed");
3306
+ continue;
3307
+ }
2594
3308
  if (!routeAvailable) {
2595
3309
  this.finishSelectedAck(selectedAck, { status: "failed", reason: "route_missing" });
3310
+ this.pool.settle(item.itemId!, "rejected");
2596
3311
  continue;
2597
3312
  }
2598
3313
  if (item.deadlineAt !== undefined && item.deadlineAt <= this.runtime.now()) {
2599
3314
  this.finishSelectedAck(selectedAck, { status: "failed", reason: "expired" });
3315
+ this.pool.settle(item.itemId!, "expired");
2600
3316
  continue;
2601
3317
  }
2602
3318
  selectedAck.state = "sending";
@@ -2610,31 +3326,49 @@ export class TelegramNotificationDaemon {
2610
3326
  "sendMessage",
2611
3327
  {
2612
3328
  chat_id: this.opts.chatId,
2613
- ...(topicId ? { message_thread_id: Number(topicId) } : {}),
3329
+ ...(topicLease ? { message_thread_id: Number(topicLease.topicId) } : {}),
2614
3330
  text: "Selected!",
2615
3331
  },
2616
3332
  { signal: controller.signal, noRetry: true },
2617
3333
  )) as { ok?: unknown; result?: { message_id?: unknown } };
3334
+ const messageId = response.result?.message_id;
3335
+ const delivered = response.ok === true && typeof messageId === "number";
2618
3336
  this.finishSelectedAck(
2619
3337
  selectedAck,
2620
- response.ok === true && typeof response.result?.message_id === "number"
2621
- ? { status: "delivered", messageId: response.result.message_id }
2622
- : { status: "failed", reason: "telegram_rejected" },
3338
+ delivered ? { status: "delivered", messageId } : { status: "failed", reason: "telegram_rejected" },
2623
3339
  );
3340
+ this.pool.settle(item.itemId!, delivered ? "accepted" : "rejected");
2624
3341
  } catch {
2625
3342
  this.finishSelectedAck(selectedAck, { status: "unknown", reason: "transport_ambiguous" });
3343
+ this.pool.settle(item.itemId!, "ambiguous");
2626
3344
  } finally {
2627
3345
  (this.opts.clearTimeoutImpl ?? clearTimeout)(timer);
2628
3346
  }
2629
3347
  continue;
2630
3348
  }
2631
- const { send, topicId } = item.payload;
2632
- if (topicId && !(await this.pairedChatIsPrivate())) continue;
3349
+ const { send, topicLease } = item.payload;
3350
+ if (topicLease && !this.topicLeaseIsCurrent(topicLease)) {
3351
+ this.pool.settle(item.itemId!, "rejected");
3352
+ continue;
3353
+ }
3354
+ const topicId = topicLease?.topicId;
3355
+ if (topicId && !(await this.pairedChatIsPrivate())) {
3356
+ this.pool.settle(item.itemId!, "rejected");
3357
+ continue;
3358
+ }
3359
+ if (topicLease && !this.topicLeaseIsCurrent(topicLease)) {
3360
+ this.pool.settle(item.itemId!, "rejected");
3361
+ continue;
3362
+ }
2633
3363
  // Threaded topic when available; otherwise deliver flat to the paired chat.
2634
3364
  const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
2635
3365
  const ckey = send.editable ? item.coalesceKey : undefined;
2636
3366
  const editKey = ckey !== undefined ? `${item.sessionId}:${ckey}` : undefined;
2637
- if (item.lane === "live" && editKey && finalizedKeys.has(editKey)) continue;
3367
+ if (item.lane === "live" && editKey && finalizedKeys.has(editKey)) {
3368
+ this.pool.settle(item.itemId!, "removed");
3369
+ continue;
3370
+ }
3371
+ let disposition: "accepted" | "ambiguous" | "rejected" = "accepted";
2638
3372
  try {
2639
3373
  // Draft streaming (opt-in, off by default): stream a live turn frame as a
2640
3374
  // best-effort rich-draft preview, debounced to >=1.5s per session through
@@ -2663,6 +3397,10 @@ export class TelegramNotificationDaemon {
2663
3397
  }
2664
3398
  }
2665
3399
  }
3400
+ if (topicLease && !this.topicLeaseIsCurrent(topicLease)) {
3401
+ disposition = "rejected";
3402
+ continue;
3403
+ }
2666
3404
  if (send.method === "sendPhoto" && send.photoBase64) {
2667
3405
  // Real photo upload (the default botApi multiparts base64 -> file).
2668
3406
  await this.botApi.call("sendPhoto", {
@@ -2699,6 +3437,7 @@ export class TelegramNotificationDaemon {
2699
3437
  // non-editable, HTML-only pool items (rich markers stripped) — same
2700
3438
  // per-token discipline as the non-rich split path.
2701
3439
  const chunks = splitTelegramHtml(send.text!);
3440
+ if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return;
2702
3441
  await this.botApi.call("sendMessage", {
2703
3442
  chat_id: this.opts.chatId,
2704
3443
  ...threadField,
@@ -2706,7 +3445,7 @@ export class TelegramNotificationDaemon {
2706
3445
  parse_mode: TELEGRAM_PARSE_MODE,
2707
3446
  });
2708
3447
  for (let i = 1; i < chunks.length; i++) {
2709
- this.pool.submit({
3448
+ this.submitPool({
2710
3449
  sessionId: item.sessionId,
2711
3450
  lane: item.lane,
2712
3451
  payload: {
@@ -2722,7 +3461,7 @@ export class TelegramNotificationDaemon {
2722
3461
  richDraftMarkdown: undefined,
2723
3462
  richClass: undefined,
2724
3463
  },
2725
- topicId,
3464
+ topicLease,
2726
3465
  },
2727
3466
  });
2728
3467
  }
@@ -2731,6 +3470,7 @@ export class TelegramNotificationDaemon {
2731
3470
  this.botApi,
2732
3471
  { chat_id: this.opts.chatId, ...threadField },
2733
3472
  send,
3473
+ AbortSignal.any([this.#deliveryAbort.signal, AbortSignal.timeout(30_000)]),
2734
3474
  sendHtmlFallback,
2735
3475
  logger,
2736
3476
  );
@@ -2768,7 +3508,7 @@ export class TelegramNotificationDaemon {
2768
3508
  }
2769
3509
  if (edited) {
2770
3510
  firstMessageId = existingId;
2771
- } else {
3511
+ } else if (!topicLease || this.topicLeaseIsCurrent(topicLease)) {
2772
3512
  const res = (await this.botApi.call("sendMessage", {
2773
3513
  chat_id: this.opts.chatId,
2774
3514
  ...threadField,
@@ -2796,7 +3536,7 @@ export class TelegramNotificationDaemon {
2796
3536
  // can never be re-promoted to a duplicate sendRichMessage.
2797
3537
  if (item.lane !== "live") {
2798
3538
  for (let i = 1; i < chunks.length; i++) {
2799
- this.pool.submit({
3539
+ this.submitPool({
2800
3540
  sessionId: item.sessionId,
2801
3541
  lane: item.lane,
2802
3542
  payload: {
@@ -2812,18 +3552,25 @@ export class TelegramNotificationDaemon {
2812
3552
  richDraftMarkdown: undefined,
2813
3553
  richClass: undefined,
2814
3554
  },
2815
- topicId,
3555
+ topicLease,
2816
3556
  },
2817
3557
  });
2818
3558
  }
2819
3559
  }
2820
- if (editKey && ckey !== undefined && firstMessageId !== undefined) {
3560
+ if (editKey && ckey !== undefined && firstMessageId !== undefined && !send.terminal) {
2821
3561
  this.recordLiveMessage(item.sessionId, ckey, firstMessageId);
2822
3562
  }
2823
3563
  }
2824
3564
  }
2825
3565
  } catch {
2826
3566
  // Best-effort: a failed send/edit must never stop the daemon.
3567
+ disposition = "ambiguous";
3568
+ } finally {
3569
+ this.pool.settle(item.itemId!, disposition);
3570
+ // A terminal tool frame owns the end of this coalescing key even when both
3571
+ // edit and fallback delivery fail. Retaining the old message id would leak
3572
+ // one entry per failure and let a later reused key edit stale Telegram state.
3573
+ if (send.terminal && editKey) this.liveMessages.delete(editKey);
2827
3574
  }
2828
3575
  }
2829
3576
  }
@@ -2836,9 +3583,17 @@ export class TelegramNotificationDaemon {
2836
3583
  private recordLiveMessage(sessionId: string, coalesceKey: string, messageId: number): void {
2837
3584
  const mapKey = `${sessionId}:${coalesceKey}`;
2838
3585
  const category = coalesceKey.split(":")[0] ?? "";
2839
- const prefix = `${sessionId}:${category}:`;
2840
- for (const k of [...this.liveMessages.keys()]) {
2841
- if (k !== mapKey && k.startsWith(prefix)) this.liveMessages.delete(k);
3586
+ // Single-slot categories (rolling turn/context/reasoning previews) evict prior
3587
+ // same-category entries to stay bounded. Concurrent categories such as
3588
+ // `tool:<toolCallId>` key each in-flight item independently: parallel tools each
3589
+ // own a distinct bubble, so evicting same-category siblings would orphan a
3590
+ // still-open tool's message id and leave a stale "started" bubble that its
3591
+ // terminal frame can no longer edit in place.
3592
+ if (!TelegramNotificationDaemon.#CONCURRENT_LIVE_CATEGORIES.has(category)) {
3593
+ const prefix = `${sessionId}:${category}:`;
3594
+ for (const k of [...this.liveMessages.keys()]) {
3595
+ if (k !== mapKey && k.startsWith(prefix)) this.liveMessages.delete(k);
3596
+ }
2842
3597
  }
2843
3598
  this.liveMessages.set(mapKey, messageId);
2844
3599
  }
@@ -2856,7 +3611,7 @@ export class TelegramNotificationDaemon {
2856
3611
  if (!(await this.pairedChatIsPrivate())) return;
2857
3612
  await this.notifyThreadedFallback();
2858
3613
  if (send.identity && this.flatIdentitySent.has(sessionId)) return;
2859
- this.pool.submit({ sessionId, lane: send.lane, coalesceKey: send.coalesceKey, payload: { send } });
3614
+ this.submitPool({ sessionId, lane: send.lane, coalesceKey: send.coalesceKey, payload: { send } });
2860
3615
  await this.flushPool();
2861
3616
  if (send.identity) this.flatIdentitySent.add(sessionId);
2862
3617
  }
@@ -2924,6 +3679,38 @@ export class TelegramNotificationDaemon {
2924
3679
  private stopFlushTimer(): void {
2925
3680
  this.runtime.stopInterval("telegram-flush");
2926
3681
  }
3682
+ private async renewOwnershipHeartbeat(): Promise<boolean> {
3683
+ return renewDaemonHeartbeat({
3684
+ settings: this.opts.settings,
3685
+ ownerId: this.opts.ownerId,
3686
+ tokenFingerprint: tokenFingerprint(this.opts.botToken),
3687
+ chatId: this.opts.chatId,
3688
+ fs: this.fsImpl,
3689
+ now: this.opts.now,
3690
+ pid: this.opts.pid ?? process.pid,
3691
+ });
3692
+ }
3693
+
3694
+ /**
3695
+ * Ownership must be renewed independently of Telegram's 25-second long poll:
3696
+ * the ownership TTL is shorter than a single poll request.
3697
+ */
3698
+ private startOwnershipHeartbeatTimer(): void {
3699
+ this.runtime.startInterval("telegram-owner-heartbeat", HEARTBEAT_INTERVAL_MS, () => {
3700
+ if (!this.running) return;
3701
+ void this.runtime
3702
+ .runExclusive("telegram-owner-heartbeat", async () => {
3703
+ if (!(await this.renewOwnershipHeartbeat())) this.runtime.requestStop();
3704
+ })
3705
+ .catch(err => {
3706
+ logger.warn(`notifications: ownership heartbeat failed: ${sanitizeDiagnostic(String(err))}`);
3707
+ });
3708
+ });
3709
+ }
3710
+
3711
+ private stopOwnershipHeartbeatTimer(): void {
3712
+ this.runtime.stopInterval("telegram-owner-heartbeat");
3713
+ }
2927
3714
 
2928
3715
  /** Run a root scan, guarding against overlapping scans from the timer + loop. */
2929
3716
  private async runScan(): Promise<void> {
@@ -2945,8 +3732,9 @@ export class TelegramNotificationDaemon {
2945
3732
 
2946
3733
  /** Send a single `typing` chat action into a busy session's topic (best-effort). */
2947
3734
  private async sendTyping(sessionId: string): Promise<void> {
2948
- const topicId = this.topics.get(sessionId)?.topicId;
2949
- if (!topicId || !(await this.pairedChatIsPrivate())) return;
3735
+ const topicLease = await this.topicAuthorityLease(sessionId);
3736
+ if (!topicLease || !this.topicLeaseIsCurrent(topicLease)) return;
3737
+ const topicId = topicLease.topicId;
2950
3738
  try {
2951
3739
  await this.botApi.call("sendChatAction", {
2952
3740
  chat_id: this.opts.chatId,
@@ -3010,7 +3798,8 @@ export class TelegramNotificationDaemon {
3010
3798
  const topicId =
3011
3799
  (await this.existingTopicForPrivateChat(session.sessionId)) ??
3012
3800
  (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
3013
- if (!topicId) return false;
3801
+ const topicLease = await this.topicAuthorityLease(session.sessionId);
3802
+ if (!topicId || !topicLease || topicLease.topicId !== topicId) return false;
3014
3803
 
3015
3804
  // Each logical session owns only its most recently rendered menu.
3016
3805
  this.#clearModelChoiceAliases(logicalSessionId);
@@ -3021,6 +3810,7 @@ export class TelegramNotificationDaemon {
3021
3810
  choices.map(choice => choice.label),
3022
3811
  index => aliases[index]!,
3023
3812
  );
3813
+ if (!this.topicLeaseIsCurrent(topicLease)) return false;
3024
3814
  try {
3025
3815
  const response = await this.botApi.call("sendMessage", {
3026
3816
  chat_id: this.opts.chatId,
@@ -3056,6 +3846,9 @@ export class TelegramNotificationDaemon {
3056
3846
  Number.isSafeInteger(msg.lastSeq) &&
3057
3847
  msg.lastSeq >= 0 &&
3058
3848
  Array.isArray(msg.events);
3849
+ if (replayValid) {
3850
+ session.hostGeneration = msg.generation;
3851
+ }
3059
3852
  const replayed: Record<string, unknown>[] = replayValid
3060
3853
  ? (msg.events as unknown[]).flatMap((event: unknown): Record<string, unknown>[] => {
3061
3854
  if (!event || typeof event !== "object" || Array.isArray(event)) return [];
@@ -3097,8 +3890,12 @@ export class TelegramNotificationDaemon {
3097
3890
  }
3098
3891
  await this.handleSessionMessage(session, frame);
3099
3892
  }
3100
- if (replayValid && this.topics.markReplayCursor(session.sessionId, msg.generation, msg.lastSeq))
3101
- await this.persistTopics();
3893
+ if (replayValid) {
3894
+ this.#terminalizeBtwTurnsForGenerationChange(session);
3895
+ this.#resumeBtwTurnsForSession(session);
3896
+ if (this.topics.markReplayCursor(session.sessionId, msg.generation, msg.lastSeq))
3897
+ await this.persistTopics();
3898
+ }
3102
3899
  return;
3103
3900
  }
3104
3901
  if (msg?.type === "event_replay_result") return;
@@ -3106,25 +3903,213 @@ export class TelegramNotificationDaemon {
3106
3903
  if (await this.sessionRouter.dispatch(session, msg as Record<string, unknown>)) return;
3107
3904
  if (await this.#renderModelChoices(session, msg as Record<string, unknown>)) return;
3108
3905
 
3906
+ if (msg?.type === "ephemeral_turn_result") {
3907
+ const requestId = typeof msg.requestId === "string" ? msg.requestId : undefined;
3908
+ if (!requestId) return;
3909
+ this.#purgeBtwTombstones();
3910
+ const tombstone = this.#btwTerminalTombstones.get(requestId);
3911
+ if (
3912
+ tombstone &&
3913
+ tombstone.sessionId === msg.sessionId &&
3914
+ tombstone.updateId === msg.updateId &&
3915
+ tombstone.messageId === msg.messageId &&
3916
+ tombstone.threadId === msg.threadId
3917
+ )
3918
+ return;
3919
+ if (tombstone) return;
3920
+ const pending = this.#pendingBtwTurns.get(requestId);
3921
+ if (!pending || pending.expiresAt <= (this.opts.now?.() ?? Date.now())) {
3922
+ this.#pendingBtwTurns.delete(requestId);
3923
+ return;
3924
+ }
3925
+ const logicalSessionId = this.#logicalSessionId(session);
3926
+ if (
3927
+ msg.sessionId !== logicalSessionId ||
3928
+ pending.logicalSessionId !== logicalSessionId ||
3929
+ pending.transportSessionId !== session.sessionId ||
3930
+ this.sessions.get(session.sessionId) !== session ||
3931
+ pending.endpointDigest !== session.endpointDigest ||
3932
+ pending.generation !== session.hostGeneration ||
3933
+ msg.threadId !== pending.threadId ||
3934
+ msg.updateId !== pending.updateId ||
3935
+ msg.messageId !== pending.messageId ||
3936
+ this.topics.sessionForTopic(pending.threadId) !== pending.transportSessionId
3937
+ )
3938
+ return;
3939
+ if (
3940
+ msg.status !== "ok" &&
3941
+ msg.status !== "busy" &&
3942
+ msg.status !== "timeout" &&
3943
+ msg.status !== "cancelled" &&
3944
+ msg.status !== "session_unavailable" &&
3945
+ msg.status !== "failed"
3946
+ )
3947
+ return;
3948
+ if (msg.status === "ok" && typeof msg.text !== "string") return;
3949
+ if (this.#stoppingBtw || this.#btwTerminalDeliveries.has(requestId)) return;
3950
+ const finished = Promise.withResolvers<void>();
3951
+ const terminalDelivery: PendingBtwDelivery = {
3952
+ pending,
3953
+ invalidated: false,
3954
+ terminalizeOnInvalidation: false,
3955
+ controller: new AbortController(),
3956
+ finished: finished.promise,
3957
+ finish: finished.resolve,
3958
+ };
3959
+ this.#btwTerminalDeliveries.set(requestId, terminalDelivery);
3960
+ let deliveryOutcome: "accepted" | "not_delivered" | "uncertain" | "partial_accepted" = "not_delivered";
3961
+ try {
3962
+ if (msg.status !== "ok") {
3963
+ const text =
3964
+ msg.status === "busy"
3965
+ ? "Two /btw questions are already running. Wait for one to finish."
3966
+ : msg.status === "timeout"
3967
+ ? "This /btw question timed out after 120 seconds. Send it again to retry."
3968
+ : msg.status === "cancelled" || msg.status === "session_unavailable"
3969
+ ? "This /btw question stopped because the GJC session closed or changed. Reopen it and try again."
3970
+ : "This /btw question failed. Send it again to retry.";
3971
+ try {
3972
+ const response = await this.#sendBtwMessage({
3973
+ threadId: pending.threadId,
3974
+ messageId: pending.messageId,
3975
+ text,
3976
+ signal: terminalDelivery.controller.signal,
3977
+ });
3978
+ deliveryOutcome =
3979
+ response && typeof response === "object" && (response as { ok?: unknown }).ok === true
3980
+ ? "accepted"
3981
+ : response && typeof response === "object" && (response as { ok?: unknown }).ok === false
3982
+ ? "not_delivered"
3983
+ : "uncertain";
3984
+ } catch {
3985
+ deliveryOutcome = "uncertain";
3986
+ logger.warn("notifications: /btw status delivery failed");
3987
+ }
3988
+ return;
3989
+ }
3990
+ const markdown = msg.text;
3991
+ const isAuthoritative = (): boolean =>
3992
+ !this.#stoppingBtw &&
3993
+ this.sessions.get(session.sessionId) === session &&
3994
+ session.ws.readyState === WebSocket.OPEN &&
3995
+ pending.endpointDigest === session.endpointDigest &&
3996
+ pending.generation === session.hostGeneration &&
3997
+ pending.logicalSessionId === this.#logicalSessionId(session) &&
3998
+ pending.transportSessionId === session.sessionId &&
3999
+ requestId === msg.requestId &&
4000
+ pending.logicalSessionId === msg.sessionId &&
4001
+ pending.messageId === msg.messageId &&
4002
+ pending.threadId === msg.threadId &&
4003
+ pending.updateId === msg.updateId &&
4004
+ this.topics.sessionForTopic(pending.threadId) === pending.transportSessionId;
4005
+ const signal = AbortSignal.any([
4006
+ this.#btwDeliveryAbort.signal,
4007
+ terminalDelivery.controller.signal,
4008
+ AbortSignal.timeout(30_000),
4009
+ ]);
4010
+ const deliver = async (
4011
+ method: "sendMessage" | "sendRichMessage",
4012
+ body: unknown,
4013
+ ): Promise<"accepted" | "rejected" | "uncertain" | "stale"> => {
4014
+ if (!isAuthoritative()) return "stale";
4015
+ try {
4016
+ const response = await this.botApi.call(method, body, { noRetry: true, signal });
4017
+ if (!response || typeof response !== "object") return "uncertain";
4018
+ if ((response as { ok?: unknown }).ok === true) return "accepted";
4019
+ if ((response as { ok?: unknown }).ok === false) return "rejected";
4020
+ return "uncertain";
4021
+ } catch {
4022
+ return "uncertain";
4023
+ }
4024
+ };
4025
+ const html = markdownToTelegramHtml(markdown);
4026
+ const fallback = async (): Promise<typeof deliveryOutcome> => {
4027
+ let acceptedChunks = 0;
4028
+ for (const [index, text] of splitTelegramHtml(html).entries()) {
4029
+ const outcome = await this.#queueBtwFallbackChunk({
4030
+ requestId,
4031
+ chunkIndex: index,
4032
+ pending,
4033
+ signal,
4034
+ isAuthoritative,
4035
+ body: {
4036
+ chat_id: this.opts.chatId,
4037
+ message_thread_id: Number(pending.threadId),
4038
+ ...(index === 0 ? { reply_parameters: { message_id: pending.messageId } } : {}),
4039
+ text,
4040
+ parse_mode: TELEGRAM_PARSE_MODE,
4041
+ },
4042
+ });
4043
+ if (outcome === "accepted") {
4044
+ acceptedChunks++;
4045
+ continue;
4046
+ }
4047
+ if (outcome === "partial_accepted" || acceptedChunks > 0) return "partial_accepted";
4048
+ return outcome === "uncertain" ? "uncertain" : "not_delivered";
4049
+ }
4050
+ return "accepted";
4051
+ };
4052
+ if (this.opts.rich?.enabled !== false && isBtwRichEligible(markdown)) {
4053
+ const outcome = await deliver("sendRichMessage", {
4054
+ chat_id: this.opts.chatId,
4055
+ message_thread_id: Number(pending.threadId),
4056
+ reply_parameters: { message_id: pending.messageId },
4057
+ rich_message: { markdown, skip_entity_detection: true },
4058
+ });
4059
+ deliveryOutcome =
4060
+ outcome === "accepted"
4061
+ ? "accepted"
4062
+ : outcome === "rejected"
4063
+ ? await fallback()
4064
+ : outcome === "uncertain"
4065
+ ? "uncertain"
4066
+ : "not_delivered";
4067
+ } else {
4068
+ deliveryOutcome = await fallback();
4069
+ }
4070
+ } finally {
4071
+ if (this.#btwTerminalDeliveries.get(requestId) === terminalDelivery) {
4072
+ this.#btwTerminalDeliveries.delete(requestId);
4073
+ }
4074
+ try {
4075
+ // Unknown transport outcomes stay single-attempt to avoid duplicate Telegram replies.
4076
+ if (terminalDelivery.invalidated && deliveryOutcome === "not_delivered") {
4077
+ if (terminalDelivery.terminalizeOnInvalidation)
4078
+ await this.#terminalizeBtwTurn(requestId, pending, this.#stoppingBtw);
4079
+ } else {
4080
+ // Any accepted chunk is already a user-visible terminal sequence. Tombstone
4081
+ // it even when a later chunk fails so invalidation cannot append a second
4082
+ // session-unavailable reply.
4083
+ this.#takeBtwTurn(requestId, pending);
4084
+ }
4085
+ } finally {
4086
+ terminalDelivery.finish();
4087
+ }
4088
+ }
4089
+ return;
4090
+ }
3109
4091
  if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
3110
4092
  const send = renderThreadedFrame(msg);
3111
4093
  if (!send) return;
3112
4094
  const existingTopic = await this.existingTopicForPrivateChat(session.sessionId);
4095
+ if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
3113
4096
  if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
3114
4097
  this.rememberPendingThreadedFrame(session.sessionId, send, msg as Record<string, unknown>);
3115
4098
  return;
3116
4099
  }
3117
4100
  if (send.identity && !this.sessionCanClaimIdentity(session, msg)) {
3118
4101
  const ownerId = this.topicOwnerForIdentity(msg);
3119
- const ownerTopic = ownerId ? this.topics.get(ownerId) : undefined;
3120
- if (ownerId && ownerId !== session.sessionId && ownerTopic) {
3121
- await this.flushPendingThreadedFrames(session.sessionId, ownerTopic.topicId);
4102
+ const ownerLease = ownerId ? await this.topicAuthorityLease(ownerId) : undefined;
4103
+ if (ownerId && ownerId !== session.sessionId && ownerLease) {
4104
+ await this.flushPendingThreadedFrames(session.sessionId, ownerLease);
3122
4105
  return;
3123
4106
  }
3124
4107
  }
3125
4108
  const topicId =
3126
4109
  existingTopic ?? (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
3127
- if (!topicId) {
4110
+ const topicLease = await this.topicAuthorityLease(session.sessionId);
4111
+ if (!topicId || !topicLease || topicLease.topicId !== topicId) {
4112
+ if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
3128
4113
  await this.deliverFlatFallback(session.sessionId, send);
3129
4114
  return;
3130
4115
  }
@@ -3137,7 +4122,7 @@ export class TelegramNotificationDaemon {
3137
4122
  // Explicit Telegram-side user renames own the topic title. Pending user
3138
4123
  // reconciliation runs before daemon identity naming, so retries and daemon
3139
4124
  // restarts cannot silently replace the preserved name.
3140
- await this.reconcileUserTopicName(session.sessionId, topicId);
4125
+ await this.reconcileUserTopicName(topicLease);
3141
4126
  const name = this.topicNameFor(session.sessionId, msg);
3142
4127
  if (this.topics.needsRename(session.sessionId, name)) {
3143
4128
  this.daemonRenameAttempts.set(
@@ -3145,6 +4130,7 @@ export class TelegramNotificationDaemon {
3145
4130
  (this.daemonRenameAttempts.get(session.sessionId) ?? 0) + 1,
3146
4131
  );
3147
4132
  try {
4133
+ if (!this.topicLeaseIsCurrent(topicLease)) return;
3148
4134
  const response = await this.botApi.call("editForumTopic", {
3149
4135
  chat_id: this.opts.chatId,
3150
4136
  message_thread_id: Number(topicId),
@@ -3159,31 +4145,34 @@ export class TelegramNotificationDaemon {
3159
4145
  if (remaining > 0) this.daemonRenameAttempts.set(session.sessionId, remaining);
3160
4146
  else {
3161
4147
  this.daemonRenameAttempts.delete(session.sessionId);
3162
- await this.reconcileUserTopicName(session.sessionId, topicId);
4148
+ await this.reconcileUserTopicName(topicLease);
3163
4149
  }
3164
4150
  }
3165
4151
  }
3166
4152
  // Send the full bulleted identity header EXACTLY ONCE per topic.
3167
4153
  if (this.topics.needsIdentity(session.sessionId)) {
3168
- await this.submitThreadedFrame(session.sessionId, send, topicId);
4154
+ await this.submitThreadedFrame(session.sessionId, send, topicLease);
3169
4155
  this.topics.markIdentitySent(session.sessionId);
3170
4156
  }
3171
- await this.flushPendingThreadedFrames(session.sessionId, topicId);
4157
+ await this.flushPendingThreadedFrames(session.sessionId, topicLease);
3172
4158
  await this.persistTopics();
3173
4159
  return;
3174
4160
  }
3175
- await this.submitThreadedFrame(session.sessionId, send, topicId);
4161
+ await this.submitThreadedFrame(session.sessionId, send, topicLease);
3176
4162
  return;
3177
4163
  }
3178
4164
  if (msg.type === "action_needed" && msg.id) {
3179
4165
  if (msg.kind === "ask") session.pending.set(msg.id, { sessionId: session.sessionId, actionId: msg.id });
4166
+ if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
3180
4167
  const topicId = await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg));
4168
+ const topicLease = topicId ? this.topicAuthorityLeaseFromRegistry(session.sessionId) : undefined;
4169
+ if (topicId && (!topicLease || topicLease.topicId !== topicId)) return;
3181
4170
  if (!topicId) {
3182
4171
  // Fail closed for non-private chats; only nudge + flat-deliver in a private DM.
3183
4172
  if (!(await this.pairedChatIsPrivate())) return;
3184
4173
  await this.notifyThreadedFallback();
3185
4174
  }
3186
- const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
4175
+ const threadField = topicLease ? { message_thread_id: Number(topicLease.topicId) } : {};
3187
4176
  const controls: Array<{
3188
4177
  id: "navigation_forward";
3189
4178
  kind: "navigation";
@@ -3238,6 +4227,7 @@ export class TelegramNotificationDaemon {
3238
4227
  const chunks = splitTelegramHtml(rendered.text);
3239
4228
  let result: { result?: { message_id?: number } } = {};
3240
4229
  for (let i = 0; i < chunks.length; i++) {
4230
+ if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return undefined;
3241
4231
  result = (await this.botApi.call("sendMessage", {
3242
4232
  chat_id: this.opts.chatId,
3243
4233
  ...threadField,
@@ -3253,6 +4243,7 @@ export class TelegramNotificationDaemon {
3253
4243
  // Rich (default on): promote to sendRichMessage with a top-level
3254
4244
  // reply_markup (probe-confirmed). Any miss falls back to the HTML loop.
3255
4245
 
4246
+ if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return;
3256
4247
  const outcome = await deliverRichActionWithFallback(
3257
4248
  this.botApi,
3258
4249
  { chat_id: this.opts.chatId, ...threadField },
@@ -3264,8 +4255,8 @@ export class TelegramNotificationDaemon {
3264
4255
  summary: msg.summary,
3265
4256
  }),
3266
4257
  replyMarkup: kind === "ask" && inline_keyboard.length ? { inline_keyboard } : undefined,
3267
- requireMessageId: kind === "ask",
3268
4258
  },
4259
+ AbortSignal.any([this.#deliveryAbort.signal, AbortSignal.timeout(30_000)]),
3269
4260
  sendHtmlChunks,
3270
4261
  logger,
3271
4262
  );
@@ -3433,7 +4424,8 @@ export class TelegramNotificationDaemon {
3433
4424
  } catch {
3434
4425
  return "retry";
3435
4426
  }
3436
- await this.reconcileUserTopicName(sessionId, String(threadId));
4427
+ const topicLease = this.topicAuthorityLeaseFromRegistry(sessionId);
4428
+ if (topicLease) await this.reconcileUserTopicName(topicLease);
3437
4429
  await this.rememberSeenUpdateId(updateId);
3438
4430
  return "consumed";
3439
4431
  }
@@ -3442,7 +4434,8 @@ export class TelegramNotificationDaemon {
3442
4434
  } catch {
3443
4435
  return "retry";
3444
4436
  }
3445
- await this.reconcileUserTopicName(sessionId, String(threadId));
4437
+ const topicLease = this.topicAuthorityLeaseFromRegistry(sessionId);
4438
+ if (topicLease) await this.reconcileUserTopicName(topicLease);
3446
4439
  await this.rememberSeenUpdateId(updateId);
3447
4440
  return "consumed";
3448
4441
  }
@@ -3560,6 +4553,8 @@ export class TelegramNotificationDaemon {
3560
4553
  // message in a topic (plain text, or a reply to a non-ask message) is a
3561
4554
  // free-text injection. Previously replies bypassed injection entirely.
3562
4555
  const replyTo = raw.message?.reply_to_message?.message_id;
4556
+ const reservedBtw =
4557
+ typeof raw.message?.text === "string" ? parseBtwCommand(raw.message.text, this.botUsername) : undefined;
3563
4558
  const isAskReply =
3564
4559
  replyTo !== undefined && (this.messageRoutes.has(String(replyTo)) || this.messageRoutes.has(Number(replyTo)));
3565
4560
  const directControl =
@@ -3567,7 +4562,7 @@ export class TelegramNotificationDaemon {
3567
4562
  ? parseTelegramControlCommand(raw.message.text, this.botUsername)
3568
4563
  : ({ kind: "none" } as const);
3569
4564
  const interceptAskControl = isAskReply && directControl.kind !== "none";
3570
- if (!raw.callback_query && (!isAskReply || interceptAskControl)) {
4565
+ if (!raw.callback_query && (!isAskReply || interceptAskControl || reservedBtw !== undefined)) {
3571
4566
  const inbound = decideThreadedInbound(update as never, {
3572
4567
  pairedChatId: this.opts.chatId,
3573
4568
  topicToSession: t => this.topics.sessionForTopic(t),
@@ -3583,7 +4578,7 @@ export class TelegramNotificationDaemon {
3583
4578
  const session = this.sessions.get(inbound.sessionId);
3584
4579
  if (preliminaryControl.kind === "invalid" && session?.ws.readyState !== WebSocket.OPEN) return;
3585
4580
  if (preliminaryControl.kind === "command" && session?.ws.readyState !== WebSocket.OPEN) {
3586
- if (await this.rememberSeenUpdateIdForUnavailableNotice(inbound.updateId)) {
4581
+ if (await this.reserveSeenUpdateId(inbound.updateId)) {
3587
4582
  try {
3588
4583
  await this.botApi.call("sendMessage", {
3589
4584
  chat_id: this.opts.chatId,
@@ -3596,13 +4591,45 @@ export class TelegramNotificationDaemon {
3596
4591
  }
3597
4592
  return;
3598
4593
  }
4594
+ const reservedBtw = parseBtwCommand(inbound.text, this.botUsername);
4595
+ if (reservedBtw?.kind === "ignored") {
4596
+ await this.rememberSeenUpdateId(inbound.updateId);
4597
+ return;
4598
+ }
4599
+ if (reservedBtw?.kind === "question" && (!reservedBtw.question || inbound.attachment)) {
4600
+ if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
4601
+ await this.#sendBtwMessage({
4602
+ threadId: inbound.threadId,
4603
+ messageId: inbound.messageId,
4604
+ text: BTW_USAGE_TEXT,
4605
+ });
4606
+ return;
4607
+ }
4608
+ if (reservedBtw?.kind === "question" && this.opts.btw?.enabled === false) {
4609
+ if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
4610
+ await this.#sendBtwMessage({
4611
+ threadId: inbound.threadId,
4612
+ messageId: inbound.messageId,
4613
+ text: "Telegram /btw is disabled in local settings.",
4614
+ });
4615
+ return;
4616
+ }
4617
+ if (reservedBtw?.kind === "question" && session?.ws.readyState !== WebSocket.OPEN) {
4618
+ if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
4619
+ await this.#sendBtwMessage({
4620
+ threadId: inbound.threadId,
4621
+ messageId: inbound.messageId,
4622
+ text: "Restart this GJC session to enable /btw.",
4623
+ });
4624
+ return;
4625
+ }
3599
4626
  if (session?.ws.readyState === WebSocket.OPEN) {
3600
4627
  const attachmentResult = inbound.attachment
3601
4628
  ? await this.resolveInboundAttachment(inbound.attachment, inbound.sessionId)
3602
4629
  : undefined;
3603
4630
  const images = attachmentResult?.images ?? [];
3604
4631
  const fileNotes = attachmentResult?.fileNotes ?? [];
3605
- const hasMedia = images.length > 0 || fileNotes.length > 0;
4632
+ const hasMedia = inbound.attachment !== undefined || images.length > 0 || fileNotes.length > 0;
3606
4633
  const baseInjectedText = [inbound.text, ...fileNotes].filter(Boolean).join("\n");
3607
4634
  // A reply to a rich message we sent (not an ask route) loses its original
3608
4635
  // text: Telegram does not echo it in reply_to_message. Restore it from the
@@ -3614,6 +4641,100 @@ export class TelegramNotificationDaemon {
3614
4641
  const injectedText = repliedOriginal
3615
4642
  ? `> replied-to message:\n${repliedOriginal}\n\n${baseInjectedText}`
3616
4643
  : baseInjectedText;
4644
+ const btw = parseBtwCommand(inbound.text, this.botUsername);
4645
+ if (btw?.kind === "ignored") {
4646
+ await this.rememberSeenUpdateId(inbound.updateId);
4647
+ return;
4648
+ }
4649
+ if (btw?.kind === "question") {
4650
+ const btwQuestion = btw.question;
4651
+ if (!btwQuestion || hasMedia) {
4652
+ if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
4653
+ await this.#sendBtwMessage({
4654
+ threadId: inbound.threadId,
4655
+ messageId: inbound.messageId,
4656
+ text: BTW_USAGE_TEXT,
4657
+ });
4658
+ return;
4659
+ }
4660
+ if (!isValidBtwQuestion(btwQuestion)) {
4661
+ try {
4662
+ await this.#sendBtwMessage({
4663
+ threadId: inbound.threadId,
4664
+ messageId: inbound.messageId,
4665
+ text: BTW_QUESTION_LIMIT_TEXT,
4666
+ });
4667
+ } catch {
4668
+ logger.warn("notifications: /btw question-limit delivery failed");
4669
+ }
4670
+ await this.reserveSeenUpdateId(inbound.updateId);
4671
+ return;
4672
+ }
4673
+ if (this.opts.btw?.enabled === false) {
4674
+ if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
4675
+ await this.#sendBtwMessage({
4676
+ threadId: inbound.threadId,
4677
+ messageId: inbound.messageId,
4678
+ text: "Telegram /btw is disabled in local settings.",
4679
+ });
4680
+ return;
4681
+ }
4682
+ if (!session.ephemeralCapable || session.hostGeneration < 1) {
4683
+ if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
4684
+ await this.#sendBtwMessage({
4685
+ threadId: inbound.threadId,
4686
+ messageId: inbound.messageId,
4687
+ text: "Restart this GJC session to enable /btw.",
4688
+ });
4689
+ return;
4690
+ }
4691
+ this.#purgeBtwTombstones();
4692
+ for (const [id, pending] of this.#pendingBtwTurns) {
4693
+ if (pending.expiresAt <= (this.opts.now?.() ?? Date.now())) this.#pendingBtwTurns.delete(id);
4694
+ }
4695
+ if (!Number.isSafeInteger(inbound.messageId) || inbound.messageId <= 0) return;
4696
+ while (
4697
+ this.#pendingBtwTurns.size + this.#btwTerminalTombstones.size >= BTW_MAX_PENDING &&
4698
+ this.#btwTerminalTombstones.size > 0
4699
+ ) {
4700
+ this.#btwTerminalTombstones.delete(this.#btwTerminalTombstones.keys().next().value!);
4701
+ }
4702
+ if (this.#pendingBtwTurns.size >= BTW_MAX_PENDING) {
4703
+ if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
4704
+ await this.#sendBtwMessage({
4705
+ threadId: inbound.threadId,
4706
+ messageId: inbound.messageId,
4707
+ text: BTW_CAPACITY_TEXT,
4708
+ });
4709
+ return;
4710
+ }
4711
+ const transportSessionId = session.sessionId;
4712
+ const logicalSessionId = this.#logicalSessionId(session);
4713
+ const requestId = `btw:${crypto.randomUUID()}`;
4714
+ if (!(await this.reserveSeenUpdateId(inbound.updateId))) return;
4715
+ const pending: PendingBtwTurn = {
4716
+ transportSessionId,
4717
+ logicalSessionId,
4718
+ endpointDigest: session.endpointDigest,
4719
+ generation: session.hostGeneration,
4720
+ question: btwQuestion,
4721
+ messageId: inbound.messageId,
4722
+ threadId: inbound.threadId,
4723
+ updateId: inbound.updateId,
4724
+ expiresAt: (this.opts.now?.() ?? Date.now()) + BTW_PENDING_TTL_MS,
4725
+ };
4726
+ this.#pendingBtwTurns.set(requestId, pending);
4727
+ if (!this.#sendPendingBtwTurn(session, requestId, pending)) {
4728
+ this.#pendingBtwTurns.delete(requestId);
4729
+ await this.#sendBtwMessage({
4730
+ threadId: inbound.threadId,
4731
+ messageId: inbound.messageId,
4732
+ text: "Unable to start /btw because this GJC session disconnected. Reopen the session and try again.",
4733
+ });
4734
+ return;
4735
+ }
4736
+ return;
4737
+ }
3617
4738
  const control = hasMedia ? { kind: "none" as const } : preliminaryControl;
3618
4739
  if (control.kind !== "none") {
3619
4740
  await this.rememberSeenUpdateId(inbound.updateId);
@@ -3727,7 +4848,10 @@ export class TelegramNotificationDaemon {
3727
4848
  try {
3728
4849
  await this.botApi.call("setMyCommands", {
3729
4850
  commands: [
3730
- { command: "verbose", description: "Mirror full tool output + reasoning in this thread" },
4851
+ {
4852
+ command: "verbose",
4853
+ description: "Mirror bounded tool-owned summaries + provider-displayable reasoning summaries",
4854
+ },
3731
4855
  { command: "lean", description: "Mirror assistant text + tool names only (default)" },
3732
4856
  { command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
3733
4857
  { command: "rich", description: "Toggle rich Telegram delivery: /rich <on|off>" },
@@ -3736,6 +4860,7 @@ export class TelegramNotificationDaemon {
3736
4860
  { command: "model", description: "Select a model for this session" },
3737
4861
  { command: "context", description: "Show current context usage for this session" },
3738
4862
  { command: "compact", description: "Compact this session: /compact [instructions]" },
4863
+ { command: "btw", description: "Ask an ephemeral side question in this session" },
3739
4864
  { command: "session_create", description: "Create a GJC session: path, worktree, or dir [--mpreset]" },
3740
4865
  { command: "session_recent", description: "List recent GJC sessions" },
3741
4866
  { command: "session_close", description: "Close a GJC-managed session" },
@@ -3748,15 +4873,25 @@ export class TelegramNotificationDaemon {
3748
4873
  }
3749
4874
 
3750
4875
  async run(): Promise<void> {
4876
+ // Runtime callers can bypass TypeScript's option type. Do not derive an
4877
+ // ownership identity from absent credentials; lifecycle startup still owns
4878
+ // cleanup of a factory-created server on this fail-closed path.
4879
+ if (!validBotToken(this.opts.botToken)) {
4880
+ await this.startLifecycleControl();
4881
+ return;
4882
+ }
3751
4883
  this.running = await renewDaemonHeartbeat({
3752
4884
  settings: this.opts.settings,
3753
4885
  ownerId: this.opts.ownerId,
4886
+ tokenFingerprint: tokenFingerprint(this.opts.botToken),
4887
+ chatId: this.opts.chatId,
3754
4888
  fs: this.fsImpl,
3755
4889
  now: this.opts.now,
3756
4890
  pid: this.opts.pid ?? process.pid,
3757
4891
  });
3758
4892
  if (!this.running) return;
3759
4893
  this.runtime.start();
4894
+ this.startOwnershipHeartbeatTimer();
3760
4895
  this.startFlushTimer();
3761
4896
  this.startScanTimer();
3762
4897
  this.startTypingTimer();
@@ -3774,16 +4909,7 @@ export class TelegramNotificationDaemon {
3774
4909
  let idleSince = this.runtime.now();
3775
4910
  while (this.running) {
3776
4911
  if (await this.controlStopRequested()) break;
3777
- if (
3778
- !(await renewDaemonHeartbeat({
3779
- settings: this.opts.settings,
3780
- ownerId: this.opts.ownerId,
3781
- fs: this.fsImpl,
3782
- now: this.opts.now,
3783
- pid: this.opts.pid ?? process.pid,
3784
- }))
3785
- )
3786
- break;
4912
+ if (!(await this.renewOwnershipHeartbeat())) break;
3787
4913
  await this.runScan();
3788
4914
  if (await this.controlStopRequested()) break;
3789
4915
  const idleElapsed = this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000);
@@ -3818,24 +4944,50 @@ export class TelegramNotificationDaemon {
3818
4944
  await this.runtime.sleep(10);
3819
4945
  }
3820
4946
  } finally {
3821
- this.runtime.stop();
3822
- this.stopFlushTimer();
3823
- this.stopScanTimer();
3824
- this.stopTypingTimer();
3825
- this.stopLifecycleControl();
3826
- await this.cleanupAllAttachmentDirs();
3827
- // Persist durable state before releasing ownership so a fresh daemon
3828
- // (e.g. after reload) reloads aliases/topics seamlessly.
3829
- await this.persistAliases().catch(() => undefined);
3830
- await this.persistTopics().catch(() => undefined);
3831
- await this.persistSeenUpdateIds().catch(() => undefined);
3832
- await this.opts.control?.clear?.(this.opts.ownerId).catch(() => undefined);
3833
- await releaseDaemonOwnership({
3834
- settings: this.opts.settings,
3835
- ownerId: this.opts.ownerId,
3836
- fs: this.fsImpl,
3837
- now: this.opts.now,
4947
+ this.effects.beginShutdown();
4948
+ let persisted = false;
4949
+ const shutdown = this.effects.allowTerminal(async () => {
4950
+ await this.#drainBtwTurns();
4951
+ this.runtime.stop();
4952
+ this.stopOwnershipHeartbeatTimer();
4953
+ this.stopFlushTimer();
4954
+ this.stopScanTimer();
4955
+ this.stopTypingTimer();
4956
+ this.stopLifecycleControl();
4957
+ await this.cleanupAllAttachmentDirs();
4958
+ await this.persistAliases();
4959
+ await this.persistTopics();
4960
+ await this.persistSeenUpdateIds();
4961
+ await this.opts.control?.clear?.(this.opts.ownerId);
4962
+ persisted = true;
3838
4963
  });
4964
+ const deadline = Promise.withResolvers<boolean>();
4965
+ const deadlineTimer = setTimeout(() => deadline.resolve(false), BTW_SHUTDOWN_JOIN_MS);
4966
+ const completed = await Promise.race([
4967
+ shutdown.then(
4968
+ () => true,
4969
+ error => {
4970
+ logger.warn(`notifications: shutdown persistence failed: ${sanitizeDiagnostic(String(error))}`);
4971
+ return false;
4972
+ },
4973
+ ),
4974
+ deadline.promise,
4975
+ ]);
4976
+ clearTimeout(deadlineTimer);
4977
+ const quiesced = completed && (await this.effects.join(BTW_SHUTDOWN_JOIN_MS));
4978
+ if (!quiesced || !persisted) {
4979
+ logger.warn("notifications: shutdown was not durably quiesced; retaining daemon ownership");
4980
+ } else {
4981
+ await releaseDaemonOwnership({
4982
+ settings: this.opts.settings,
4983
+ ownerId: this.opts.ownerId,
4984
+ tokenFingerprint: tokenFingerprint(this.opts.botToken),
4985
+ chatId: this.opts.chatId,
4986
+ pid: this.opts.pid ?? process.pid,
4987
+ fs: this.fsImpl,
4988
+ now: this.opts.now,
4989
+ });
4990
+ }
3839
4991
  }
3840
4992
  }
3841
4993