@gajae-code/coding-agent 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (418) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/dist/types/async/job-manager.d.ts +46 -7
  3. package/dist/types/cli/config-cli.d.ts +11 -1
  4. package/dist/types/cli/notify-cli.d.ts +1 -0
  5. package/dist/types/cli/session-picker.d.ts +2 -1
  6. package/dist/types/cli/setup-cli.d.ts +1 -1
  7. package/dist/types/commands/mcp-serve.d.ts +33 -0
  8. package/dist/types/commands/mcp.d.ts +1 -1
  9. package/dist/types/commands/notify.d.ts +0 -2
  10. package/dist/types/commands/sdk.d.ts +1 -1
  11. package/dist/types/config/atomic-yaml-patch.d.ts +18 -0
  12. package/dist/types/config/file-lock.d.ts +14 -4
  13. package/dist/types/config/keybindings.d.ts +42 -1
  14. package/dist/types/config/model-auth.d.ts +3 -0
  15. package/dist/types/config/model-bindings-applier.d.ts +13 -0
  16. package/dist/types/config/model-discovery-manager.d.ts +49 -0
  17. package/dist/types/config/model-equivalence.d.ts +20 -0
  18. package/dist/types/config/model-registry.d.ts +16 -15
  19. package/dist/types/config/model-resolver.d.ts +28 -18
  20. package/dist/types/config/models-config-schema.d.ts +11 -0
  21. package/dist/types/config/settings-schema.d.ts +56 -1
  22. package/dist/types/config/settings.d.ts +14 -11
  23. package/dist/types/coordinator-mcp/policy.d.ts +5 -0
  24. package/dist/types/coordinator-mcp/question-gate-codec.d.ts +129 -0
  25. package/dist/types/coordinator-mcp/question-state.d.ts +356 -0
  26. package/dist/types/coordinator-mcp/server.d.ts +4 -0
  27. package/dist/types/discovery/helpers.d.ts +0 -6
  28. package/dist/types/edit/index.d.ts +3 -0
  29. package/dist/types/eval/py/env.d.ts +9 -0
  30. package/dist/types/extensibility/extensions/loader.d.ts +2 -1
  31. package/dist/types/extensibility/extensions/runner.d.ts +2 -2
  32. package/dist/types/extensibility/extensions/types.d.ts +34 -3
  33. package/dist/types/extensibility/hooks/runner.d.ts +1 -1
  34. package/dist/types/extensibility/shared-events.d.ts +2 -0
  35. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +10 -1
  36. package/dist/types/gjc-runtime/deep-interview-state.d.ts +44 -0
  37. package/dist/types/gjc-runtime/goal-mode-request.d.ts +11 -1
  38. package/dist/types/gjc-runtime/launch-worktree.d.ts +4 -2
  39. package/dist/types/gjc-runtime/linux-proc.d.ts +29 -0
  40. package/dist/types/gjc-runtime/session-resolution.d.ts +2 -2
  41. package/dist/types/gjc-runtime/state-migrations.d.ts +1 -1
  42. package/dist/types/gjc-runtime/state-writer.d.ts +2 -1
  43. package/dist/types/gjc-runtime/team-launch.d.ts +59 -0
  44. package/dist/types/gjc-runtime/team-notify.d.ts +45 -0
  45. package/dist/types/gjc-runtime/team-runtime.d.ts +29 -121
  46. package/dist/types/gjc-runtime/team-store.d.ts +160 -0
  47. package/dist/types/gjc-runtime/team-workers.d.ts +78 -0
  48. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +8 -0
  49. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +13 -0
  50. package/dist/types/gjc-runtime/ultragoal-evidence.d.ts +38 -0
  51. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +7 -0
  52. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +23 -0
  53. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +11 -0
  54. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +55 -33
  55. package/dist/types/gjc-runtime/workflow-cli-common.d.ts +8 -0
  56. package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -0
  57. package/dist/types/goals/continuation-timeout-guard.d.ts +22 -0
  58. package/dist/types/goals/runtime.d.ts +1 -0
  59. package/dist/types/goals/state.d.ts +8 -0
  60. package/dist/types/harness-control-plane/lifecycle-reconciliation.d.ts +17 -0
  61. package/dist/types/harness-control-plane/phase-rollup.d.ts +4 -7
  62. package/dist/types/harness-control-plane/receipt-ingest.d.ts +5 -0
  63. package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
  64. package/dist/types/hindsight/state.d.ts +11 -4
  65. package/dist/types/lsp/lspmux.d.ts +5 -3
  66. package/dist/types/lsp/path-trust.d.ts +1 -0
  67. package/dist/types/main.d.ts +3 -2
  68. package/dist/types/memories/storage.d.ts +1 -0
  69. package/dist/types/modes/action-registry.d.ts +29 -0
  70. package/dist/types/modes/components/chrome.d.ts +14 -0
  71. package/dist/types/modes/components/command-palette.d.ts +27 -0
  72. package/dist/types/modes/components/custom-provider-wizard.d.ts +1 -0
  73. package/dist/types/modes/components/index.d.ts +2 -1
  74. package/dist/types/modes/components/pet-selector.d.ts +1 -1
  75. package/dist/types/modes/components/plan-preview-overlay.d.ts +43 -0
  76. package/dist/types/modes/components/queue-mode-selector.d.ts +1 -1
  77. package/dist/types/modes/components/queue-pane.d.ts +12 -0
  78. package/dist/types/modes/components/read-tool-group.d.ts +6 -0
  79. package/dist/types/modes/components/session-observer-overlay.d.ts +3 -4
  80. package/dist/types/modes/components/sessions-dashboard.d.ts +32 -0
  81. package/dist/types/modes/components/settings-selector.d.ts +1 -1
  82. package/dist/types/modes/components/show-images-selector.d.ts +1 -1
  83. package/dist/types/modes/components/status-line/types.d.ts +1 -1
  84. package/dist/types/modes/components/status-line.d.ts +1 -94
  85. package/dist/types/modes/components/tasks-pane.d.ts +15 -0
  86. package/dist/types/modes/components/theme-selector.d.ts +1 -1
  87. package/dist/types/modes/components/tool-execution.d.ts +15 -0
  88. package/dist/types/modes/components/tool-status-header.d.ts +111 -0
  89. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +42 -0
  90. package/dist/types/modes/controllers/command-controller.d.ts +2 -2
  91. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -0
  92. package/dist/types/modes/controllers/goal-mode-controller.d.ts +62 -0
  93. package/dist/types/modes/controllers/input-controller.d.ts +8 -1
  94. package/dist/types/modes/controllers/mode-gate.d.ts +8 -0
  95. package/dist/types/modes/controllers/plan-mode-controller.d.ts +65 -0
  96. package/dist/types/modes/controllers/selector-controller.d.ts +15 -3
  97. package/dist/types/modes/controllers/stt-controller.d.ts +26 -0
  98. package/dist/types/modes/interactive-mode.d.ts +21 -19
  99. package/dist/types/modes/jobs-observer.d.ts +2 -0
  100. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +4 -11
  101. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +21 -2
  102. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +31 -0
  103. package/dist/types/modes/tasks-aggregator.d.ts +39 -0
  104. package/dist/types/modes/transcript-item-registry.d.ts +60 -0
  105. package/dist/types/modes/types.d.ts +22 -15
  106. package/dist/types/modes/utils/ui-helpers.d.ts +2 -2
  107. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  108. package/dist/types/sdk/broker/broker.d.ts +78 -12
  109. package/dist/types/sdk/broker/ensure.d.ts +9 -0
  110. package/dist/types/sdk/broker/lifecycle-codec.d.ts +4 -0
  111. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +15 -1
  112. package/dist/types/sdk/broker/lifecycle.d.ts +6 -2
  113. package/dist/types/sdk/broker/process-incarnation.d.ts +2 -0
  114. package/dist/types/sdk/broker/session-index.d.ts +0 -1
  115. package/dist/types/sdk/broker/state-version.d.ts +2 -0
  116. package/dist/types/sdk/bus/chat-effect-journal.d.ts +1 -0
  117. package/dist/types/sdk/bus/config-commands.d.ts +1 -1
  118. package/dist/types/sdk/bus/config.d.ts +11 -0
  119. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  120. package/dist/types/sdk/bus/discord-daemon.d.ts +5 -0
  121. package/dist/types/sdk/bus/html-format.d.ts +3 -2
  122. package/dist/types/sdk/bus/index.d.ts +27 -0
  123. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  124. package/dist/types/sdk/bus/notification-service.d.ts +20 -0
  125. package/dist/types/sdk/bus/rate-limit-pool.d.ts +13 -2
  126. package/dist/types/sdk/bus/recent-activity.d.ts +21 -12
  127. package/dist/types/sdk/bus/rich-document.d.ts +7 -0
  128. package/dist/types/sdk/bus/rich-render.d.ts +18 -41
  129. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +9 -4
  130. package/dist/types/sdk/bus/telegram-daemon.d.ts +69 -3
  131. package/dist/types/sdk/bus/threaded-inbound.d.ts +1 -1
  132. package/dist/types/sdk/bus/threaded-render.d.ts +13 -0
  133. package/dist/types/sdk/bus/topic-registry.d.ts +24 -2
  134. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  135. package/dist/types/sdk/host/host.d.ts +2 -0
  136. package/dist/types/sdk/index.d.ts +1 -0
  137. package/dist/types/sdk/session-directory.d.ts +56 -0
  138. package/dist/types/session/agent-session.d.ts +82 -47
  139. package/dist/types/session/agent-storage.d.ts +7 -0
  140. package/dist/types/session/fallback-chain-controller.d.ts +10 -2
  141. package/dist/types/session/internal/managed-session-scope.d.ts +102 -0
  142. package/dist/types/session/internal/managed-session-storage.d.ts +38 -0
  143. package/dist/types/session/messages.d.ts +16 -0
  144. package/dist/types/session/session-manager-internal.d.ts +14 -0
  145. package/dist/types/session/session-manager.d.ts +82 -12
  146. package/dist/types/session/session-storage.d.ts +43 -3
  147. package/dist/types/session/volatile-context-pruning.d.ts +15 -0
  148. package/dist/types/skill-state/{deep-interview-mutation-guard.d.ts → workflow-mutation-guard.d.ts} +24 -8
  149. package/dist/types/skill-state/workflow-state-contract.d.ts +1 -3
  150. package/dist/types/slash-commands/builtin-registry.d.ts +4 -1
  151. package/dist/types/system-prompt.d.ts +2 -11
  152. package/dist/types/task/executor.d.ts +3 -1
  153. package/dist/types/thinking.d.ts +7 -0
  154. package/dist/types/tool-discovery/tool-index.d.ts +1 -50
  155. package/dist/types/tools/ask.d.ts +64 -2
  156. package/dist/types/tools/bash.d.ts +3 -0
  157. package/dist/types/tools/browser/dead-tab-recovery.d.ts +24 -0
  158. package/dist/types/tools/browser/tab-supervisor.d.ts +21 -0
  159. package/dist/types/tools/cron.d.ts +2 -0
  160. package/dist/types/tools/fetch.d.ts +1 -0
  161. package/dist/types/tools/index.d.ts +30 -15
  162. package/dist/types/tools/output-meta.d.ts +1 -0
  163. package/dist/types/tools/read.d.ts +3 -0
  164. package/dist/types/tools/search-tool-bm25.d.ts +3 -4
  165. package/dist/types/tools/subagent-render.d.ts +1 -0
  166. package/dist/types/tools/subagent.d.ts +6 -1
  167. package/dist/types/utils/event-bus.d.ts +1 -1
  168. package/dist/types/web/search/index.d.ts +3 -1
  169. package/package.json +21 -9
  170. package/scripts/build-sdk-package-smoke.ts +39 -1
  171. package/scripts/generate-hotkeys-docs.ts +74 -0
  172. package/scripts/generate-sdk-operation-inventory.ts +64 -20
  173. package/scripts/verify-gjc-sdk-canonicalization.ts +182 -2
  174. package/src/async/job-manager.ts +455 -69
  175. package/src/cli/config-cli.ts +75 -2
  176. package/src/cli/notify-cli.ts +92 -26
  177. package/src/cli/session-picker.ts +20 -3
  178. package/src/cli/setup-cli.ts +1 -1
  179. package/src/cli.ts +1 -1
  180. package/src/commands/config.ts +1 -1
  181. package/src/commands/harness.ts +34 -106
  182. package/src/commands/launch.ts +6 -4
  183. package/src/commands/mcp-serve.ts +85 -8
  184. package/src/commands/mcp.ts +2 -2
  185. package/src/commands/notify.ts +8 -4
  186. package/src/commands/sdk.ts +61 -40
  187. package/src/commands/ultragoal.ts +4 -1
  188. package/src/config/atomic-yaml-patch.ts +80 -13
  189. package/src/config/file-lock.ts +105 -43
  190. package/src/config/keybindings.ts +64 -20
  191. package/src/config/model-auth.ts +9 -0
  192. package/src/config/model-bindings-applier.ts +139 -0
  193. package/src/config/model-discovery-manager.ts +224 -0
  194. package/src/config/model-equivalence.ts +66 -0
  195. package/src/config/model-registry.ts +169 -302
  196. package/src/config/model-resolver.ts +361 -369
  197. package/src/config/models-config-schema.ts +5 -4
  198. package/src/config/settings-schema.ts +159 -2
  199. package/src/config/settings.ts +458 -482
  200. package/src/coordinator-mcp/policy.ts +76 -11
  201. package/src/coordinator-mcp/question-gate-codec.ts +274 -0
  202. package/src/coordinator-mcp/question-state.ts +778 -0
  203. package/src/coordinator-mcp/server.ts +1702 -238
  204. package/src/debug/index.ts +1 -1
  205. package/src/defaults/gjc/skills/deep-interview/SKILL.md +13 -1
  206. package/src/defaults/gjc/skills/team/SKILL.md +16 -0
  207. package/src/discovery/helpers.ts +1 -50
  208. package/src/edit/index.ts +64 -0
  209. package/src/eval/py/env.ts +26 -0
  210. package/src/eval/py/kernel.ts +5 -3
  211. package/src/extensibility/extensions/loader.ts +9 -1
  212. package/src/extensibility/extensions/runner.ts +12 -3
  213. package/src/extensibility/extensions/types.ts +39 -1
  214. package/src/extensibility/gjc-plugins/installer.ts +16 -16
  215. package/src/extensibility/hooks/runner.ts +2 -2
  216. package/src/extensibility/runtime-skill-discovery.ts +33 -13
  217. package/src/extensibility/shared-events.ts +2 -0
  218. package/src/gjc-runtime/deep-interview-recorder.ts +63 -3
  219. package/src/gjc-runtime/deep-interview-runtime.ts +76 -27
  220. package/src/gjc-runtime/deep-interview-state.ts +317 -5
  221. package/src/gjc-runtime/goal-mode-request.ts +55 -8
  222. package/src/gjc-runtime/launch-tmux.ts +2 -5
  223. package/src/gjc-runtime/launch-worktree.ts +5 -5
  224. package/src/gjc-runtime/linux-proc.ts +71 -0
  225. package/src/gjc-runtime/ralplan-runtime.ts +74 -90
  226. package/src/gjc-runtime/session-resolution.ts +18 -3
  227. package/src/gjc-runtime/session-state-sidecar.ts +6 -107
  228. package/src/gjc-runtime/state-migrations.ts +1 -1
  229. package/src/gjc-runtime/state-runtime.ts +320 -249
  230. package/src/gjc-runtime/state-writer.ts +36 -40
  231. package/src/gjc-runtime/team-launch.ts +319 -0
  232. package/src/gjc-runtime/team-notify.ts +326 -0
  233. package/src/gjc-runtime/team-runtime.ts +1099 -1482
  234. package/src/gjc-runtime/team-store.ts +828 -0
  235. package/src/gjc-runtime/team-workers.ts +962 -0
  236. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +2 -11
  237. package/src/gjc-runtime/tmux-owner-isolation.ts +14 -9
  238. package/src/gjc-runtime/tmux-sessions.ts +4 -21
  239. package/src/gjc-runtime/ultragoal-change-set.ts +172 -0
  240. package/src/gjc-runtime/ultragoal-evidence.ts +874 -0
  241. package/src/gjc-runtime/ultragoal-guard.ts +83 -12
  242. package/src/gjc-runtime/ultragoal-pipeline.ts +306 -0
  243. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +102 -1
  244. package/src/gjc-runtime/ultragoal-runtime.ts +156 -1319
  245. package/src/gjc-runtime/workflow-cli-common.ts +30 -0
  246. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  247. package/src/gjc-runtime/workflow-manifest.ts +18 -0
  248. package/src/goals/continuation-timeout-guard.ts +79 -0
  249. package/src/goals/runtime.ts +7 -5
  250. package/src/goals/state.ts +18 -0
  251. package/src/harness-control-plane/lifecycle-reconciliation.ts +64 -0
  252. package/src/harness-control-plane/phase-rollup.ts +4 -7
  253. package/src/harness-control-plane/receipt-ingest.ts +5 -0
  254. package/src/harness-control-plane/receipt-spool.ts +51 -7
  255. package/src/harness-control-plane/session-lease.ts +12 -130
  256. package/src/hindsight/backend.ts +26 -27
  257. package/src/hindsight/state.ts +101 -32
  258. package/src/hooks/skill-state.ts +17 -11
  259. package/src/internal-urls/docs-index.generated.ts +21 -18
  260. package/src/lsp/client.ts +1 -1
  261. package/src/lsp/config.ts +145 -26
  262. package/src/lsp/index.ts +1 -1
  263. package/src/lsp/lspmux.ts +39 -10
  264. package/src/lsp/path-trust.ts +81 -0
  265. package/src/main.ts +28 -8
  266. package/src/memories/index.ts +1 -0
  267. package/src/memories/storage.ts +6 -2
  268. package/src/modes/action-registry.ts +165 -0
  269. package/src/modes/components/chrome.ts +40 -0
  270. package/src/modes/components/command-palette.ts +168 -0
  271. package/src/modes/components/custom-editor.ts +1 -1
  272. package/src/modes/components/custom-provider-wizard.ts +51 -14
  273. package/src/modes/components/index.ts +2 -1
  274. package/src/modes/components/pet-selector.ts +11 -17
  275. package/src/modes/components/plan-preview-overlay.ts +295 -0
  276. package/src/modes/components/queue-mode-selector.ts +10 -27
  277. package/src/modes/components/queue-pane.ts +79 -0
  278. package/src/modes/components/read-tool-group.ts +13 -0
  279. package/src/modes/components/session-observer-overlay.ts +207 -772
  280. package/src/modes/components/sessions-dashboard.ts +181 -0
  281. package/src/modes/components/settings-selector.ts +1 -1
  282. package/src/modes/components/show-images-selector.ts +10 -24
  283. package/src/modes/components/status-line/types.ts +1 -1
  284. package/src/modes/components/status-line.ts +1 -969
  285. package/src/modes/components/tasks-pane.ts +73 -0
  286. package/src/modes/components/theme-selector.ts +11 -31
  287. package/src/modes/components/tool-execution.ts +22 -0
  288. package/src/modes/components/tool-status-header.ts +1056 -0
  289. package/src/modes/components/transcript-viewer-overlay.ts +384 -0
  290. package/src/modes/controllers/btw-controller.ts +1 -0
  291. package/src/modes/controllers/command-controller.ts +9 -15
  292. package/src/modes/controllers/event-controller.ts +3 -2
  293. package/src/modes/controllers/extension-ui-controller.ts +72 -25
  294. package/src/modes/controllers/goal-mode-controller.ts +474 -0
  295. package/src/modes/controllers/input-controller.ts +764 -229
  296. package/src/modes/controllers/mode-gate.ts +24 -0
  297. package/src/modes/controllers/plan-mode-controller.ts +517 -0
  298. package/src/modes/controllers/selector-controller.ts +225 -10
  299. package/src/modes/controllers/stt-controller.ts +125 -0
  300. package/src/modes/interactive-mode.ts +302 -1151
  301. package/src/modes/jobs-observer.ts +3 -0
  302. package/src/modes/runtime-init.ts +8 -0
  303. package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -106
  304. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +78 -19
  305. package/src/modes/shared/agent-wire/workflow-gate-types.ts +177 -0
  306. package/src/modes/tasks-aggregator.ts +199 -0
  307. package/src/modes/transcript-item-registry.ts +229 -0
  308. package/src/modes/types.ts +47 -20
  309. package/src/modes/utils/ui-helpers.ts +17 -9
  310. package/src/prompts/agent-fragments/ralplan-persistence.md +6 -0
  311. package/src/prompts/agent-fragments/restricted-bash.md +1 -0
  312. package/src/prompts/agents/architect.md +2 -8
  313. package/src/prompts/agents/critic.md +2 -8
  314. package/src/prompts/agents/executor.md +3 -0
  315. package/src/prompts/agents/planner.md +4 -12
  316. package/src/prompts/system/custom-system-prompt.md +1 -23
  317. package/src/prompts/system/plan-mode-approved.md +8 -0
  318. package/src/prompts/system/system-prompt.md +17 -96
  319. package/src/prompts/tools/bash.md +4 -4
  320. package/src/prompts/tools/browser.md +15 -52
  321. package/src/prompts/tools/find.md +2 -2
  322. package/src/prompts/tools/hashline.md +13 -55
  323. package/src/prompts/tools/read.md +3 -6
  324. package/src/prompts/tools/search-tool-bm25.md +0 -2
  325. package/src/prompts/tools/search.md +2 -4
  326. package/src/prompts/tools/skill-discovery.md +1 -2
  327. package/src/prompts/tools/task.md +1 -1
  328. package/src/runtime-mcp/manager.ts +1 -1
  329. package/src/sdk/broker/broker.ts +206 -78
  330. package/src/sdk/broker/discovery.ts +21 -2
  331. package/src/sdk/broker/ensure.ts +85 -25
  332. package/src/sdk/broker/lifecycle-codec.ts +11 -0
  333. package/src/sdk/broker/lifecycle-ledger.ts +371 -74
  334. package/src/sdk/broker/lifecycle.ts +1399 -142
  335. package/src/sdk/broker/process-incarnation.ts +5 -13
  336. package/src/sdk/broker/session-index.ts +145 -56
  337. package/src/sdk/broker/state-version.ts +21 -0
  338. package/src/sdk/broker/transport.ts +1 -1
  339. package/src/sdk/bus/chat-daemon-cli.ts +2 -0
  340. package/src/sdk/bus/chat-daemon-control.ts +8 -28
  341. package/src/sdk/bus/chat-effect-journal.ts +14 -4
  342. package/src/sdk/bus/config-commands.ts +5 -4
  343. package/src/sdk/bus/config.ts +98 -0
  344. package/src/sdk/bus/conversation-store.ts +5 -29
  345. package/src/sdk/bus/daemon-paths.ts +2 -0
  346. package/src/sdk/bus/discord-daemon.ts +25 -8
  347. package/src/sdk/bus/html-format.ts +20 -3
  348. package/src/sdk/bus/index.ts +834 -113
  349. package/src/sdk/bus/lifecycle-control-runtime.ts +91 -33
  350. package/src/sdk/bus/native-runtime-compatibility.ts +1 -1
  351. package/src/sdk/bus/natives-augment.d.ts +26 -1
  352. package/src/sdk/bus/notification-service.ts +168 -20
  353. package/src/sdk/bus/rate-limit-pool.ts +58 -14
  354. package/src/sdk/bus/recent-activity.ts +188 -74
  355. package/src/sdk/bus/rich-document.ts +258 -0
  356. package/src/sdk/bus/rich-render.ts +62 -70
  357. package/src/sdk/bus/telegram-daemon-cli.ts +15 -59
  358. package/src/sdk/bus/telegram-daemon-contract.ts +9 -4
  359. package/src/sdk/bus/telegram-daemon.ts +1491 -247
  360. package/src/sdk/bus/threaded-inbound.ts +8 -3
  361. package/src/sdk/bus/threaded-render.ts +57 -1
  362. package/src/sdk/bus/topic-registry.ts +120 -13
  363. package/src/sdk/host/control/dispatch.ts +14 -2
  364. package/src/sdk/host/control/operations.ts +6 -1
  365. package/src/sdk/host/host.ts +31 -1
  366. package/src/sdk/host/query/handlers.ts +20 -22
  367. package/src/sdk/host/query/revision-store.ts +80 -57
  368. package/src/sdk/index.ts +1 -0
  369. package/src/sdk/protocol/operation-inventory.generated.json +943 -1907
  370. package/src/sdk/session-directory.ts +141 -0
  371. package/src/sdk/session.ts +458 -479
  372. package/src/session/agent-session.ts +1901 -637
  373. package/src/session/agent-storage.ts +18 -0
  374. package/src/session/cache-economics.ts +25 -8
  375. package/src/session/fallback-chain-controller.ts +53 -3
  376. package/src/session/internal/managed-session-scope.ts +2226 -0
  377. package/src/session/internal/managed-session-storage.ts +453 -0
  378. package/src/session/messages.ts +91 -2
  379. package/src/session/session-manager-internal.ts +33 -0
  380. package/src/session/session-manager.ts +1112 -333
  381. package/src/session/session-storage.ts +444 -39
  382. package/src/session/volatile-context-pruning.ts +78 -0
  383. package/src/setup/credential-auto-import.ts +2 -1
  384. package/src/setup/hermes/templates/operator-instructions.v1.md +1 -1
  385. package/src/setup/provider-onboarding.ts +36 -61
  386. package/src/setup/provider-presets.json +5 -0
  387. package/src/skill-state/active-state.ts +3 -13
  388. package/src/skill-state/{deep-interview-mutation-guard.ts → workflow-mutation-guard.ts} +47 -29
  389. package/src/skill-state/workflow-state-contract.ts +6 -41
  390. package/src/slash-commands/builtin-registry.ts +46 -11
  391. package/src/system-prompt.ts +37 -44
  392. package/src/task/agents.ts +19 -1
  393. package/src/task/executor.ts +90 -6
  394. package/src/task/index.ts +62 -37
  395. package/src/thinking.ts +21 -6
  396. package/src/tool-discovery/tool-index.ts +2 -134
  397. package/src/tools/ask.ts +157 -21
  398. package/src/tools/ast-edit.ts +1 -1
  399. package/src/tools/bash.ts +38 -0
  400. package/src/tools/browser/dead-tab-recovery.ts +78 -0
  401. package/src/tools/browser/tab-supervisor.ts +282 -36
  402. package/src/tools/computer.ts +10 -3
  403. package/src/tools/cron.ts +30 -16
  404. package/src/tools/eval.ts +1 -2
  405. package/src/tools/fetch.ts +11 -4
  406. package/src/tools/index.ts +76 -24
  407. package/src/tools/output-meta.ts +13 -5
  408. package/src/tools/read.ts +37 -1
  409. package/src/tools/resource-gc.ts +2 -2
  410. package/src/tools/search-tool-bm25.ts +11 -74
  411. package/src/tools/subagent-render.ts +19 -8
  412. package/src/tools/subagent.ts +84 -32
  413. package/src/utils/event-bus.ts +9 -4
  414. package/src/web/search/index.ts +3 -3
  415. package/dist/types/discovery/plugin-dir-roots.d.ts +0 -15
  416. package/dist/types/runtime-mcp/discoverable-tool-metadata.d.ts +0 -7
  417. package/src/discovery/plugin-dir-roots.ts +0 -28
  418. package/src/runtime-mcp/discoverable-tool-metadata.ts +0 -25
@@ -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 ?? "",
@@ -941,23 +1117,96 @@ type PairedChatPrivacy = "private" | "non-private" | "indeterminate";
941
1117
 
942
1118
  export type TelegramUpdateOutcome = "consumed" | "retry";
943
1119
 
1120
+ export type TelegramPollResult =
1121
+ | { kind: "success"; updateCount: number }
1122
+ | { kind: "aborted" }
1123
+ | { kind: "getUpdates_failed"; error: string }
1124
+ | { kind: "api_failure"; errorCode?: number; description: string }
1125
+ | { kind: "conflict"; description: string; backoffMs: number };
1126
+
944
1127
  export interface TelegramUpdatePollerOptions {
945
1128
  botApi: BotApi;
946
1129
  runtime: NotificationOperatorRuntime;
947
1130
  backoff: OperatorBackoffPolicy;
948
1131
  processUpdate: (update: unknown) => Promise<TelegramUpdateOutcome>;
1132
+ health?: TelegramPollHealth;
1133
+ }
1134
+
1135
+ type TelegramPollHealthStatus = "healthy" | "getUpdates_failed" | "api_failure" | "conflict";
1136
+
1137
+ export class TelegramPollHealth {
1138
+ #status: TelegramPollHealthStatus = "healthy";
1139
+ #suppressedCount = 0;
1140
+
1141
+ record(result: TelegramPollResult): void {
1142
+ if (result.kind === "aborted") return;
1143
+ if (result.kind === "success") {
1144
+ if (this.#status !== "healthy") {
1145
+ logger.info("notifications daemon: Telegram getUpdates recovered", {
1146
+ from: this.#status,
1147
+ suppressedCount: this.#suppressedCount,
1148
+ updateCount: result.updateCount,
1149
+ });
1150
+ }
1151
+ this.#status = "healthy";
1152
+ this.#suppressedCount = 0;
1153
+ return;
1154
+ }
1155
+
1156
+ const previousStatus = this.#status;
1157
+ if (previousStatus === result.kind) {
1158
+ this.#suppressedCount += 1;
1159
+ return;
1160
+ }
1161
+
1162
+ const suppressedCount = this.#suppressedCount;
1163
+ this.#status = result.kind;
1164
+ this.#suppressedCount = 0;
1165
+ if (result.kind === "conflict") {
1166
+ logger.error("notifications daemon: Telegram getUpdates 409 conflict", {
1167
+ description: result.description,
1168
+ backoffMs: result.backoffMs,
1169
+ previousStatus,
1170
+ suppressedCount,
1171
+ });
1172
+ return;
1173
+ }
1174
+ if (result.kind === "api_failure") {
1175
+ logger.error("notifications daemon: Telegram getUpdates API failed", {
1176
+ errorCode: result.errorCode,
1177
+ description: result.description,
1178
+ previousStatus,
1179
+ suppressedCount,
1180
+ });
1181
+ return;
1182
+ }
1183
+
1184
+ logger.error("notifications daemon: getUpdates failed", {
1185
+ error: result.error,
1186
+ previousStatus,
1187
+ suppressedCount,
1188
+ });
1189
+ }
949
1190
  }
950
1191
 
951
1192
  /** Owns getUpdates offset, conflict backoff, and per-update error isolation. */
952
1193
  export class TelegramUpdatePoller {
953
1194
  #offset = 0;
954
1195
  #opts: TelegramUpdatePollerOptions;
1196
+ #health: TelegramPollHealth;
955
1197
 
956
1198
  constructor(opts: TelegramUpdatePollerOptions) {
957
1199
  this.#opts = opts;
1200
+ this.#health = opts.health ?? new TelegramPollHealth();
958
1201
  }
959
1202
 
960
1203
  async pollOnce(signal?: AbortSignal): Promise<number> {
1204
+ const result = await this.pollOnceResult(signal);
1205
+ this.#health.record(result);
1206
+ return result.kind === "success" ? result.updateCount : 0;
1207
+ }
1208
+
1209
+ async pollOnceResult(signal?: AbortSignal): Promise<TelegramPollResult> {
961
1210
  let body: {
962
1211
  ok?: boolean;
963
1212
  error_code?: number;
@@ -972,24 +1221,37 @@ export class TelegramUpdatePoller {
972
1221
  )) as typeof body;
973
1222
  } catch (err) {
974
1223
  // A cooperative stop aborts the in-flight long poll; treat as a clean wake.
975
- if (isAbortError(err)) return 0;
1224
+ if (isAbortError(err)) return { kind: "aborted" };
976
1225
  // A transient Telegram API failure must never crash the daemon.
977
- logger.error("notifications daemon: getUpdates failed", { error: sanitizeDiagnostic(String(err)) });
978
1226
  await this.#opts.runtime.sleep(POLL_BACKOFF_MS, signal);
979
- return 0;
1227
+ return { kind: "getUpdates_failed", error: sanitizeDiagnostic(String(err)) };
980
1228
  }
981
1229
  // Telegram allows only one active getUpdates poller per bot. A 409 means
982
1230
  // another poller is live; back off boundedly instead of hot-looping.
983
1231
  if (body && body.ok === false && (body.error_code === 409 || /409|conflict/i.test(body.description ?? ""))) {
984
1232
  const backoffMs = this.#opts.backoff.next();
985
- logger.error(
986
- `notifications daemon: Telegram getUpdates 409 conflict (${body.description ?? "no description"}); backing off ${backoffMs}ms`,
987
- );
988
1233
  await this.#opts.runtime.sleep(backoffMs, signal);
989
- return 0;
1234
+ return { kind: "conflict", description: sanitizeDiagnostic(body.description ?? "no description"), backoffMs };
1235
+ }
1236
+ if (body?.ok !== true || !Array.isArray(body.result)) {
1237
+ await this.#opts.runtime.sleep(POLL_BACKOFF_MS, signal);
1238
+ return {
1239
+ kind: "api_failure",
1240
+ errorCode: typeof body?.error_code === "number" ? body.error_code : undefined,
1241
+ description: sanitizeDiagnostic(body?.description ?? "Malformed getUpdates response"),
1242
+ };
990
1243
  }
991
1244
  this.#opts.backoff.reset();
992
- for (const update of body.result ?? []) {
1245
+ let malformedSeen = false;
1246
+ for (const update of body.result) {
1247
+ // A single malformed update_id must not wedge the poller. Skip the
1248
+ // bad entry (surfaced below as an api_failure health signal) while
1249
+ // still processing valid updates and advancing the offset past them,
1250
+ // so one poisoned item cannot stall an otherwise-valid stream.
1251
+ if (!Number.isSafeInteger(update?.update_id)) {
1252
+ malformedSeen = true;
1253
+ continue;
1254
+ }
993
1255
  try {
994
1256
  const outcome = await this.#opts.processUpdate(update);
995
1257
  if (outcome === "retry") {
@@ -998,11 +1260,17 @@ export class TelegramUpdatePoller {
998
1260
  }
999
1261
  this.#offset = update.update_id + 1;
1000
1262
  } catch (err) {
1001
- logger.error("notifications daemon: handleTelegramUpdate failed", { error: String(err) });
1263
+ logger.error("notifications daemon: handleTelegramUpdate failed", {
1264
+ error: sanitizeDiagnostic(String(err)),
1265
+ });
1002
1266
  this.#offset = update.update_id + 1;
1003
1267
  }
1004
1268
  }
1005
- return body.result?.length ?? 0;
1269
+ if (malformedSeen) {
1270
+ await this.#opts.runtime.sleep(POLL_BACKOFF_MS, signal);
1271
+ return { kind: "api_failure", description: "Malformed getUpdates response" };
1272
+ }
1273
+ return { kind: "success", updateCount: body.result.length };
1006
1274
  }
1007
1275
  }
1008
1276
 
@@ -1039,6 +1307,7 @@ export interface TelegramDaemonOptions {
1039
1307
  clearTimeoutImpl?: typeof clearTimeout;
1040
1308
  setIntervalImpl?: typeof setInterval;
1041
1309
  clearIntervalImpl?: typeof clearInterval;
1310
+ btw?: { enabled: boolean };
1042
1311
  idleTimeoutMs?: number;
1043
1312
  scanIntervalMs?: number;
1044
1313
  pid?: number;
@@ -1082,10 +1351,13 @@ interface SessionSocket {
1082
1351
  logicalSessionId: string;
1083
1352
  token: string;
1084
1353
  endpointKey: string;
1354
+ endpointDigest: string;
1355
+ hostGeneration: number;
1085
1356
  ws: WebSocket;
1086
1357
  pending: Map<string, { sessionId: string; actionId: string }>;
1087
1358
  /** True once the server advertised the `client_ping_pong` capability. */
1088
1359
  capable: boolean;
1360
+ ephemeralCapable: boolean;
1089
1361
  /** Timestamp (via opts.now) of the last received pong; seeds the TTL window. */
1090
1362
  lastPongAt: number;
1091
1363
  /** Nonce of the most recent in-flight ping, if any. */
@@ -1113,6 +1385,12 @@ interface RenderedModelChoice {
1113
1385
  label: string;
1114
1386
  }
1115
1387
 
1388
+ interface TopicAuthorityLease {
1389
+ sessionId: string;
1390
+ topicId: string;
1391
+ authorityEpoch: number;
1392
+ }
1393
+
1116
1394
  interface PendingThreadedFrame {
1117
1395
  send: ThreadedSend;
1118
1396
  msg: Record<string, unknown>;
@@ -1122,6 +1400,15 @@ type SelectedAckOutcome =
1122
1400
  | { status: "delivered"; messageId: number }
1123
1401
  | { status: "failed"; reason: "route_missing" | "expired" | "cancelled" | "telegram_rejected" }
1124
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
+ }
1125
1412
 
1126
1413
  interface SelectedAckQueueItem {
1127
1414
  pendingKey: string;
@@ -1137,8 +1424,92 @@ interface SelectedAckQueueItem {
1137
1424
 
1138
1425
  interface TelegramQueuePayload {
1139
1426
  send: ThreadedSend;
1140
- topicId?: string;
1427
+ topicLease?: TopicAuthorityLease;
1141
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
+ }
1142
1513
  }
1143
1514
 
1144
1515
  export class TelegramNotificationDaemon {
@@ -1157,12 +1528,22 @@ export class TelegramNotificationDaemon {
1157
1528
  private running = false;
1158
1529
  private readonly fsImpl: TelegramDaemonFs;
1159
1530
  private readonly botApi: BotApi;
1531
+ private readonly effects = new TelegramEffectSupervisor();
1160
1532
  private readonly topics = new TopicRegistry();
1161
1533
  /** Serializes registry snapshots so an older atomic write cannot overwrite newer rename state. */
1162
1534
  private topicsPersistQueue: Promise<void> = Promise.resolve();
1163
1535
  /** Daemon edit attempts that can race an accepted user service message. */
1164
1536
  private readonly daemonRenameAttempts = new Map<string, number>();
1165
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();
1166
1547
  private readonly pool: RateLimitPool<TelegramQueuePayload>;
1167
1548
  private readonly poller: TelegramUpdatePoller;
1168
1549
  private readonly dispatchState = new TelegramEventDispatchState();
@@ -1245,11 +1626,18 @@ export class TelegramNotificationDaemon {
1245
1626
  * ~25s getUpdates timeout. Safe to call from a signal handler.
1246
1627
  */
1247
1628
  requestStop(_reason?: "reload" | "stop" | "signal"): void {
1629
+ this.effects.beginShutdown();
1248
1630
  for (const item of new Set(this.selectedAckPending.values())) {
1249
1631
  if (item.state === "queued") this.pool.removeById(item.itemId);
1250
1632
  else item.controller?.abort();
1251
1633
  this.finishSelectedAck(item, { status: "unknown", reason: "shutdown" });
1252
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();
1253
1641
  this.runtime.requestStop();
1254
1642
  this.running = false;
1255
1643
  }
@@ -1276,8 +1664,14 @@ export class TelegramNotificationDaemon {
1276
1664
  const deps = (this.opts.createLifecycleOrchestratorDeps ?? buildOrchestratorDeps)({
1277
1665
  pairedChatId: this.opts.chatId,
1278
1666
  agentNotificationsDir: daemonPaths(agentDir).dir,
1667
+ agentDir,
1279
1668
  sessionsRoot: path.join(agentDir, "sessions"),
1280
- 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
+ ),
1281
1675
  });
1282
1676
  // Register the lifecycle-request handler BEFORE start(): the native
1283
1677
  // control server captures the callback at start time, so wiring must
@@ -1518,15 +1912,22 @@ export class TelegramNotificationDaemon {
1518
1912
  return true;
1519
1913
  }
1520
1914
  if (parsed.kind === "recent") {
1521
- const recent = listRecentSessions({
1522
- sessionsRoot: path.join(this.opts.settings.getAgentDir(), "sessions"),
1915
+ const recent = await listRecentSessions({
1916
+ cwd: process.cwd(),
1917
+ agentDir: this.opts.settings.getAgentDir(),
1523
1918
  limit: 10,
1524
1919
  includeInternal: false,
1920
+ allWorkspaces: true,
1525
1921
  });
1526
- const body = recent.length
1527
- ? recent.map(e => `• ${code(e.sessionId)}${e.path ? ` (${code(e.path)})` : ""}`).join("\n")
1528
- : "No recent sessions.";
1529
- 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
+ );
1530
1931
  return true;
1531
1932
  }
1532
1933
 
@@ -1565,7 +1966,7 @@ export class TelegramNotificationDaemon {
1565
1966
  this.fsImpl = opts.fs ?? nodeFs;
1566
1967
  this.replyStore = new ReplySentStore({ agentDir: opts.settings.getAgentDir(), fs: opts.fs });
1567
1968
  this.aliasTable = createAliasTable();
1568
- this.botApi =
1969
+ const rawBotApi =
1569
1970
  opts.botApi ??
1570
1971
  new TelegramBotTransport({
1571
1972
  botToken: opts.botToken,
@@ -1573,6 +1974,9 @@ export class TelegramNotificationDaemon {
1573
1974
  fetchImpl: opts.fetchImpl,
1574
1975
  setTimeoutImpl: opts.setTimeoutImpl,
1575
1976
  });
1977
+ this.botApi = {
1978
+ call: (method, body, callOpts) => this.effects.call(rawBotApi, method, body, callOpts),
1979
+ };
1576
1980
  this.runtime = new NotificationOperatorRuntime({
1577
1981
  now: opts.now,
1578
1982
  setTimeoutImpl: opts.setTimeoutImpl,
@@ -1581,7 +1985,7 @@ export class TelegramNotificationDaemon {
1581
1985
  clearIntervalImpl: opts.clearIntervalImpl,
1582
1986
  });
1583
1987
  this.sessionRouter = this.createSessionRouter();
1584
- this.pool = new RateLimitPool<{ send: ThreadedSend; topicId?: string }>({ now: opts.now });
1988
+ this.pool = new RateLimitPool<TelegramQueuePayload>({ now: opts.now });
1585
1989
  this.poller = new TelegramUpdatePoller({
1586
1990
  botApi: this.botApi,
1587
1991
  runtime: this.runtime,
@@ -1597,6 +2001,7 @@ export class TelegramNotificationDaemon {
1597
2001
  matches: msg => msg.type === "hello",
1598
2002
  handle: (session, msg) => {
1599
2003
  const caps = Array.isArray(msg.capabilities) ? msg.capabilities : [];
2004
+ if (caps.includes("ephemeral_turn_v1")) session.ephemeralCapable = true;
1600
2005
  if (caps.includes(CLIENT_PING_PONG_CAPABILITY)) {
1601
2006
  session.capable = true;
1602
2007
  this.startLiveness(session);
@@ -1636,8 +2041,11 @@ export class TelegramNotificationDaemon {
1636
2041
  finishImmediately({ status: "failed", reason: "route_missing" });
1637
2042
  return;
1638
2043
  }
1639
- const topicId = this.topics.get(session.sessionId)?.topicId;
1640
- 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
+ ) {
1641
2049
  finishImmediately({ status: "failed", reason: "route_missing" });
1642
2050
  return;
1643
2051
  }
@@ -1666,14 +2074,14 @@ export class TelegramNotificationDaemon {
1666
2074
  followers: [],
1667
2075
  };
1668
2076
  this.selectedAckPending.set(pendingKey, item);
1669
- this.pool.submit({
2077
+ this.submitPool({
1670
2078
  sessionId: session.sessionId,
1671
2079
  lane: "ask",
1672
2080
  itemId: item.itemId,
1673
2081
  deadlineAt,
1674
2082
  payload: {
1675
2083
  send: { method: "sendMessage", lane: "ask", text: "Selected!" },
1676
- topicId,
2084
+ topicLease,
1677
2085
  selectedAck: item,
1678
2086
  },
1679
2087
  });
@@ -1770,6 +2178,7 @@ export class TelegramNotificationDaemon {
1770
2178
  handle: async session => {
1771
2179
  this.busy.delete(session.sessionId);
1772
2180
  this.closedEndpointKeys.set(session.sessionId, session.endpointKey);
2181
+ await this.#terminalizeBtwTurnsForSession(session, true);
1773
2182
  await this.deleteTopic(session.sessionId);
1774
2183
  this.dropSession(session, "session_closed");
1775
2184
  },
@@ -1831,7 +2240,7 @@ export class TelegramNotificationDaemon {
1831
2240
  logger.warn(`notifications: failed to persist Telegram update id ${updateId}: ${String(err)}`);
1832
2241
  }
1833
2242
  }
1834
- private async rememberSeenUpdateIdForUnavailableNotice(updateId: number): Promise<boolean> {
2243
+ private async reserveSeenUpdateId(updateId: number): Promise<boolean> {
1835
2244
  if (!Number.isSafeInteger(updateId) || updateId < 0) return false;
1836
2245
  const candidate = new Set(this.dispatchState.seenUpdateIds);
1837
2246
  candidate.add(updateId);
@@ -1844,7 +2253,7 @@ export class TelegramNotificationDaemon {
1844
2253
  updateIds: [...candidate],
1845
2254
  });
1846
2255
  } catch {
1847
- logger.warn("notifications: unavailable-control notice state publication failed");
2256
+ logger.warn("notifications: Telegram update state publication failed");
1848
2257
  return false;
1849
2258
  }
1850
2259
  this.dispatchState.seenUpdateIds.clear();
@@ -1869,20 +2278,28 @@ export class TelegramNotificationDaemon {
1869
2278
  for (const file of files.filter(item => item.endsWith(".json"))) {
1870
2279
  const sessionId = path.basename(file, ".json");
1871
2280
  endpointSessionIds.add(sessionId);
1872
- if (this.sessions.has(sessionId)) continue;
1873
2281
  try {
1874
2282
  const endpoint = readEndpoint(path.join(dir, file));
1875
- // Skip endpoint files whose owning process is gone or that are
1876
- // explicitly stale (e.g. a hard-closed session): reconnecting
1877
- // would chase a dead, token-bearing record forever. Once the
1878
- // associated topic is past the grace window, reap it through the
1879
- // 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.
1880
2287
  const pidAlive = this.opts.pidAlive ?? defaultPidAlive;
1881
2288
  if (endpoint.stale || (endpoint.pid !== undefined && !pidAlive(endpoint.pid))) {
1882
- 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);
1883
2293
  continue;
1884
2294
  }
2295
+ if (this.topics.clearOrphaned(sessionId)) await this.persistTopics();
1885
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
+ }
1886
2303
  if (this.closedEndpointKeys.get(sessionId) === endpointKey) continue;
1887
2304
  this.closedEndpointKeys.delete(sessionId);
1888
2305
  this.connectSession(sessionId, endpoint.url, endpoint.token);
@@ -1891,8 +2308,10 @@ export class TelegramNotificationDaemon {
1891
2308
  }
1892
2309
  if (allRootsReadable) {
1893
2310
  for (const sessionId of this.topics.sessionIds()) {
1894
- if (!this.sessions.has(sessionId) && !endpointSessionIds.has(sessionId))
1895
- await this.deleteOrphanedTopic(sessionId);
2311
+ if (!this.sessions.has(sessionId) && !endpointSessionIds.has(sessionId)) {
2312
+ this.#terminalizeBtwTurnsForTransportSession(sessionId);
2313
+ await this.observeOrphanedTopic(sessionId);
2314
+ }
1896
2315
  }
1897
2316
  }
1898
2317
  }
@@ -1901,9 +2320,17 @@ export class TelegramNotificationDaemon {
1901
2320
  const WS = this.opts.WebSocketImpl ?? WebSocket;
1902
2321
  const ws = new WS(`${url}/?token=${encodeURIComponent(token)}`);
1903
2322
  const endpointKey = endpointGenerationKey(url, token);
2323
+ const endpointDigest = endpointAuthorityDigest(url, token);
1904
2324
  this.closedEndpointKeys.delete(sessionId);
1905
2325
  const existing = this.sessions.get(sessionId);
1906
- 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
+ }
1907
2334
  this.#clearModelChoiceAliases(sessionId);
1908
2335
 
1909
2336
  const session: SessionSocket = {
@@ -1912,9 +2339,12 @@ export class TelegramNotificationDaemon {
1912
2339
 
1913
2340
  token,
1914
2341
  endpointKey,
2342
+ endpointDigest,
2343
+ hostGeneration: 0,
1915
2344
  ws,
1916
2345
  pending: new Map(),
1917
2346
  capable: false,
2347
+ ephemeralCapable: false,
1918
2348
  lastPongAt: 0,
1919
2349
  awaitingNonce: undefined,
1920
2350
  pingTimer: undefined,
@@ -1936,7 +2366,13 @@ export class TelegramNotificationDaemon {
1936
2366
  JSON.stringify({
1937
2367
  type: "hello",
1938
2368
  protocolVersion: NOTIFICATION_PROTOCOL_VERSION,
1939
- 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
+ ],
1940
2376
  }),
1941
2377
  );
1942
2378
  } catch {}
@@ -2014,6 +2450,11 @@ export class TelegramNotificationDaemon {
2014
2450
  clearIntervalImpl(session.pingTimer);
2015
2451
  session.pingTimer = undefined;
2016
2452
  }
2453
+ if (reason === "socket_closed" || reason === "same_authority_replaced") {
2454
+ this.#invalidateBtwDeliveriesForSession(session);
2455
+ } else {
2456
+ void this.#terminalizeBtwTurnsForSession(session).catch(() => undefined);
2457
+ }
2017
2458
  const isCurrentSession = this.sessions.get(session.sessionId) === session;
2018
2459
  if (isCurrentSession || reason === "session_closed") {
2019
2460
  this.deleteMessageRoutes(session.sessionId);
@@ -2036,6 +2477,280 @@ export class TelegramNotificationDaemon {
2036
2477
  }
2037
2478
  }
2038
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
+
2039
2754
  private deleteMessageRoutes(sessionId: string, actionId?: string): void {
2040
2755
  for (const [messageId, route] of this.messageRoutes.entries()) {
2041
2756
  if (route.sessionId === sessionId && (actionId === undefined || route.actionId === actionId)) {
@@ -2077,6 +2792,14 @@ export class TelegramNotificationDaemon {
2077
2792
  return alias;
2078
2793
  }
2079
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
+
2080
2803
  private static readonly THREADED_FRAMES = new Set([
2081
2804
  "identity_header",
2082
2805
  "context_update",
@@ -2085,9 +2808,11 @@ export class TelegramNotificationDaemon {
2085
2808
  "file_attachment",
2086
2809
  "config_update",
2087
2810
  "control_command_result",
2811
+ "tool_activity",
2812
+ "reasoning_summary",
2088
2813
  ]);
2089
2814
 
2090
- /** 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. */
2091
2816
  #updateLogicalSessionForThreadedFrame(session: SessionSocket, msg: Record<string, unknown>): void {
2092
2817
  if (
2093
2818
  typeof msg.type !== "string" ||
@@ -2098,6 +2823,7 @@ export class TelegramNotificationDaemon {
2098
2823
  (msg.type !== "config_update" && !renderThreadedFrame(msg))
2099
2824
  )
2100
2825
  return;
2826
+ void this.#terminalizeBtwTurnsForSession(session).catch(() => undefined);
2101
2827
  this.#clearModelChoiceAliasesForSocket(session);
2102
2828
  this.#clearModelChoiceAliases(msg.sessionId);
2103
2829
  session.logicalSessionId = msg.sessionId;
@@ -2165,11 +2891,13 @@ export class TelegramNotificationDaemon {
2165
2891
  private topicOwnerForIdentity(msg: { repo?: unknown; branch?: unknown }): string | undefined {
2166
2892
  const identityKey = this.topicIdentityKey(msg);
2167
2893
  const remembered = identityKey ? this.topicOwnerByIdentity.get(identityKey) : undefined;
2168
- 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;
2169
2896
  if (!identityKey) return undefined;
2170
2897
  const base = this.topicIdentityBase(msg);
2171
2898
  for (const sessionId of this.topics.sessionIds()) {
2172
2899
  const topic = this.topics.get(sessionId);
2900
+ if (topic?.authorityState === "delete_pending") continue;
2173
2901
  const nameMatchesLegacyIdentity =
2174
2902
  base !== undefined && (topic?.name === base || topic?.name?.startsWith(`${base} - `));
2175
2903
  if (topic?.identityKey === identityKey || nameMatchesLegacyIdentity) {
@@ -2187,30 +2915,55 @@ export class TelegramNotificationDaemon {
2187
2915
  return !ownerId || ownerId === session.sessionId;
2188
2916
  }
2189
2917
 
2190
- private async submitThreadedFrame(sessionId: string, send: ThreadedSend, topicId: string): Promise<void> {
2191
- this.pool.submit({
2918
+ private async submitThreadedFrame(
2919
+ sessionId: string,
2920
+ send: ThreadedSend,
2921
+ topicLease: TopicAuthorityLease,
2922
+ ): Promise<void> {
2923
+ this.submitPool({
2192
2924
  sessionId,
2193
2925
  lane: send.lane,
2194
2926
  coalesceKey: send.coalesceKey,
2195
- payload: { send, topicId },
2927
+ payload: { send, topicLease },
2196
2928
  });
2197
2929
  await this.flushPool();
2198
2930
  }
2199
2931
 
2200
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> {
2201
2937
  if (!(await this.pairedChatIsPrivate())) return undefined;
2202
- 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
+ );
2203
2954
  }
2204
2955
 
2205
2956
  /** Best-effort re-assertion for a durable user-owned topic name. */
2206
- private async reconcileUserTopicName(sessionId: string, topicId: string): Promise<void> {
2957
+ private async reconcileUserTopicName(topicLease: TopicAuthorityLease): Promise<void> {
2958
+ const { sessionId } = topicLease;
2207
2959
  if ((this.daemonRenameAttempts.get(sessionId) ?? 0) > 0) return;
2208
2960
  let userName = this.topics.userNameToReconcile(sessionId);
2209
2961
  while (userName) {
2210
2962
  try {
2963
+ if (!this.topicLeaseIsCurrent(topicLease)) return;
2211
2964
  const response = await this.botApi.call("editForumTopic", {
2212
2965
  chat_id: this.opts.chatId,
2213
- message_thread_id: Number(topicId),
2966
+ message_thread_id: Number(topicLease.topicId),
2214
2967
  name: userName,
2215
2968
  });
2216
2969
  if (!topicRenameApplied(response)) return;
@@ -2240,11 +2993,11 @@ export class TelegramNotificationDaemon {
2240
2993
  this.pendingThreadedFrames.set(sessionId, frames);
2241
2994
  }
2242
2995
 
2243
- private async flushPendingThreadedFrames(sessionId: string, topicId: string): Promise<void> {
2996
+ private async flushPendingThreadedFrames(sessionId: string, topicLease: TopicAuthorityLease): Promise<void> {
2244
2997
  const frames = this.pendingThreadedFrames.get(sessionId);
2245
2998
  if (!frames || frames.length === 0) return;
2246
2999
  this.pendingThreadedFrames.delete(sessionId);
2247
- 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);
2248
3001
  }
2249
3002
 
2250
3003
  /**
@@ -2256,17 +3009,20 @@ export class TelegramNotificationDaemon {
2256
3009
  private async ensureTopic(sessionId: string, name: string): Promise<string | undefined> {
2257
3010
  if (!(await this.pairedChatIsPrivate())) return undefined;
2258
3011
  const existing = this.topics.get(sessionId);
3012
+ if (existing?.authorityState === "delete_pending") return undefined;
2259
3013
  if (existing) return existing.topicId;
2260
3014
  try {
2261
3015
  const rec = await this.topics.getOrCreateTopic(
2262
3016
  sessionId,
2263
3017
  async () => {
2264
- const res = (await this.botApi.call("createForumTopic", {
3018
+ const res = await this.botApi.call("createForumTopic", {
2265
3019
  chat_id: this.opts.chatId,
2266
3020
  name,
2267
- })) as { result?: { message_thread_id?: number } };
2268
- const tid = res.result?.message_thread_id;
2269
- 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");
2270
3026
  return String(tid);
2271
3027
  },
2272
3028
  this.opts.now,
@@ -2278,50 +3034,62 @@ export class TelegramNotificationDaemon {
2278
3034
  );
2279
3035
  await this.persistTopics();
2280
3036
  return rec.topicId;
2281
- } catch {
2282
- 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;
2283
3043
  }
2284
3044
  }
2285
3045
 
2286
3046
  private topicPastOrphanGrace(sessionId: string): boolean {
2287
- const record = this.topics.get(sessionId);
2288
- 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;
2289
3049
  }
2290
3050
 
2291
- 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();
2292
3053
  if (!this.topicPastOrphanGrace(sessionId)) return;
2293
3054
  await this.deleteTopic(sessionId);
2294
3055
  }
2295
3056
 
2296
3057
  /** Best-effort delete of a session topic once its local notification endpoint shuts down. */
2297
3058
  private async deleteTopic(sessionId: string): Promise<void> {
2298
- const record = this.topics.get(sessionId);
2299
- 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
+ }
2300
3077
  try {
2301
- // Drop queued sends for this session before deleting the topic; otherwise
2302
- // rate-limited frames can flush later into a deleted topic or across resume.
2303
- const removed = this.pool.removeWhere(item => item.sessionId === sessionId);
2304
- for (const item of removed) {
2305
- if (item.payload.selectedAck)
2306
- this.finishSelectedAck(item.payload.selectedAck, { status: "failed", reason: "cancelled" });
2307
- }
2308
3078
  await this.flushPool();
2309
3079
  const res = (await this.botApi.call("deleteForumTopic", {
2310
3080
  chat_id: this.opts.chatId,
2311
3081
  message_thread_id: Number(record.topicId),
2312
3082
  })) as { ok?: boolean };
2313
- if (res?.ok === false) return;
2314
- this.topics.delete(sessionId);
2315
- for (const k of [...this.liveMessages.keys()]) {
2316
- if (k.startsWith(`${sessionId}:`)) this.liveMessages.delete(k);
2317
- }
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);
2318
3086
  this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
2319
3087
  if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
2320
3088
  });
2321
3089
  this.pendingThreadedFrames.delete(sessionId);
2322
3090
  await this.persistTopics();
2323
3091
  } catch {
2324
- // Best-effort: missing Telegram topic permissions must not stop teardown.
3092
+ // Retain the persisted delete fence on transport ambiguity.
2325
3093
  }
2326
3094
  }
2327
3095
 
@@ -2462,12 +3230,19 @@ export class TelegramNotificationDaemon {
2462
3230
  }
2463
3231
 
2464
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
+
2465
3239
  private async flushPoolInner(): Promise<void> {
2466
3240
  const { granted: batch, expired } = this.pool.drainWithExpired();
2467
3241
  for (const expiredItem of expired) {
2468
3242
  if (expiredItem.payload.selectedAck) {
2469
3243
  this.finishSelectedAck(expiredItem.payload.selectedAck, { status: "failed", reason: "expired" });
2470
3244
  }
3245
+ expiredItem.payload.btwDelivery?.finish("not_delivered");
2471
3246
  }
2472
3247
  // Within a batch a finalized frame supersedes any still-queued live frame for
2473
3248
  // the same streamed message (finalized outranks live), so drop the stale live
@@ -2491,20 +3266,53 @@ export class TelegramNotificationDaemon {
2491
3266
  );
2492
3267
  }
2493
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
+ }
2494
3296
  const selectedAck = item.payload.selectedAck;
2495
3297
  if (selectedAck) {
2496
- const { topicId } = item.payload;
3298
+ const { topicLease } = item.payload;
2497
3299
  selectedAck.state = "dispatching";
2498
3300
  const controller = new AbortController();
2499
3301
  selectedAck.controller = controller;
2500
- const routeAvailable = !topicId || (await this.pairedChatIsPrivate());
2501
- 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
+ }
2502
3308
  if (!routeAvailable) {
2503
3309
  this.finishSelectedAck(selectedAck, { status: "failed", reason: "route_missing" });
3310
+ this.pool.settle(item.itemId!, "rejected");
2504
3311
  continue;
2505
3312
  }
2506
3313
  if (item.deadlineAt !== undefined && item.deadlineAt <= this.runtime.now()) {
2507
3314
  this.finishSelectedAck(selectedAck, { status: "failed", reason: "expired" });
3315
+ this.pool.settle(item.itemId!, "expired");
2508
3316
  continue;
2509
3317
  }
2510
3318
  selectedAck.state = "sending";
@@ -2518,31 +3326,49 @@ export class TelegramNotificationDaemon {
2518
3326
  "sendMessage",
2519
3327
  {
2520
3328
  chat_id: this.opts.chatId,
2521
- ...(topicId ? { message_thread_id: Number(topicId) } : {}),
3329
+ ...(topicLease ? { message_thread_id: Number(topicLease.topicId) } : {}),
2522
3330
  text: "Selected!",
2523
3331
  },
2524
3332
  { signal: controller.signal, noRetry: true },
2525
3333
  )) as { ok?: unknown; result?: { message_id?: unknown } };
3334
+ const messageId = response.result?.message_id;
3335
+ const delivered = response.ok === true && typeof messageId === "number";
2526
3336
  this.finishSelectedAck(
2527
3337
  selectedAck,
2528
- response.ok === true && typeof response.result?.message_id === "number"
2529
- ? { status: "delivered", messageId: response.result.message_id }
2530
- : { status: "failed", reason: "telegram_rejected" },
3338
+ delivered ? { status: "delivered", messageId } : { status: "failed", reason: "telegram_rejected" },
2531
3339
  );
3340
+ this.pool.settle(item.itemId!, delivered ? "accepted" : "rejected");
2532
3341
  } catch {
2533
3342
  this.finishSelectedAck(selectedAck, { status: "unknown", reason: "transport_ambiguous" });
3343
+ this.pool.settle(item.itemId!, "ambiguous");
2534
3344
  } finally {
2535
3345
  (this.opts.clearTimeoutImpl ?? clearTimeout)(timer);
2536
3346
  }
2537
3347
  continue;
2538
3348
  }
2539
- const { send, topicId } = item.payload;
2540
- 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
+ }
2541
3363
  // Threaded topic when available; otherwise deliver flat to the paired chat.
2542
3364
  const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
2543
3365
  const ckey = send.editable ? item.coalesceKey : undefined;
2544
3366
  const editKey = ckey !== undefined ? `${item.sessionId}:${ckey}` : undefined;
2545
- 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";
2546
3372
  try {
2547
3373
  // Draft streaming (opt-in, off by default): stream a live turn frame as a
2548
3374
  // best-effort rich-draft preview, debounced to >=1.5s per session through
@@ -2571,6 +3397,10 @@ export class TelegramNotificationDaemon {
2571
3397
  }
2572
3398
  }
2573
3399
  }
3400
+ if (topicLease && !this.topicLeaseIsCurrent(topicLease)) {
3401
+ disposition = "rejected";
3402
+ continue;
3403
+ }
2574
3404
  if (send.method === "sendPhoto" && send.photoBase64) {
2575
3405
  // Real photo upload (the default botApi multiparts base64 -> file).
2576
3406
  await this.botApi.call("sendPhoto", {
@@ -2607,6 +3437,7 @@ export class TelegramNotificationDaemon {
2607
3437
  // non-editable, HTML-only pool items (rich markers stripped) — same
2608
3438
  // per-token discipline as the non-rich split path.
2609
3439
  const chunks = splitTelegramHtml(send.text!);
3440
+ if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return;
2610
3441
  await this.botApi.call("sendMessage", {
2611
3442
  chat_id: this.opts.chatId,
2612
3443
  ...threadField,
@@ -2614,7 +3445,7 @@ export class TelegramNotificationDaemon {
2614
3445
  parse_mode: TELEGRAM_PARSE_MODE,
2615
3446
  });
2616
3447
  for (let i = 1; i < chunks.length; i++) {
2617
- this.pool.submit({
3448
+ this.submitPool({
2618
3449
  sessionId: item.sessionId,
2619
3450
  lane: item.lane,
2620
3451
  payload: {
@@ -2630,7 +3461,7 @@ export class TelegramNotificationDaemon {
2630
3461
  richDraftMarkdown: undefined,
2631
3462
  richClass: undefined,
2632
3463
  },
2633
- topicId,
3464
+ topicLease,
2634
3465
  },
2635
3466
  });
2636
3467
  }
@@ -2639,6 +3470,7 @@ export class TelegramNotificationDaemon {
2639
3470
  this.botApi,
2640
3471
  { chat_id: this.opts.chatId, ...threadField },
2641
3472
  send,
3473
+ AbortSignal.any([this.#deliveryAbort.signal, AbortSignal.timeout(30_000)]),
2642
3474
  sendHtmlFallback,
2643
3475
  logger,
2644
3476
  );
@@ -2676,7 +3508,7 @@ export class TelegramNotificationDaemon {
2676
3508
  }
2677
3509
  if (edited) {
2678
3510
  firstMessageId = existingId;
2679
- } else {
3511
+ } else if (!topicLease || this.topicLeaseIsCurrent(topicLease)) {
2680
3512
  const res = (await this.botApi.call("sendMessage", {
2681
3513
  chat_id: this.opts.chatId,
2682
3514
  ...threadField,
@@ -2704,7 +3536,7 @@ export class TelegramNotificationDaemon {
2704
3536
  // can never be re-promoted to a duplicate sendRichMessage.
2705
3537
  if (item.lane !== "live") {
2706
3538
  for (let i = 1; i < chunks.length; i++) {
2707
- this.pool.submit({
3539
+ this.submitPool({
2708
3540
  sessionId: item.sessionId,
2709
3541
  lane: item.lane,
2710
3542
  payload: {
@@ -2720,18 +3552,25 @@ export class TelegramNotificationDaemon {
2720
3552
  richDraftMarkdown: undefined,
2721
3553
  richClass: undefined,
2722
3554
  },
2723
- topicId,
3555
+ topicLease,
2724
3556
  },
2725
3557
  });
2726
3558
  }
2727
3559
  }
2728
- if (editKey && ckey !== undefined && firstMessageId !== undefined) {
3560
+ if (editKey && ckey !== undefined && firstMessageId !== undefined && !send.terminal) {
2729
3561
  this.recordLiveMessage(item.sessionId, ckey, firstMessageId);
2730
3562
  }
2731
3563
  }
2732
3564
  }
2733
3565
  } catch {
2734
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);
2735
3574
  }
2736
3575
  }
2737
3576
  }
@@ -2744,9 +3583,17 @@ export class TelegramNotificationDaemon {
2744
3583
  private recordLiveMessage(sessionId: string, coalesceKey: string, messageId: number): void {
2745
3584
  const mapKey = `${sessionId}:${coalesceKey}`;
2746
3585
  const category = coalesceKey.split(":")[0] ?? "";
2747
- const prefix = `${sessionId}:${category}:`;
2748
- for (const k of [...this.liveMessages.keys()]) {
2749
- 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
+ }
2750
3597
  }
2751
3598
  this.liveMessages.set(mapKey, messageId);
2752
3599
  }
@@ -2764,7 +3611,7 @@ export class TelegramNotificationDaemon {
2764
3611
  if (!(await this.pairedChatIsPrivate())) return;
2765
3612
  await this.notifyThreadedFallback();
2766
3613
  if (send.identity && this.flatIdentitySent.has(sessionId)) return;
2767
- this.pool.submit({ sessionId, lane: send.lane, coalesceKey: send.coalesceKey, payload: { send } });
3614
+ this.submitPool({ sessionId, lane: send.lane, coalesceKey: send.coalesceKey, payload: { send } });
2768
3615
  await this.flushPool();
2769
3616
  if (send.identity) this.flatIdentitySent.add(sessionId);
2770
3617
  }
@@ -2832,6 +3679,38 @@ export class TelegramNotificationDaemon {
2832
3679
  private stopFlushTimer(): void {
2833
3680
  this.runtime.stopInterval("telegram-flush");
2834
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
+ }
2835
3714
 
2836
3715
  /** Run a root scan, guarding against overlapping scans from the timer + loop. */
2837
3716
  private async runScan(): Promise<void> {
@@ -2853,8 +3732,9 @@ export class TelegramNotificationDaemon {
2853
3732
 
2854
3733
  /** Send a single `typing` chat action into a busy session's topic (best-effort). */
2855
3734
  private async sendTyping(sessionId: string): Promise<void> {
2856
- const topicId = this.topics.get(sessionId)?.topicId;
2857
- 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;
2858
3738
  try {
2859
3739
  await this.botApi.call("sendChatAction", {
2860
3740
  chat_id: this.opts.chatId,
@@ -2918,7 +3798,8 @@ export class TelegramNotificationDaemon {
2918
3798
  const topicId =
2919
3799
  (await this.existingTopicForPrivateChat(session.sessionId)) ??
2920
3800
  (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
2921
- if (!topicId) return false;
3801
+ const topicLease = await this.topicAuthorityLease(session.sessionId);
3802
+ if (!topicId || !topicLease || topicLease.topicId !== topicId) return false;
2922
3803
 
2923
3804
  // Each logical session owns only its most recently rendered menu.
2924
3805
  this.#clearModelChoiceAliases(logicalSessionId);
@@ -2929,6 +3810,7 @@ export class TelegramNotificationDaemon {
2929
3810
  choices.map(choice => choice.label),
2930
3811
  index => aliases[index]!,
2931
3812
  );
3813
+ if (!this.topicLeaseIsCurrent(topicLease)) return false;
2932
3814
  try {
2933
3815
  const response = await this.botApi.call("sendMessage", {
2934
3816
  chat_id: this.opts.chatId,
@@ -2964,6 +3846,9 @@ export class TelegramNotificationDaemon {
2964
3846
  Number.isSafeInteger(msg.lastSeq) &&
2965
3847
  msg.lastSeq >= 0 &&
2966
3848
  Array.isArray(msg.events);
3849
+ if (replayValid) {
3850
+ session.hostGeneration = msg.generation;
3851
+ }
2967
3852
  const replayed: Record<string, unknown>[] = replayValid
2968
3853
  ? (msg.events as unknown[]).flatMap((event: unknown): Record<string, unknown>[] => {
2969
3854
  if (!event || typeof event !== "object" || Array.isArray(event)) return [];
@@ -3005,8 +3890,12 @@ export class TelegramNotificationDaemon {
3005
3890
  }
3006
3891
  await this.handleSessionMessage(session, frame);
3007
3892
  }
3008
- if (replayValid && this.topics.markReplayCursor(session.sessionId, msg.generation, msg.lastSeq))
3009
- 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
+ }
3010
3899
  return;
3011
3900
  }
3012
3901
  if (msg?.type === "event_replay_result") return;
@@ -3014,25 +3903,213 @@ export class TelegramNotificationDaemon {
3014
3903
  if (await this.sessionRouter.dispatch(session, msg as Record<string, unknown>)) return;
3015
3904
  if (await this.#renderModelChoices(session, msg as Record<string, unknown>)) return;
3016
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
+ }
3017
4091
  if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
3018
4092
  const send = renderThreadedFrame(msg);
3019
4093
  if (!send) return;
3020
4094
  const existingTopic = await this.existingTopicForPrivateChat(session.sessionId);
4095
+ if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
3021
4096
  if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
3022
4097
  this.rememberPendingThreadedFrame(session.sessionId, send, msg as Record<string, unknown>);
3023
4098
  return;
3024
4099
  }
3025
4100
  if (send.identity && !this.sessionCanClaimIdentity(session, msg)) {
3026
4101
  const ownerId = this.topicOwnerForIdentity(msg);
3027
- const ownerTopic = ownerId ? this.topics.get(ownerId) : undefined;
3028
- if (ownerId && ownerId !== session.sessionId && ownerTopic) {
3029
- 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);
3030
4105
  return;
3031
4106
  }
3032
4107
  }
3033
4108
  const topicId =
3034
4109
  existingTopic ?? (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
3035
- 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;
3036
4113
  await this.deliverFlatFallback(session.sessionId, send);
3037
4114
  return;
3038
4115
  }
@@ -3045,7 +4122,7 @@ export class TelegramNotificationDaemon {
3045
4122
  // Explicit Telegram-side user renames own the topic title. Pending user
3046
4123
  // reconciliation runs before daemon identity naming, so retries and daemon
3047
4124
  // restarts cannot silently replace the preserved name.
3048
- await this.reconcileUserTopicName(session.sessionId, topicId);
4125
+ await this.reconcileUserTopicName(topicLease);
3049
4126
  const name = this.topicNameFor(session.sessionId, msg);
3050
4127
  if (this.topics.needsRename(session.sessionId, name)) {
3051
4128
  this.daemonRenameAttempts.set(
@@ -3053,6 +4130,7 @@ export class TelegramNotificationDaemon {
3053
4130
  (this.daemonRenameAttempts.get(session.sessionId) ?? 0) + 1,
3054
4131
  );
3055
4132
  try {
4133
+ if (!this.topicLeaseIsCurrent(topicLease)) return;
3056
4134
  const response = await this.botApi.call("editForumTopic", {
3057
4135
  chat_id: this.opts.chatId,
3058
4136
  message_thread_id: Number(topicId),
@@ -3067,31 +4145,34 @@ export class TelegramNotificationDaemon {
3067
4145
  if (remaining > 0) this.daemonRenameAttempts.set(session.sessionId, remaining);
3068
4146
  else {
3069
4147
  this.daemonRenameAttempts.delete(session.sessionId);
3070
- await this.reconcileUserTopicName(session.sessionId, topicId);
4148
+ await this.reconcileUserTopicName(topicLease);
3071
4149
  }
3072
4150
  }
3073
4151
  }
3074
4152
  // Send the full bulleted identity header EXACTLY ONCE per topic.
3075
4153
  if (this.topics.needsIdentity(session.sessionId)) {
3076
- await this.submitThreadedFrame(session.sessionId, send, topicId);
4154
+ await this.submitThreadedFrame(session.sessionId, send, topicLease);
3077
4155
  this.topics.markIdentitySent(session.sessionId);
3078
4156
  }
3079
- await this.flushPendingThreadedFrames(session.sessionId, topicId);
4157
+ await this.flushPendingThreadedFrames(session.sessionId, topicLease);
3080
4158
  await this.persistTopics();
3081
4159
  return;
3082
4160
  }
3083
- await this.submitThreadedFrame(session.sessionId, send, topicId);
4161
+ await this.submitThreadedFrame(session.sessionId, send, topicLease);
3084
4162
  return;
3085
4163
  }
3086
4164
  if (msg.type === "action_needed" && msg.id) {
3087
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;
3088
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;
3089
4170
  if (!topicId) {
3090
4171
  // Fail closed for non-private chats; only nudge + flat-deliver in a private DM.
3091
4172
  if (!(await this.pairedChatIsPrivate())) return;
3092
4173
  await this.notifyThreadedFallback();
3093
4174
  }
3094
- const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
4175
+ const threadField = topicLease ? { message_thread_id: Number(topicLease.topicId) } : {};
3095
4176
  const controls: Array<{
3096
4177
  id: "navigation_forward";
3097
4178
  kind: "navigation";
@@ -3146,6 +4227,7 @@ export class TelegramNotificationDaemon {
3146
4227
  const chunks = splitTelegramHtml(rendered.text);
3147
4228
  let result: { result?: { message_id?: number } } = {};
3148
4229
  for (let i = 0; i < chunks.length; i++) {
4230
+ if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return undefined;
3149
4231
  result = (await this.botApi.call("sendMessage", {
3150
4232
  chat_id: this.opts.chatId,
3151
4233
  ...threadField,
@@ -3161,6 +4243,7 @@ export class TelegramNotificationDaemon {
3161
4243
  // Rich (default on): promote to sendRichMessage with a top-level
3162
4244
  // reply_markup (probe-confirmed). Any miss falls back to the HTML loop.
3163
4245
 
4246
+ if (topicLease && !this.topicLeaseIsCurrent(topicLease)) return;
3164
4247
  const outcome = await deliverRichActionWithFallback(
3165
4248
  this.botApi,
3166
4249
  { chat_id: this.opts.chatId, ...threadField },
@@ -3172,8 +4255,8 @@ export class TelegramNotificationDaemon {
3172
4255
  summary: msg.summary,
3173
4256
  }),
3174
4257
  replyMarkup: kind === "ask" && inline_keyboard.length ? { inline_keyboard } : undefined,
3175
- requireMessageId: kind === "ask",
3176
4258
  },
4259
+ AbortSignal.any([this.#deliveryAbort.signal, AbortSignal.timeout(30_000)]),
3177
4260
  sendHtmlChunks,
3178
4261
  logger,
3179
4262
  );
@@ -3341,7 +4424,8 @@ export class TelegramNotificationDaemon {
3341
4424
  } catch {
3342
4425
  return "retry";
3343
4426
  }
3344
- await this.reconcileUserTopicName(sessionId, String(threadId));
4427
+ const topicLease = this.topicAuthorityLeaseFromRegistry(sessionId);
4428
+ if (topicLease) await this.reconcileUserTopicName(topicLease);
3345
4429
  await this.rememberSeenUpdateId(updateId);
3346
4430
  return "consumed";
3347
4431
  }
@@ -3350,7 +4434,8 @@ export class TelegramNotificationDaemon {
3350
4434
  } catch {
3351
4435
  return "retry";
3352
4436
  }
3353
- await this.reconcileUserTopicName(sessionId, String(threadId));
4437
+ const topicLease = this.topicAuthorityLeaseFromRegistry(sessionId);
4438
+ if (topicLease) await this.reconcileUserTopicName(topicLease);
3354
4439
  await this.rememberSeenUpdateId(updateId);
3355
4440
  return "consumed";
3356
4441
  }
@@ -3468,6 +4553,8 @@ export class TelegramNotificationDaemon {
3468
4553
  // message in a topic (plain text, or a reply to a non-ask message) is a
3469
4554
  // free-text injection. Previously replies bypassed injection entirely.
3470
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;
3471
4558
  const isAskReply =
3472
4559
  replyTo !== undefined && (this.messageRoutes.has(String(replyTo)) || this.messageRoutes.has(Number(replyTo)));
3473
4560
  const directControl =
@@ -3475,7 +4562,7 @@ export class TelegramNotificationDaemon {
3475
4562
  ? parseTelegramControlCommand(raw.message.text, this.botUsername)
3476
4563
  : ({ kind: "none" } as const);
3477
4564
  const interceptAskControl = isAskReply && directControl.kind !== "none";
3478
- if (!raw.callback_query && (!isAskReply || interceptAskControl)) {
4565
+ if (!raw.callback_query && (!isAskReply || interceptAskControl || reservedBtw !== undefined)) {
3479
4566
  const inbound = decideThreadedInbound(update as never, {
3480
4567
  pairedChatId: this.opts.chatId,
3481
4568
  topicToSession: t => this.topics.sessionForTopic(t),
@@ -3491,7 +4578,7 @@ export class TelegramNotificationDaemon {
3491
4578
  const session = this.sessions.get(inbound.sessionId);
3492
4579
  if (preliminaryControl.kind === "invalid" && session?.ws.readyState !== WebSocket.OPEN) return;
3493
4580
  if (preliminaryControl.kind === "command" && session?.ws.readyState !== WebSocket.OPEN) {
3494
- if (await this.rememberSeenUpdateIdForUnavailableNotice(inbound.updateId)) {
4581
+ if (await this.reserveSeenUpdateId(inbound.updateId)) {
3495
4582
  try {
3496
4583
  await this.botApi.call("sendMessage", {
3497
4584
  chat_id: this.opts.chatId,
@@ -3504,13 +4591,45 @@ export class TelegramNotificationDaemon {
3504
4591
  }
3505
4592
  return;
3506
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
+ }
3507
4626
  if (session?.ws.readyState === WebSocket.OPEN) {
3508
4627
  const attachmentResult = inbound.attachment
3509
4628
  ? await this.resolveInboundAttachment(inbound.attachment, inbound.sessionId)
3510
4629
  : undefined;
3511
4630
  const images = attachmentResult?.images ?? [];
3512
4631
  const fileNotes = attachmentResult?.fileNotes ?? [];
3513
- const hasMedia = images.length > 0 || fileNotes.length > 0;
4632
+ const hasMedia = inbound.attachment !== undefined || images.length > 0 || fileNotes.length > 0;
3514
4633
  const baseInjectedText = [inbound.text, ...fileNotes].filter(Boolean).join("\n");
3515
4634
  // A reply to a rich message we sent (not an ask route) loses its original
3516
4635
  // text: Telegram does not echo it in reply_to_message. Restore it from the
@@ -3522,6 +4641,100 @@ export class TelegramNotificationDaemon {
3522
4641
  const injectedText = repliedOriginal
3523
4642
  ? `> replied-to message:\n${repliedOriginal}\n\n${baseInjectedText}`
3524
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
+ }
3525
4738
  const control = hasMedia ? { kind: "none" as const } : preliminaryControl;
3526
4739
  if (control.kind !== "none") {
3527
4740
  await this.rememberSeenUpdateId(inbound.updateId);
@@ -3635,7 +4848,10 @@ export class TelegramNotificationDaemon {
3635
4848
  try {
3636
4849
  await this.botApi.call("setMyCommands", {
3637
4850
  commands: [
3638
- { 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
+ },
3639
4855
  { command: "lean", description: "Mirror assistant text + tool names only (default)" },
3640
4856
  { command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
3641
4857
  { command: "rich", description: "Toggle rich Telegram delivery: /rich <on|off>" },
@@ -3644,6 +4860,7 @@ export class TelegramNotificationDaemon {
3644
4860
  { command: "model", description: "Select a model for this session" },
3645
4861
  { command: "context", description: "Show current context usage for this session" },
3646
4862
  { command: "compact", description: "Compact this session: /compact [instructions]" },
4863
+ { command: "btw", description: "Ask an ephemeral side question in this session" },
3647
4864
  { command: "session_create", description: "Create a GJC session: path, worktree, or dir [--mpreset]" },
3648
4865
  { command: "session_recent", description: "List recent GJC sessions" },
3649
4866
  { command: "session_close", description: "Close a GJC-managed session" },
@@ -3656,15 +4873,25 @@ export class TelegramNotificationDaemon {
3656
4873
  }
3657
4874
 
3658
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
+ }
3659
4883
  this.running = await renewDaemonHeartbeat({
3660
4884
  settings: this.opts.settings,
3661
4885
  ownerId: this.opts.ownerId,
4886
+ tokenFingerprint: tokenFingerprint(this.opts.botToken),
4887
+ chatId: this.opts.chatId,
3662
4888
  fs: this.fsImpl,
3663
4889
  now: this.opts.now,
3664
4890
  pid: this.opts.pid ?? process.pid,
3665
4891
  });
3666
4892
  if (!this.running) return;
3667
4893
  this.runtime.start();
4894
+ this.startOwnershipHeartbeatTimer();
3668
4895
  this.startFlushTimer();
3669
4896
  this.startScanTimer();
3670
4897
  this.startTypingTimer();
@@ -3682,16 +4909,7 @@ export class TelegramNotificationDaemon {
3682
4909
  let idleSince = this.runtime.now();
3683
4910
  while (this.running) {
3684
4911
  if (await this.controlStopRequested()) break;
3685
- if (
3686
- !(await renewDaemonHeartbeat({
3687
- settings: this.opts.settings,
3688
- ownerId: this.opts.ownerId,
3689
- fs: this.fsImpl,
3690
- now: this.opts.now,
3691
- pid: this.opts.pid ?? process.pid,
3692
- }))
3693
- )
3694
- break;
4912
+ if (!(await this.renewOwnershipHeartbeat())) break;
3695
4913
  await this.runScan();
3696
4914
  if (await this.controlStopRequested()) break;
3697
4915
  const idleElapsed = this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000);
@@ -3726,24 +4944,50 @@ export class TelegramNotificationDaemon {
3726
4944
  await this.runtime.sleep(10);
3727
4945
  }
3728
4946
  } finally {
3729
- this.runtime.stop();
3730
- this.stopFlushTimer();
3731
- this.stopScanTimer();
3732
- this.stopTypingTimer();
3733
- this.stopLifecycleControl();
3734
- await this.cleanupAllAttachmentDirs();
3735
- // Persist durable state before releasing ownership so a fresh daemon
3736
- // (e.g. after reload) reloads aliases/topics seamlessly.
3737
- await this.persistAliases().catch(() => undefined);
3738
- await this.persistTopics().catch(() => undefined);
3739
- await this.persistSeenUpdateIds().catch(() => undefined);
3740
- await this.opts.control?.clear?.(this.opts.ownerId).catch(() => undefined);
3741
- await releaseDaemonOwnership({
3742
- settings: this.opts.settings,
3743
- ownerId: this.opts.ownerId,
3744
- fs: this.fsImpl,
3745
- 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;
3746
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
+ }
3747
4991
  }
3748
4992
  }
3749
4993