charness 3.6.1 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (578) hide show
  1. package/CHANGELOG.md +267 -0
  2. package/DEVELOPMENT.md +31 -5
  3. package/MIGRATION_RENAME_TO_CHARNESS.md +3 -2
  4. package/README.md +84 -782
  5. package/SETUP.md +37 -10
  6. package/agent-plugin/.claude-plugin/plugin.json +7 -0
  7. package/agent-plugin/README.md +35 -0
  8. package/agent-plugin/skills/charness-delegate-claude/SKILL.md +85 -0
  9. package/agent-plugin/skills/charness-delegate-claude/summarize-events.py +90 -0
  10. package/agent-plugin/skills/charness-delegate-opencode/SKILL.md +68 -0
  11. package/agent-plugin/skills/charness-manager/SKILL.md +152 -0
  12. package/dist/adapters/adapterNames.d.ts +6 -0
  13. package/dist/adapters/adapterNames.d.ts.map +1 -1
  14. package/dist/adapters/adapterNames.js +8 -2
  15. package/dist/adapters/adapterNames.js.map +1 -1
  16. package/dist/adapters/claudeCliAdapter.d.ts +8 -0
  17. package/dist/adapters/claudeCliAdapter.d.ts.map +1 -1
  18. package/dist/adapters/claudeCliAdapter.js +42 -5
  19. package/dist/adapters/claudeCliAdapter.js.map +1 -1
  20. package/dist/adapters/claudeJsonStreamAdapter.d.ts +10 -9
  21. package/dist/adapters/claudeJsonStreamAdapter.d.ts.map +1 -1
  22. package/dist/adapters/claudeJsonStreamAdapter.js +53 -55
  23. package/dist/adapters/claudeJsonStreamAdapter.js.map +1 -1
  24. package/dist/adapters/createAdapter.d.ts +8 -0
  25. package/dist/adapters/createAdapter.d.ts.map +1 -1
  26. package/dist/adapters/createAdapter.js +14 -2
  27. package/dist/adapters/createAdapter.js.map +1 -1
  28. package/dist/adapters/openCodeAdapter.d.ts +43 -6
  29. package/dist/adapters/openCodeAdapter.d.ts.map +1 -1
  30. package/dist/adapters/openCodeAdapter.js +95 -6
  31. package/dist/adapters/openCodeAdapter.js.map +1 -1
  32. package/dist/agentLogin/agentLogin.d.ts +1 -1
  33. package/dist/agentLogin/agentLogin.d.ts.map +1 -1
  34. package/dist/apiRetryKick.d.ts +4 -3
  35. package/dist/apiRetryKick.d.ts.map +1 -1
  36. package/dist/apiRetryKick.js +16 -9
  37. package/dist/apiRetryKick.js.map +1 -1
  38. package/dist/bot.d.ts.map +1 -1
  39. package/dist/bot.js +1313 -381
  40. package/dist/bot.js.map +1 -1
  41. package/dist/cli/claudeProfiles.d.ts +11 -6
  42. package/dist/cli/claudeProfiles.d.ts.map +1 -1
  43. package/dist/cli/claudeProfiles.js +50 -22
  44. package/dist/cli/claudeProfiles.js.map +1 -1
  45. package/dist/cli/hot.d.ts +5 -0
  46. package/dist/cli/hot.d.ts.map +1 -1
  47. package/dist/cli/hot.js +71 -25
  48. package/dist/cli/hot.js.map +1 -1
  49. package/dist/cli/tscWatchGuard.d.ts +95 -0
  50. package/dist/cli/tscWatchGuard.d.ts.map +1 -0
  51. package/dist/cli/tscWatchGuard.js +295 -0
  52. package/dist/cli/tscWatchGuard.js.map +1 -0
  53. package/dist/cli.js +12 -1
  54. package/dist/cli.js.map +1 -1
  55. package/dist/connectors/jira/answerSink.d.ts +10 -0
  56. package/dist/connectors/jira/answerSink.d.ts.map +1 -1
  57. package/dist/connectors/jira/answerSink.js +13 -0
  58. package/dist/connectors/jira/answerSink.js.map +1 -1
  59. package/dist/connectors/jira/config.d.ts +2 -0
  60. package/dist/connectors/jira/config.d.ts.map +1 -1
  61. package/dist/connectors/jira/config.js +9 -1
  62. package/dist/connectors/jira/config.js.map +1 -1
  63. package/dist/connectors/jira/connector.d.ts +11 -1
  64. package/dist/connectors/jira/connector.d.ts.map +1 -1
  65. package/dist/connectors/jira/connector.js +7 -0
  66. package/dist/connectors/jira/connector.js.map +1 -1
  67. package/dist/connectors/jira/inbound.d.ts +27 -4
  68. package/dist/connectors/jira/inbound.d.ts.map +1 -1
  69. package/dist/connectors/jira/inbound.js +69 -8
  70. package/dist/connectors/jira/inbound.js.map +1 -1
  71. package/dist/connectors/jira/issueBlocks.d.ts +5 -0
  72. package/dist/connectors/jira/issueBlocks.d.ts.map +1 -1
  73. package/dist/connectors/jira/issueBlocks.js +11 -0
  74. package/dist/connectors/jira/issueBlocks.js.map +1 -1
  75. package/dist/connectors/telegram/bindPicker.d.ts +5 -0
  76. package/dist/connectors/telegram/bindPicker.d.ts.map +1 -1
  77. package/dist/connectors/telegram/bindPicker.js +18 -1
  78. package/dist/connectors/telegram/bindPicker.js.map +1 -1
  79. package/dist/connectors/telegram/bindPickerClaims.d.ts +46 -0
  80. package/dist/connectors/telegram/bindPickerClaims.d.ts.map +1 -0
  81. package/dist/connectors/telegram/bindPickerClaims.js +83 -0
  82. package/dist/connectors/telegram/bindPickerClaims.js.map +1 -0
  83. package/dist/connectors/telegram/bindSweep.d.ts +32 -0
  84. package/dist/connectors/telegram/bindSweep.d.ts.map +1 -0
  85. package/dist/connectors/telegram/bindSweep.js +47 -0
  86. package/dist/connectors/telegram/bindSweep.js.map +1 -0
  87. package/dist/connectors/telegram/bindingWelcome.d.ts +23 -0
  88. package/dist/connectors/telegram/bindingWelcome.d.ts.map +1 -0
  89. package/dist/connectors/telegram/bindingWelcome.js +26 -0
  90. package/dist/connectors/telegram/bindingWelcome.js.map +1 -0
  91. package/dist/connectors/telegram/botApiConnection.d.ts +74 -0
  92. package/dist/connectors/telegram/botApiConnection.d.ts.map +1 -0
  93. package/dist/connectors/telegram/botApiConnection.js +201 -0
  94. package/dist/connectors/telegram/botApiConnection.js.map +1 -0
  95. package/dist/connectors/telegram/commands/botCore.d.ts +8 -2
  96. package/dist/connectors/telegram/commands/botCore.d.ts.map +1 -1
  97. package/dist/connectors/telegram/commands/botSkills.d.ts +21 -0
  98. package/dist/connectors/telegram/commands/botSkills.d.ts.map +1 -0
  99. package/dist/connectors/telegram/commands/botSkills.js +133 -0
  100. package/dist/connectors/telegram/commands/botSkills.js.map +1 -0
  101. package/dist/connectors/telegram/commands/callbackMessage.d.ts +5 -0
  102. package/dist/connectors/telegram/commands/callbackMessage.d.ts.map +1 -1
  103. package/dist/connectors/telegram/commands/callbackMessage.js +9 -0
  104. package/dist/connectors/telegram/commands/callbackMessage.js.map +1 -1
  105. package/dist/connectors/telegram/commands/compaction.d.ts +65 -3
  106. package/dist/connectors/telegram/commands/compaction.d.ts.map +1 -1
  107. package/dist/connectors/telegram/commands/compaction.js +512 -94
  108. package/dist/connectors/telegram/commands/compaction.js.map +1 -1
  109. package/dist/connectors/telegram/commands/displayModes.d.ts +1 -1
  110. package/dist/connectors/telegram/commands/displayModes.d.ts.map +1 -1
  111. package/dist/connectors/telegram/commands/displayModes.js +6 -18
  112. package/dist/connectors/telegram/commands/displayModes.js.map +1 -1
  113. package/dist/connectors/telegram/commands/fallbackChain.js +5 -5
  114. package/dist/connectors/telegram/commands/fallbackChain.js.map +1 -1
  115. package/dist/connectors/telegram/commands/reminders.d.ts +1 -1
  116. package/dist/connectors/telegram/commands/reminders.d.ts.map +1 -1
  117. package/dist/connectors/telegram/commands/reminders.js +4 -15
  118. package/dist/connectors/telegram/commands/reminders.js.map +1 -1
  119. package/dist/connectors/telegram/commands/selfUpdate.d.ts.map +1 -1
  120. package/dist/connectors/telegram/commands/selfUpdate.js +1 -11
  121. package/dist/connectors/telegram/commands/selfUpdate.js.map +1 -1
  122. package/dist/connectors/telegram/commands/taskAgents.d.ts +22 -0
  123. package/dist/connectors/telegram/commands/taskAgents.d.ts.map +1 -0
  124. package/dist/connectors/telegram/commands/taskAgents.js +104 -0
  125. package/dist/connectors/telegram/commands/taskAgents.js.map +1 -0
  126. package/dist/connectors/telegram/commands/todo.d.ts +82 -0
  127. package/dist/connectors/telegram/commands/todo.d.ts.map +1 -0
  128. package/dist/connectors/telegram/commands/todo.js +384 -0
  129. package/dist/connectors/telegram/commands/todo.js.map +1 -0
  130. package/dist/connectors/telegram/commands/topicCommand.d.ts +6 -2
  131. package/dist/connectors/telegram/commands/topicCommand.d.ts.map +1 -1
  132. package/dist/connectors/telegram/commands/topicCommand.js +6 -9
  133. package/dist/connectors/telegram/commands/topicCommand.js.map +1 -1
  134. package/dist/connectors/telegram/openBindMessages.d.ts +45 -0
  135. package/dist/connectors/telegram/openBindMessages.d.ts.map +1 -0
  136. package/dist/connectors/telegram/openBindMessages.js +67 -0
  137. package/dist/connectors/telegram/openBindMessages.js.map +1 -0
  138. package/dist/connectors/telegram/outputFlushPlan.d.ts +49 -0
  139. package/dist/connectors/telegram/outputFlushPlan.d.ts.map +1 -1
  140. package/dist/connectors/telegram/outputFlushPlan.js +46 -0
  141. package/dist/connectors/telegram/outputFlushPlan.js.map +1 -1
  142. package/dist/connectors/telegram/pendingTopicName.d.ts +26 -0
  143. package/dist/connectors/telegram/pendingTopicName.d.ts.map +1 -0
  144. package/dist/connectors/telegram/pendingTopicName.js +27 -0
  145. package/dist/connectors/telegram/pendingTopicName.js.map +1 -0
  146. package/dist/connectors/test/connector.d.ts.map +1 -1
  147. package/dist/connectors/test/connector.js +6 -1
  148. package/dist/connectors/test/connector.js.map +1 -1
  149. package/dist/i18n/de.d.ts.map +1 -1
  150. package/dist/i18n/de.js +90 -7
  151. package/dist/i18n/de.js.map +1 -1
  152. package/dist/i18n/en.d.ts.map +1 -1
  153. package/dist/i18n/en.js +90 -7
  154. package/dist/i18n/en.js.map +1 -1
  155. package/dist/i18n/es.d.ts.map +1 -1
  156. package/dist/i18n/es.js +90 -7
  157. package/dist/i18n/es.js.map +1 -1
  158. package/dist/i18n/fr.d.ts.map +1 -1
  159. package/dist/i18n/fr.js +90 -7
  160. package/dist/i18n/fr.js.map +1 -1
  161. package/dist/i18n/hi.d.ts.map +1 -1
  162. package/dist/i18n/hi.js +90 -7
  163. package/dist/i18n/hi.js.map +1 -1
  164. package/dist/i18n/ja.d.ts.map +1 -1
  165. package/dist/i18n/ja.js +90 -7
  166. package/dist/i18n/ja.js.map +1 -1
  167. package/dist/i18n/ka.d.ts.map +1 -1
  168. package/dist/i18n/ka.js +90 -7
  169. package/dist/i18n/ka.js.map +1 -1
  170. package/dist/i18n/pt.d.ts.map +1 -1
  171. package/dist/i18n/pt.js +90 -7
  172. package/dist/i18n/pt.js.map +1 -1
  173. package/dist/i18n/ru.d.ts.map +1 -1
  174. package/dist/i18n/ru.js +90 -7
  175. package/dist/i18n/ru.js.map +1 -1
  176. package/dist/i18n/uk.d.ts.map +1 -1
  177. package/dist/i18n/uk.js +90 -7
  178. package/dist/i18n/uk.js.map +1 -1
  179. package/dist/i18n/uz.d.ts.map +1 -1
  180. package/dist/i18n/uz.js +90 -7
  181. package/dist/i18n/uz.js.map +1 -1
  182. package/dist/i18n/zh.d.ts.map +1 -1
  183. package/dist/i18n/zh.js +90 -7
  184. package/dist/i18n/zh.js.map +1 -1
  185. package/dist/i18n.d.ts +1 -1
  186. package/dist/i18n.js +1 -1
  187. package/dist/installManager.d.ts +13 -0
  188. package/dist/installManager.d.ts.map +1 -1
  189. package/dist/installManager.js +38 -4
  190. package/dist/installManager.js.map +1 -1
  191. package/dist/instanceSettings.d.ts +40 -1
  192. package/dist/instanceSettings.d.ts.map +1 -1
  193. package/dist/instanceSettings.js +105 -26
  194. package/dist/instanceSettings.js.map +1 -1
  195. package/dist/openCodeQuestionFlow.d.ts +30 -0
  196. package/dist/openCodeQuestionFlow.d.ts.map +1 -1
  197. package/dist/openCodeQuestionFlow.js +43 -0
  198. package/dist/openCodeQuestionFlow.js.map +1 -1
  199. package/dist/postToSession.d.ts +20 -5
  200. package/dist/postToSession.d.ts.map +1 -1
  201. package/dist/postToSession.js +38 -7
  202. package/dist/postToSession.js.map +1 -1
  203. package/dist/remoteRun/cliArgs.d.ts +73 -0
  204. package/dist/remoteRun/cliArgs.d.ts.map +1 -0
  205. package/dist/remoteRun/cliArgs.js +197 -0
  206. package/dist/remoteRun/cliArgs.js.map +1 -0
  207. package/dist/remoteRun/commands.d.ts +56 -0
  208. package/dist/remoteRun/commands.d.ts.map +1 -0
  209. package/dist/remoteRun/commands.js +306 -0
  210. package/dist/remoteRun/commands.js.map +1 -0
  211. package/dist/remoteRun/depsPlan.d.ts +16 -0
  212. package/dist/remoteRun/depsPlan.d.ts.map +1 -0
  213. package/dist/remoteRun/depsPlan.js +82 -0
  214. package/dist/remoteRun/depsPlan.js.map +1 -0
  215. package/dist/remoteRun/exitCodes.d.ts +34 -0
  216. package/dist/remoteRun/exitCodes.d.ts.map +1 -0
  217. package/dist/remoteRun/exitCodes.js +38 -0
  218. package/dist/remoteRun/exitCodes.js.map +1 -0
  219. package/dist/remoteRun/foreground.d.ts +29 -0
  220. package/dist/remoteRun/foreground.d.ts.map +1 -0
  221. package/dist/remoteRun/foreground.js +159 -0
  222. package/dist/remoteRun/foreground.js.map +1 -0
  223. package/dist/remoteRun/interrupts.d.ts +17 -0
  224. package/dist/remoteRun/interrupts.d.ts.map +1 -0
  225. package/dist/remoteRun/interrupts.js +41 -0
  226. package/dist/remoteRun/interrupts.js.map +1 -0
  227. package/dist/remoteRun/jobFiles.d.ts +31 -0
  228. package/dist/remoteRun/jobFiles.d.ts.map +1 -0
  229. package/dist/remoteRun/jobFiles.js +84 -0
  230. package/dist/remoteRun/jobFiles.js.map +1 -0
  231. package/dist/remoteRun/jobFormat.d.ts +16 -0
  232. package/dist/remoteRun/jobFormat.d.ts.map +1 -0
  233. package/dist/remoteRun/jobFormat.js +130 -0
  234. package/dist/remoteRun/jobFormat.js.map +1 -0
  235. package/dist/remoteRun/jobId.d.ts +15 -0
  236. package/dist/remoteRun/jobId.d.ts.map +1 -0
  237. package/dist/remoteRun/jobId.js +85 -0
  238. package/dist/remoteRun/jobId.js.map +1 -0
  239. package/dist/remoteRun/jobMeta.d.ts +18 -0
  240. package/dist/remoteRun/jobMeta.d.ts.map +1 -0
  241. package/dist/remoteRun/jobMeta.js +33 -0
  242. package/dist/remoteRun/jobMeta.js.map +1 -0
  243. package/dist/remoteRun/jobQuery.d.ts +12 -0
  244. package/dist/remoteRun/jobQuery.d.ts.map +1 -0
  245. package/dist/remoteRun/jobQuery.js +22 -0
  246. package/dist/remoteRun/jobQuery.js.map +1 -0
  247. package/dist/remoteRun/jobRecords.d.ts +22 -0
  248. package/dist/remoteRun/jobRecords.d.ts.map +1 -0
  249. package/dist/remoteRun/jobRecords.js +145 -0
  250. package/dist/remoteRun/jobRecords.js.map +1 -0
  251. package/dist/remoteRun/jobState.d.ts +62 -0
  252. package/dist/remoteRun/jobState.d.ts.map +1 -0
  253. package/dist/remoteRun/jobState.js +132 -0
  254. package/dist/remoteRun/jobState.js.map +1 -0
  255. package/dist/remoteRun/leaseTiming.d.ts +18 -0
  256. package/dist/remoteRun/leaseTiming.d.ts.map +1 -0
  257. package/dist/remoteRun/leaseTiming.js +43 -0
  258. package/dist/remoteRun/leaseTiming.js.map +1 -0
  259. package/dist/remoteRun/output.d.ts +35 -0
  260. package/dist/remoteRun/output.d.ts.map +1 -0
  261. package/dist/remoteRun/output.js +82 -0
  262. package/dist/remoteRun/output.js.map +1 -0
  263. package/dist/remoteRun/pathPatterns.d.ts +33 -0
  264. package/dist/remoteRun/pathPatterns.d.ts.map +1 -0
  265. package/dist/remoteRun/pathPatterns.js +115 -0
  266. package/dist/remoteRun/pathPatterns.js.map +1 -0
  267. package/dist/remoteRun/probe.d.ts +56 -0
  268. package/dist/remoteRun/probe.d.ts.map +1 -0
  269. package/dist/remoteRun/probe.js +123 -0
  270. package/dist/remoteRun/probe.js.map +1 -0
  271. package/dist/remoteRun/project.d.ts +26 -0
  272. package/dist/remoteRun/project.d.ts.map +1 -0
  273. package/dist/remoteRun/project.js +159 -0
  274. package/dist/remoteRun/project.js.map +1 -0
  275. package/dist/remoteRun/projectConfig.d.ts +38 -0
  276. package/dist/remoteRun/projectConfig.d.ts.map +1 -0
  277. package/dist/remoteRun/projectConfig.js +87 -0
  278. package/dist/remoteRun/projectConfig.js.map +1 -0
  279. package/dist/remoteRun/remoteRunCli.d.ts +15 -0
  280. package/dist/remoteRun/remoteRunCli.d.ts.map +1 -0
  281. package/dist/remoteRun/remoteRunCli.js +144 -0
  282. package/dist/remoteRun/remoteRunCli.js.map +1 -0
  283. package/dist/remoteRun/remoteRunError.d.ts +14 -0
  284. package/dist/remoteRun/remoteRunError.d.ts.map +1 -0
  285. package/dist/remoteRun/remoteRunError.js +28 -0
  286. package/dist/remoteRun/remoteRunError.js.map +1 -0
  287. package/dist/remoteRun/rsync.d.ts +17 -0
  288. package/dist/remoteRun/rsync.d.ts.map +1 -0
  289. package/dist/remoteRun/rsync.js +39 -0
  290. package/dist/remoteRun/rsync.js.map +1 -0
  291. package/dist/remoteRun/runnerClient.d.ts +50 -0
  292. package/dist/remoteRun/runnerClient.d.ts.map +1 -0
  293. package/dist/remoteRun/runnerClient.js +139 -0
  294. package/dist/remoteRun/runnerClient.js.map +1 -0
  295. package/dist/remoteRun/runners.d.ts +57 -0
  296. package/dist/remoteRun/runners.d.ts.map +1 -0
  297. package/dist/remoteRun/runners.js +181 -0
  298. package/dist/remoteRun/runners.js.map +1 -0
  299. package/dist/remoteRun/runnersConfig.d.ts +44 -0
  300. package/dist/remoteRun/runnersConfig.d.ts.map +1 -0
  301. package/dist/remoteRun/runnersConfig.js +107 -0
  302. package/dist/remoteRun/runnersConfig.js.map +1 -0
  303. package/dist/remoteRun/safeCopy.d.ts +31 -0
  304. package/dist/remoteRun/safeCopy.d.ts.map +1 -0
  305. package/dist/remoteRun/safeCopy.js +137 -0
  306. package/dist/remoteRun/safeCopy.js.map +1 -0
  307. package/dist/remoteRun/sendPlan.d.ts +67 -0
  308. package/dist/remoteRun/sendPlan.d.ts.map +1 -0
  309. package/dist/remoteRun/sendPlan.js +139 -0
  310. package/dist/remoteRun/sendPlan.js.map +1 -0
  311. package/dist/remoteRun/ssh.d.ts +62 -0
  312. package/dist/remoteRun/ssh.d.ts.map +1 -0
  313. package/dist/remoteRun/ssh.js +202 -0
  314. package/dist/remoteRun/ssh.js.map +1 -0
  315. package/dist/remoteRun/submit.d.ts +29 -0
  316. package/dist/remoteRun/submit.d.ts.map +1 -0
  317. package/dist/remoteRun/submit.js +142 -0
  318. package/dist/remoteRun/submit.js.map +1 -0
  319. package/dist/requests/answerRequest.d.ts +5 -0
  320. package/dist/requests/answerRequest.d.ts.map +1 -1
  321. package/dist/requests/answerRequest.js +16 -1
  322. package/dist/requests/answerRequest.js.map +1 -1
  323. package/dist/requests/requestLedger.d.ts +13 -0
  324. package/dist/requests/requestLedger.d.ts.map +1 -1
  325. package/dist/requests/requestLedger.js +9 -0
  326. package/dist/requests/requestLedger.js.map +1 -1
  327. package/dist/requests/topicRequestSettle.d.ts +30 -0
  328. package/dist/requests/topicRequestSettle.d.ts.map +1 -0
  329. package/dist/requests/topicRequestSettle.js +23 -0
  330. package/dist/requests/topicRequestSettle.js.map +1 -0
  331. package/dist/requests/wakeUpEngine.d.ts +4 -0
  332. package/dist/requests/wakeUpEngine.d.ts.map +1 -1
  333. package/dist/requests/wakeUpEngine.js +5 -1
  334. package/dist/requests/wakeUpEngine.js.map +1 -1
  335. package/dist/scheduler/delivery.d.ts +2 -0
  336. package/dist/scheduler/delivery.d.ts.map +1 -1
  337. package/dist/scheduler/delivery.js +8 -4
  338. package/dist/scheduler/delivery.js.map +1 -1
  339. package/dist/scheduler/mcpSurface.d.ts +59 -10
  340. package/dist/scheduler/mcpSurface.d.ts.map +1 -1
  341. package/dist/scheduler/mcpSurface.js +276 -79
  342. package/dist/scheduler/mcpSurface.js.map +1 -1
  343. package/dist/scheduler/recurrence.d.ts +5 -0
  344. package/dist/scheduler/recurrence.d.ts.map +1 -1
  345. package/dist/scheduler/recurrence.js +12 -2
  346. package/dist/scheduler/recurrence.js.map +1 -1
  347. package/dist/scheduler/strictToolInput.d.ts +45 -0
  348. package/dist/scheduler/strictToolInput.d.ts.map +1 -0
  349. package/dist/scheduler/strictToolInput.js +155 -0
  350. package/dist/scheduler/strictToolInput.js.map +1 -0
  351. package/dist/scheduler/types.d.ts +5 -0
  352. package/dist/scheduler/types.d.ts.map +1 -1
  353. package/dist/scheduler/workerWatch.d.ts +23 -0
  354. package/dist/scheduler/workerWatch.d.ts.map +1 -0
  355. package/dist/scheduler/workerWatch.js +26 -0
  356. package/dist/scheduler/workerWatch.js.map +1 -0
  357. package/dist/selfUpdate/emittedSource.d.ts +11 -0
  358. package/dist/selfUpdate/emittedSource.d.ts.map +1 -0
  359. package/dist/selfUpdate/emittedSource.js +24 -0
  360. package/dist/selfUpdate/emittedSource.js.map +1 -0
  361. package/dist/selfUpdate/gitSource.d.ts +0 -6
  362. package/dist/selfUpdate/gitSource.d.ts.map +1 -1
  363. package/dist/selfUpdate/gitSource.js +2 -18
  364. package/dist/selfUpdate/gitSource.js.map +1 -1
  365. package/dist/selfUpdate/updatePending.d.ts +1 -1
  366. package/dist/sendErrorClassifier.d.ts +10 -0
  367. package/dist/sendErrorClassifier.d.ts.map +1 -1
  368. package/dist/sendErrorClassifier.js +22 -0
  369. package/dist/sendErrorClassifier.js.map +1 -1
  370. package/dist/state.d.ts +78 -7
  371. package/dist/state.d.ts.map +1 -1
  372. package/dist/state.js +125 -8
  373. package/dist/state.js.map +1 -1
  374. package/dist/stateFile.d.ts +9 -0
  375. package/dist/stateFile.d.ts.map +1 -0
  376. package/dist/stateFile.js +57 -0
  377. package/dist/stateFile.js.map +1 -0
  378. package/dist/stt/sttInstall.d.ts +7 -3
  379. package/dist/stt/sttInstall.d.ts.map +1 -1
  380. package/dist/stt/sttInstall.js +8 -4
  381. package/dist/stt/sttInstall.js.map +1 -1
  382. package/dist/stt/sttSettings.d.ts +2 -1
  383. package/dist/stt/sttSettings.d.ts.map +1 -1
  384. package/dist/stt/sttSettings.js +3 -2
  385. package/dist/stt/sttSettings.js.map +1 -1
  386. package/dist/stt/transcribeChain.js +2 -0
  387. package/dist/stt/transcribeChain.js.map +1 -1
  388. package/dist/testTimings.d.ts +13 -3
  389. package/dist/testTimings.d.ts.map +1 -1
  390. package/dist/testTimings.js +26 -2
  391. package/dist/testTimings.js.map +1 -1
  392. package/dist/todos/board.d.ts +83 -0
  393. package/dist/todos/board.d.ts.map +1 -0
  394. package/dist/todos/board.js +227 -0
  395. package/dist/todos/board.js.map +1 -0
  396. package/dist/todos/format.d.ts +33 -0
  397. package/dist/todos/format.d.ts.map +1 -0
  398. package/dist/todos/format.js +90 -0
  399. package/dist/todos/format.js.map +1 -0
  400. package/dist/todos/merge.d.ts +46 -0
  401. package/dist/todos/merge.d.ts.map +1 -0
  402. package/dist/todos/merge.js +137 -0
  403. package/dist/todos/merge.js.map +1 -0
  404. package/dist/todos/midTurn.d.ts +75 -0
  405. package/dist/todos/midTurn.d.ts.map +1 -0
  406. package/dist/todos/midTurn.js +214 -0
  407. package/dist/todos/midTurn.js.map +1 -0
  408. package/dist/todos/nag.d.ts +85 -0
  409. package/dist/todos/nag.d.ts.map +1 -0
  410. package/dist/todos/nag.js +103 -0
  411. package/dist/todos/nag.js.map +1 -0
  412. package/dist/todos/order.d.ts +25 -0
  413. package/dist/todos/order.d.ts.map +1 -0
  414. package/dist/todos/order.js +61 -0
  415. package/dist/todos/order.js.map +1 -0
  416. package/dist/todos/projects.d.ts +102 -0
  417. package/dist/todos/projects.d.ts.map +1 -0
  418. package/dist/todos/projects.js +342 -0
  419. package/dist/todos/projects.js.map +1 -0
  420. package/dist/todos/registry.d.ts +64 -0
  421. package/dist/todos/registry.d.ts.map +1 -0
  422. package/dist/todos/registry.js +116 -0
  423. package/dist/todos/registry.js.map +1 -0
  424. package/dist/todos/reminderEngine.d.ts +78 -0
  425. package/dist/todos/reminderEngine.d.ts.map +1 -0
  426. package/dist/todos/reminderEngine.js +162 -0
  427. package/dist/todos/reminderEngine.js.map +1 -0
  428. package/dist/todos/store.d.ts +34 -0
  429. package/dist/todos/store.d.ts.map +1 -0
  430. package/dist/todos/store.js +208 -0
  431. package/dist/todos/store.js.map +1 -0
  432. package/dist/todos/tools.d.ts +26 -0
  433. package/dist/todos/tools.d.ts.map +1 -0
  434. package/dist/todos/tools.js +25 -0
  435. package/dist/todos/tools.js.map +1 -0
  436. package/dist/todos/types.d.ts +85 -0
  437. package/dist/todos/types.d.ts.map +1 -0
  438. package/dist/todos/types.js +30 -0
  439. package/dist/todos/types.js.map +1 -0
  440. package/dist/trackers/taskAgentLimit.d.ts +58 -0
  441. package/dist/trackers/taskAgentLimit.d.ts.map +1 -0
  442. package/dist/trackers/taskAgentLimit.js +62 -0
  443. package/dist/trackers/taskAgentLimit.js.map +1 -0
  444. package/dist/trackers/taskAgentSlots.d.ts +109 -0
  445. package/dist/trackers/taskAgentSlots.d.ts.map +1 -0
  446. package/dist/trackers/taskAgentSlots.js +227 -0
  447. package/dist/trackers/taskAgentSlots.js.map +1 -0
  448. package/dist/trackers/types.d.ts +14 -0
  449. package/dist/trackers/types.d.ts.map +1 -0
  450. package/dist/trackers/types.js +3 -0
  451. package/dist/trackers/types.js.map +1 -0
  452. package/dist/types.d.ts +80 -4
  453. package/dist/types.d.ts.map +1 -1
  454. package/dist/utils/adoptReplayGate.d.ts +16 -0
  455. package/dist/utils/adoptReplayGate.d.ts.map +1 -0
  456. package/dist/utils/adoptReplayGate.js +35 -0
  457. package/dist/utils/adoptReplayGate.js.map +1 -0
  458. package/dist/utils/agentPlugin.d.ts +113 -0
  459. package/dist/utils/agentPlugin.d.ts.map +1 -0
  460. package/dist/utils/agentPlugin.js +192 -0
  461. package/dist/utils/agentPlugin.js.map +1 -0
  462. package/dist/utils/autoContinueOnLimit.d.ts +7 -4
  463. package/dist/utils/autoContinueOnLimit.d.ts.map +1 -1
  464. package/dist/utils/autoContinueOnLimit.js +8 -5
  465. package/dist/utils/autoContinueOnLimit.js.map +1 -1
  466. package/dist/utils/botSkills.d.ts +62 -0
  467. package/dist/utils/botSkills.d.ts.map +1 -0
  468. package/dist/utils/botSkills.js +53 -0
  469. package/dist/utils/botSkills.js.map +1 -0
  470. package/dist/utils/botSkillsSetting.d.ts +7 -0
  471. package/dist/utils/botSkillsSetting.d.ts.map +1 -0
  472. package/dist/utils/botSkillsSetting.js +9 -0
  473. package/dist/utils/botSkillsSetting.js.map +1 -0
  474. package/dist/utils/claudeCodeVersion.d.ts +7 -0
  475. package/dist/utils/claudeCodeVersion.d.ts.map +1 -1
  476. package/dist/utils/claudeCodeVersion.js +14 -1
  477. package/dist/utils/claudeCodeVersion.js.map +1 -1
  478. package/dist/utils/claudeCompactHook.d.ts +33 -15
  479. package/dist/utils/claudeCompactHook.d.ts.map +1 -1
  480. package/dist/utils/claudeCompactHook.js +36 -17
  481. package/dist/utils/claudeCompactHook.js.map +1 -1
  482. package/dist/utils/claudeCompactSummary.d.ts +15 -0
  483. package/dist/utils/claudeCompactSummary.d.ts.map +1 -0
  484. package/dist/utils/claudeCompactSummary.js +96 -0
  485. package/dist/utils/claudeCompactSummary.js.map +1 -0
  486. package/dist/utils/claudeLimitSwitch.d.ts +11 -9
  487. package/dist/utils/claudeLimitSwitch.d.ts.map +1 -1
  488. package/dist/utils/claudeLimitSwitch.js +18 -16
  489. package/dist/utils/claudeLimitSwitch.js.map +1 -1
  490. package/dist/utils/claudeProfiles.d.ts +5 -0
  491. package/dist/utils/claudeProfiles.d.ts.map +1 -1
  492. package/dist/utils/claudeProfiles.js +14 -0
  493. package/dist/utils/claudeProfiles.js.map +1 -1
  494. package/dist/utils/claudeSessionTitle.d.ts +2 -1
  495. package/dist/utils/claudeSessionTitle.d.ts.map +1 -1
  496. package/dist/utils/claudeSessionTitle.js +14 -14
  497. package/dist/utils/claudeSessionTitle.js.map +1 -1
  498. package/dist/utils/claudeUsage.d.ts +95 -15
  499. package/dist/utils/claudeUsage.d.ts.map +1 -1
  500. package/dist/utils/claudeUsage.js +241 -41
  501. package/dist/utils/claudeUsage.js.map +1 -1
  502. package/dist/utils/claudeUsageRefresher.d.ts +10 -0
  503. package/dist/utils/claudeUsageRefresher.d.ts.map +1 -1
  504. package/dist/utils/claudeUsageRefresher.js +11 -0
  505. package/dist/utils/claudeUsageRefresher.js.map +1 -1
  506. package/dist/utils/claudeUserEntry.d.ts +50 -0
  507. package/dist/utils/claudeUserEntry.d.ts.map +1 -0
  508. package/dist/utils/claudeUserEntry.js +120 -0
  509. package/dist/utils/claudeUserEntry.js.map +1 -0
  510. package/dist/utils/clearThreadOutputQueues.d.ts +4 -3
  511. package/dist/utils/clearThreadOutputQueues.d.ts.map +1 -1
  512. package/dist/utils/clearThreadOutputQueues.js +1 -1
  513. package/dist/utils/clearThreadOutputQueues.js.map +1 -1
  514. package/dist/utils/compactOnIdle.d.ts +33 -13
  515. package/dist/utils/compactOnIdle.d.ts.map +1 -1
  516. package/dist/utils/compactOnIdle.js +35 -15
  517. package/dist/utils/compactOnIdle.js.map +1 -1
  518. package/dist/utils/fallbackBridge.d.ts +5 -0
  519. package/dist/utils/fallbackBridge.d.ts.map +1 -1
  520. package/dist/utils/fallbackBridge.js +19 -3
  521. package/dist/utils/fallbackBridge.js.map +1 -1
  522. package/dist/utils/fallbackChain.d.ts +9 -3
  523. package/dist/utils/fallbackChain.d.ts.map +1 -1
  524. package/dist/utils/fallbackChain.js +21 -3
  525. package/dist/utils/fallbackChain.js.map +1 -1
  526. package/dist/utils/fileSendPlan.d.ts +6 -0
  527. package/dist/utils/fileSendPlan.d.ts.map +1 -1
  528. package/dist/utils/fileSendPlan.js +11 -0
  529. package/dist/utils/fileSendPlan.js.map +1 -1
  530. package/dist/utils/fileSendService.d.ts +7 -1
  531. package/dist/utils/fileSendService.d.ts.map +1 -1
  532. package/dist/utils/fileSendService.js +6 -2
  533. package/dist/utils/fileSendService.js.map +1 -1
  534. package/dist/utils/groupFinalizePlan.d.ts +8 -10
  535. package/dist/utils/groupFinalizePlan.d.ts.map +1 -1
  536. package/dist/utils/groupFinalizePlan.js +8 -12
  537. package/dist/utils/groupFinalizePlan.js.map +1 -1
  538. package/dist/utils/jsonStreamHost.d.ts +13 -0
  539. package/dist/utils/jsonStreamHost.d.ts.map +1 -1
  540. package/dist/utils/jsonStreamHost.js +13 -0
  541. package/dist/utils/jsonStreamHost.js.map +1 -1
  542. package/dist/utils/messageSendService.d.ts +5 -3
  543. package/dist/utils/messageSendService.d.ts.map +1 -1
  544. package/dist/utils/messageSendService.js +12 -5
  545. package/dist/utils/messageSendService.js.map +1 -1
  546. package/dist/utils/openCodeCompactPlugin.d.ts +39 -14
  547. package/dist/utils/openCodeCompactPlugin.d.ts.map +1 -1
  548. package/dist/utils/openCodeCompactPlugin.js +106 -38
  549. package/dist/utils/openCodeCompactPlugin.js.map +1 -1
  550. package/dist/utils/promptDelivery.d.ts +29 -12
  551. package/dist/utils/promptDelivery.d.ts.map +1 -1
  552. package/dist/utils/promptDelivery.js +13 -5
  553. package/dist/utils/promptDelivery.js.map +1 -1
  554. package/dist/utils/sentKindsSummary.d.ts +10 -0
  555. package/dist/utils/sentKindsSummary.d.ts.map +1 -0
  556. package/dist/utils/sentKindsSummary.js +18 -0
  557. package/dist/utils/sentKindsSummary.js.map +1 -0
  558. package/dist/utils/telegramError.d.ts +6 -0
  559. package/dist/utils/telegramError.d.ts.map +1 -1
  560. package/dist/utils/telegramError.js +12 -0
  561. package/dist/utils/telegramError.js.map +1 -1
  562. package/dist/utils/topicView.d.ts +1 -1
  563. package/dist/utils/topicView.js +1 -1
  564. package/dist/utils/transcribeAudio.d.ts +10 -0
  565. package/dist/utils/transcribeAudio.d.ts.map +1 -1
  566. package/dist/utils/transcribeAudio.js +22 -3
  567. package/dist/utils/transcribeAudio.js.map +1 -1
  568. package/dist/utils/transcriptErrorTime.d.ts +8 -0
  569. package/dist/utils/transcriptErrorTime.d.ts.map +1 -0
  570. package/dist/utils/transcriptErrorTime.js +56 -0
  571. package/dist/utils/transcriptErrorTime.js.map +1 -0
  572. package/dist/utils/wedgeRecovery.d.ts +2 -0
  573. package/dist/utils/wedgeRecovery.d.ts.map +1 -1
  574. package/dist/utils/wedgeRecovery.js +1 -1
  575. package/dist/utils/wedgeRecovery.js.map +1 -1
  576. package/package.json +11 -3
  577. package/remoteRun/runner.sh +1242 -0
  578. package/stt/gigaam_sidecar.py +555 -50
package/dist/bot.js CHANGED
@@ -46,7 +46,6 @@ const filters_1 = require("telegraf/filters");
46
46
  const fs = __importStar(require("fs"));
47
47
  const os = __importStar(require("os"));
48
48
  const path = __importStar(require("path"));
49
- const https = __importStar(require("https"));
50
49
  const child_process_1 = require("child_process");
51
50
  const util_1 = require("util");
52
51
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
@@ -76,6 +75,7 @@ const startupReadiness_1 = require("./utils/startupReadiness");
76
75
  const doctorLine_1 = require("./utils/doctorLine");
77
76
  const nativeWindows_1 = require("./utils/nativeWindows");
78
77
  const state_1 = require("./state");
78
+ const diagLog_1 = require("./diagLog");
79
79
  const instanceLock_1 = require("./instanceLock");
80
80
  const shutdown_1 = require("./shutdown");
81
81
  const updateDispatcher_1 = require("./connectors/telegram/updateDispatcher");
@@ -87,7 +87,14 @@ const timezonePicker_1 = require("./connectors/telegram/timezonePicker");
87
87
  const timezoneRecompute_1 = require("./scheduler/timezoneRecompute");
88
88
  const validation_1 = require("./validation");
89
89
  const bindPicker_1 = require("./connectors/telegram/bindPicker");
90
+ const openBindMessages_1 = require("./connectors/telegram/openBindMessages");
91
+ const bindSweep_1 = require("./connectors/telegram/bindSweep");
90
92
  const topicCommand_1 = require("./connectors/telegram/commands/topicCommand");
93
+ const callbackMessage_1 = require("./connectors/telegram/commands/callbackMessage");
94
+ const callbackToast_1 = require("./connectors/telegram/commands/callbackToast");
95
+ const bindingWelcome_1 = require("./connectors/telegram/bindingWelcome");
96
+ const bindPickerClaims_1 = require("./connectors/telegram/bindPickerClaims");
97
+ const pendingTopicName_1 = require("./connectors/telegram/pendingTopicName");
91
98
  const forumTopicCreated_1 = require("./connectors/telegram/forumTopicCreated");
92
99
  const providerDisconnectPlan_1 = require("./utils/providerDisconnectPlan");
93
100
  const folderName_1 = require("./folderName");
@@ -101,10 +108,13 @@ const outbound_3 = require("./connectors/jira/outbound");
101
108
  const adapterEventRouting_1 = require("./adapters/adapterEventRouting");
102
109
  const connectorSet_1 = require("./platform/connectorSet");
103
110
  const telegramCallGuard_1 = require("./connectors/telegram/telegramCallGuard");
111
+ const botApiConnection_1 = require("./connectors/telegram/botApiConnection");
104
112
  const unservedStateGuard_1 = require("./platform/unservedStateGuard");
105
113
  const contextPreamble_1 = require("./connectors/jira/contextPreamble");
106
114
  const postToSession_1 = require("./postToSession");
107
115
  const limitHeldPrompts_1 = require("./utils/limitHeldPrompts");
116
+ const adoptReplayGate_1 = require("./utils/adoptReplayGate");
117
+ const topicRequestSettle_1 = require("./requests/topicRequestSettle");
108
118
  const promptDelivery_1 = require("./utils/promptDelivery");
109
119
  const apiRetryKick_1 = require("./apiRetryKick");
110
120
  const limitHeldPromptQueue_1 = require("./utils/limitHeldPromptQueue");
@@ -164,6 +174,9 @@ const claudeLivenessAction_1 = require("./utils/claudeLivenessAction");
164
174
  const isoTimestamp_1 = require("./utils/isoTimestamp");
165
175
  const effortLevels_1 = require("./effortLevels");
166
176
  const instanceSettings_1 = require("./instanceSettings");
177
+ const taskAgentLimit_1 = require("./trackers/taskAgentLimit");
178
+ const taskAgentSlots_1 = require("./trackers/taskAgentSlots");
179
+ const botSkills_1 = require("./utils/botSkills");
167
180
  const constants_1 = require("./selfUpdate/constants");
168
181
  const launchEnvironment_1 = require("./selfUpdate/launchEnvironment");
169
182
  const restart_1 = require("./selfUpdate/restart");
@@ -183,11 +196,20 @@ const runLedger_1 = require("./scheduler/runLedger");
183
196
  const delivery_1 = require("./scheduler/delivery");
184
197
  const checkRun_1 = require("./scheduler/checkRun");
185
198
  const engine_1 = require("./scheduler/engine");
199
+ const recurrence_1 = require("./scheduler/recurrence");
186
200
  const mcpSurface_1 = require("./scheduler/mcpSurface");
187
201
  const injection_1 = require("./scheduler/injection");
188
202
  const mcpBoot_1 = require("./scheduler/mcpBoot");
189
203
  const requestLedger_1 = require("./requests/requestLedger");
190
204
  const answerRequest_1 = require("./requests/answerRequest");
205
+ const store_1 = require("./todos/store");
206
+ const registry_1 = require("./todos/registry");
207
+ const nag_1 = require("./todos/nag");
208
+ const reminderEngine_1 = require("./todos/reminderEngine");
209
+ const projects_1 = require("./todos/projects");
210
+ const board_1 = require("./todos/board");
211
+ const tools_1 = require("./todos/tools");
212
+ const midTurn_1 = require("./todos/midTurn");
191
213
  const answerTail_1 = require("./requests/answerTail");
192
214
  const answerSink_1 = require("./connectors/telegram/answerSink");
193
215
  const answerSink_2 = require("./platform/answerSink");
@@ -204,6 +226,7 @@ const apiErrorRetry_1 = require("./apiErrorRetry");
204
226
  const wedgeRecovery_1 = require("./utils/wedgeRecovery");
205
227
  const claudeAuthLogin_1 = require("./utils/claudeAuthLogin");
206
228
  const limitEpisodeRecovery_1 = require("./utils/limitEpisodeRecovery");
229
+ const transcriptErrorTime_1 = require("./utils/transcriptErrorTime");
207
230
  const autoContinueOnLimit_1 = require("./utils/autoContinueOnLimit");
208
231
  const openCodeAuthLogin_1 = require("./utils/openCodeAuthLogin");
209
232
  const agentLogin_1 = require("./agentLogin/agentLogin");
@@ -211,9 +234,11 @@ const questionKeyboards_1 = require("./connectors/telegram/questionKeyboards");
211
234
  const displayModes_1 = require("./connectors/telegram/commands/displayModes");
212
235
  const reminders_1 = require("./connectors/telegram/commands/reminders");
213
236
  const modelProviders_1 = require("./connectors/telegram/commands/modelProviders");
214
- const callbackToast_1 = require("./connectors/telegram/commands/callbackToast");
237
+ const callbackToast_2 = require("./connectors/telegram/commands/callbackToast");
215
238
  const compaction_1 = require("./connectors/telegram/commands/compaction");
216
239
  const autoContinueLimits_1 = require("./connectors/telegram/commands/autoContinueLimits");
240
+ const botSkills_2 = require("./connectors/telegram/commands/botSkills");
241
+ const taskAgents_1 = require("./connectors/telegram/commands/taskAgents");
217
242
  const claudeAccounts_1 = require("./connectors/telegram/commands/claudeAccounts");
218
243
  const claudeProfiles_1 = require("./utils/claudeProfiles");
219
244
  const claudeUsage_1 = require("./utils/claudeUsage");
@@ -229,6 +254,7 @@ const fallbackChain_1 = require("./utils/fallbackChain");
229
254
  const fallbackBridge_1 = require("./utils/fallbackBridge");
230
255
  const fallbackChain_2 = require("./connectors/telegram/commands/fallbackChain");
231
256
  const limitsBoard_1 = require("./connectors/telegram/commands/limitsBoard");
257
+ const todo_1 = require("./connectors/telegram/commands/todo");
232
258
  const backendSwitch_1 = require("./connectors/telegram/commands/backendSwitch");
233
259
  const operatorSwitchPlan_1 = require("./utils/operatorSwitchPlan");
234
260
  const backendSwitchPlan_1 = require("./utils/backendSwitchPlan");
@@ -237,6 +263,7 @@ const stt_1 = require("./connectors/telegram/commands/stt");
237
263
  const claudeApiSpend_1 = require("./utils/claudeApiSpend");
238
264
  const claudeProfileUsageLines_1 = require("./utils/claudeProfileUsageLines");
239
265
  const resolveBinary_1 = require("./utils/resolveBinary");
266
+ const runnersConfig_1 = require("./remoteRun/runnersConfig");
240
267
  // ═══════════════════════════════════════════════════════════════════════════════
241
268
  // ENV parsing & fatal validation
242
269
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -354,6 +381,8 @@ if (ENV.testTimings.telegramSendIntervalMs !== undefined)
354
381
  (0, rateLimiter_1.configureGlobalSendInterval)(ENV.testTimings.telegramSendIntervalMs);
355
382
  if (ENV.testTimings.backgroundHoldMaxMs !== undefined)
356
383
  (0, createAdapter_1.registerPendingWorkHoldMs)(ENV.testTimings.backgroundHoldMaxMs);
384
+ if (ENV.testTimings.scheduleMinFireIntervalMs !== undefined)
385
+ (0, recurrence_1.registerMinFireIntervalMs)(ENV.testTimings.scheduleMinFireIntervalMs);
357
386
  /** The short retry that probes an unconfirmed usage limit (a test instance may shorten it). */
358
387
  const limitProbeDelayMs = ENV.testTimings.limitProbeDelayMs ?? apiErrorRetry_1.unconfirmedLimitProbeDelayMs;
359
388
  // The instance default effort (`/effort` in General) is `settings.json`'s `defaultEffort`, re-read when the file changes.
@@ -446,24 +475,22 @@ function getOutputTransport() {
446
475
  }
447
476
  return outputTransport;
448
477
  }
449
- const telegramAgent = new https.Agent({
450
- keepAlive: true,
451
- keepAliveMsecs: 10000,
452
- family: 4,
453
- });
478
+ const telegramAgent = (0, botApiConnection_1.createBotApiHttpsAgent)();
454
479
  // Without the telegram connector there is no token (an empty one), and the call
455
480
  // guard below refuses every Bot API call before it is sent (Jira plan J3, D9).
456
481
  // `TELEGRAM_API_ROOT` points the client at another Bot API host — the
457
482
  // process-level test's fake on loopback; unset is the real `api.telegram.org`.
458
- // Telegraf drops the https agent itself for an `http://` root.
459
483
  const telegramApiRoot = process.env.TELEGRAM_API_ROOT?.trim();
484
+ // Telegraf drops the https agent itself for an `http://` root and node-fetch then rides the global agent, whose
485
+ // pooling is not the bot's. That root gets the same pooling over `http`.
486
+ const telegramApiAgent = telegramApiRoot?.startsWith('http://') ? (0, botApiConnection_1.createBotApiHttpAgent)() : telegramAgent;
460
487
  const bot = new telegraf_1.Telegraf(ENV.botToken, {
461
- telegram: { agent: telegramAgent, ...(telegramApiRoot ? { apiRoot: telegramApiRoot } : {}) },
488
+ telegram: { agent: telegramApiAgent, ...(telegramApiRoot ? { apiRoot: telegramApiRoot } : {}) },
462
489
  });
463
490
  // Decouple the long-polling intake/ACK from per-handler latency: wrap
464
491
  // `bot.handleUpdate` so each update is enqueued into its thread's serial queue
465
492
  // and the loop's `Promise.all` resolves at once (next `getUpdates` = ACK + new
466
- // batch fires now). The real middleware chain — the two `bot.use` middlewares,
493
+ // batch fires now). The real middleware chain — every `bot.use` middleware,
467
494
  // every command/`on`/action, `bot.catch` — runs unchanged inside the captured
468
495
  // original, just off-loop and per-thread-serial. MUST be installed before
469
496
  // `bot.launch`. The graceful-shutdown path drains it (see `updateDrainTimeoutMs`).
@@ -481,6 +508,10 @@ const updateDrainTimeoutMs = 2000;
481
508
  // window is not lost on shutdown.
482
509
  (0, outputTrace_1.installCallApiTrace)(bot.telegram);
483
510
  process.on('exit', outputTrace_1.flushTraceBufferSyncOnExit);
511
+ // The bot's pooling (the agent Telegraf dropped for an `http://` root) and the retry of a request that met a pooled
512
+ // connection the server had closed. AFTER the trace wrap, so the trace records both real HTTP attempts, and below the
513
+ // send queue, pacer and 429 retry. Telegraf builds a NEW client per update: see the first `bot.use` below.
514
+ (0, botApiConnection_1.wireBotApiClient)(bot.telegram, telegramApiAgent);
484
515
  // Default every text message/edit to NO link preview: a bare URL in agent
485
516
  // output otherwise expanded into a large preview card, one per message, in the
486
517
  // muted topic (operator complaint 2026-09-07). Installed OUTSIDE the trace wrap
@@ -601,7 +632,14 @@ async function checkIsForumAdmin(chatId, userId) {
601
632
  return false;
602
633
  }
603
634
  }
604
- // Output-trace `recv` hook — must be the FIRST middleware so every update is
635
+ // Telegraf builds a NEW `Telegram` client for every update (`ctx.telegram`, behind every `ctx.reply`, `ctx.answerCbQuery`
636
+ // and `ctx.editMessageText`) from `bot.telegram.options`, so the wiring of `bot.telegram` does not reach it and an
637
+ // `http://` root drops the agent again. Wire it before any handler can use it.
638
+ bot.use((ctx, next) => {
639
+ (0, botApiConnection_1.wireBotApiClient)(ctx.telegram, telegramApiAgent);
640
+ return next();
641
+ });
642
+ // Output-trace `recv` hook — must come before every middleware that can gate or drop an update, so every update is
605
643
  // recorded before any gating (access control, group routing) can drop it.
606
644
  // Security: while the thread awaits a `/connect` key paste (same
607
645
  // `pendingProviderConnects` state the text handler consumes later in this
@@ -788,7 +826,7 @@ let schedulerEngine = null;
788
826
  * The request wake-up engine (request/answer core S4), assigned once by
789
827
  * `startBot` after the request ledger loads. Module-level like
790
828
  * {@link schedulerEngine} because the cancel paths (`/esc`, `/c`, `/quit`,
791
- * `/quit-all`, `/new`, `/resume`, leaving the folder, switching the topic to
829
+ * `/quitall`, `/new`, `/resume`, leaving the folder, switching the topic to
792
830
  * another agent or a shell) are module-level command handlers.
793
831
  */
794
832
  let requestWakeUpEngine = null;
@@ -820,15 +858,19 @@ async function openTopicRequest(key, text, intake) {
820
858
  return { header: (0, topicRequest_1.buildTopicRequestHeader)(request.id, intake.source, view, request.supersededRequestIds), requestId: request.id };
821
859
  }
822
860
  /**
823
- * @description The request's prompt was handed to the prompt path (forwarded, or
824
- * buffered behind a session start — the turn probe holds a starting session, so
825
- * tracking now is right either way): the wake-up engine watches the turn.
861
+ * @description Where a topic request goes once its prompt came back from the prompt path (`requests/topicRequestSettle.ts`): the
862
+ * wake-up engine watches the turn of a prompt that reached a session (or waits behind its start), and a prompt that found no
863
+ * session closes the request it opened. Both ports are read at call time: the engine and the ledger are assigned at boot.
826
864
  */
827
- function trackTopicRequestTurn(key, opening) {
828
- if (opening.requestId === null)
829
- return;
830
- void requestWakeUpEngine?.trackForwardedTurn(key, opening.requestId, { isRequestPrompt: true }).catch((e) => console.warn(`[requests] tracking the turn of ${opening.requestId} failed:`, e instanceof Error ? e.message : e));
831
- }
865
+ const { trackTurn: trackTopicRequestTurn, settleAfterDelivery: settleTopicRequestAfterDelivery } = (0, topicRequestSettle_1.createTopicRequestSettler)({
866
+ trackTurn: async (key, requestId) => {
867
+ await requestWakeUpEngine?.trackForwardedTurn(key, requestId, { isRequestPrompt: true });
868
+ },
869
+ cancelRequest: async (requestId) => {
870
+ await topicRequestLedger?.closeRequest(requestId, 'cancelled');
871
+ },
872
+ warn: (message) => console.warn(message),
873
+ });
832
874
  /** What the bot's own limit-wait answer needs (S5); set at boot next to the engine. */
833
875
  let requestLimitWaitAnswerDeps = null;
834
876
  /**
@@ -850,6 +892,212 @@ function checkIsAgentEventShownInTopic(key, event) {
850
892
  function getDataDir() {
851
893
  return path.dirname(state.stateFilePath);
852
894
  }
895
+ /** The shared todo registry (`todos/`), built on first use: its store lives in `DATA_DIR`, known once the state is. */
896
+ let todoRegistry = null;
897
+ function getTodoRegistry() {
898
+ todoRegistry ??= (0, registry_1.createTodoRegistry)({ store: new store_1.TodoStore(getDataDir()), getNowMs: Date.now });
899
+ return todoRegistry;
900
+ }
901
+ /**
902
+ * The «Bot skills» switch (`/bot_skills`, `utils/botSkills.ts`): the topic's own choice, else General's value, else `settings.json`'s
903
+ * `botSkills` (re-read when the file changes), else ON. `state` is read when asked, so the reader is registered once it is loaded.
904
+ */
905
+ const botSkillsSources = {
906
+ getGeneralValue: () => state.getBotSkillsGlobalValue(),
907
+ getTopicOverride: (key) => state.getBotSkillsOverride(key),
908
+ getSettingsValue: (0, instanceSettings_1.createBotSkillsSettingsReader)(() => (0, instanceSettings_1.getInstanceSettingsPath)(), (line) => console.error(line)),
909
+ };
910
+ const botSkillsReader = (0, botSkills_1.createBotSkillsReader)(botSkillsSources);
911
+ /**
912
+ * The cap on tracker task agents (`trackers/taskAgentLimit.ts`): General's value, else `settings.json`'s
913
+ * `taskAgents.maxConcurrent` (re-read when the file changes), else the built-in default. `state` is read when asked.
914
+ */
915
+ const taskAgentLimitSources = {
916
+ getGeneralValue: () => state.getTaskAgentsMaxConcurrentGlobalValue(),
917
+ getSettingsValue: (0, instanceSettings_1.createTaskAgentsMaxConcurrentReader)(() => (0, instanceSettings_1.getInstanceSettingsPath)(), (line) => console.error(line)),
918
+ };
919
+ /** The slots of the tracker task agents and their wait list; built at boot, once the request ledger is loaded. */
920
+ let taskAgentSlots = null;
921
+ /** `settings.json`'s `todo` (on / off, the reminder's interval), re-read when the file changes. */
922
+ const readTodoSettings = (0, instanceSettings_1.createTodoSettingsReader)(() => (0, instanceSettings_1.getInstanceSettingsPath)(), (line) => console.error(line));
923
+ /** The staleness reminder's interval now; `null` = off (the feature or its reminder). */
924
+ function getTodoReminderIntervalMsNow() {
925
+ return (0, nag_1.getTodoReminderIntervalMs)(readTodoSettings(), ENV.testTimings.todoReminderIntervalMs);
926
+ }
927
+ /**
928
+ * The reminder line a prompt forwarded now carries (D11 form a), or `null`; taking it counts as delivered. The lists
929
+ * are a courtesy to the agent: a failure here is logged and the prompt goes on without the line.
930
+ */
931
+ function takeTodoPromptReminder(key) {
932
+ try {
933
+ return getTodoRegistry().takePromptReminder(key, getTodoReminderIntervalMsNow(), Date.now());
934
+ }
935
+ catch (e) {
936
+ console.error(`[todos] ${(0, sessionKey_1.keyToString)(key)}: the reminder line could not be taken:`, e instanceof Error ? e.message : e);
937
+ return null;
938
+ }
939
+ }
940
+ /** The staleness reminder's poll (`todos/reminderEngine.ts`), built at boot and started once the sessions are restored. */
941
+ let todoReminderEngine = null;
942
+ let todoRepositories = null;
943
+ /** Which folders are one git repository (`todos/projects.ts`), cached; the root commits persist in `DATA_DIR/todos`. */
944
+ function getTodoRepositories() {
945
+ todoRepositories ??= (0, projects_1.createRepositoryIdentityReader)({
946
+ runGit: projects_1.runGitInFolder,
947
+ rootCommitStore: (0, projects_1.createRepositoryRootCommitFile)((0, store_1.getTodosDir)(getDataDir())),
948
+ });
949
+ return todoRepositories;
950
+ }
951
+ /** What the boards need of the Jira connector (D55, D57); set at boot when it is loaded. */
952
+ let todoJiraConversations = null;
953
+ /**
954
+ * A bound conversation as the project grouping sees it before git is asked: complete (a Jira issue, a folder that does
955
+ * not resolve), or still needing its folder's repository.
956
+ */
957
+ function describeTodoProjectMember({ key, data }) {
958
+ const conversationKey = (0, sessionKey_1.keyToString)(key);
959
+ if (key.platform === 'jira') {
960
+ return { member: { conversationKey, workDir: null, identity: null, jiraRepositoryNames: todoJiraConversations?.getConversationRepositoryNames(key) ?? null } };
961
+ }
962
+ const decision = (0, validation_1.resolveBoundWorkDir)(ENV.workRoot, data);
963
+ return decision.kind === 'proceed' ? { conversationKey, workDir: decision.workDir } : { member: { conversationKey, workDir: null, identity: null } };
964
+ }
965
+ /** The instance's project boards (D15, D16, D57), waiting for git where a folder's read is due. */
966
+ async function listTodoProjects() {
967
+ const repositories = getTodoRepositories();
968
+ const members = await Promise.all(state.listBindings().map(async (binding) => {
969
+ const described = describeTodoProjectMember(binding);
970
+ if ('member' in described)
971
+ return described.member;
972
+ const identity = await repositories.read(described.workDir);
973
+ return identity === undefined ? undefined : { ...described, identity };
974
+ }));
975
+ return (0, projects_1.groupTodoProjects)(members.filter((member) => member !== undefined), state.getTodoProjectOverrides());
976
+ }
977
+ /**
978
+ * The project boards as known NOW, never waiting for git and never yielding (a prompt, the mid-turn poll): `null` while a
979
+ * bound folder was never read — its read starts now, with every other one due.
980
+ */
981
+ function peekTodoProjects() {
982
+ const repositories = getTodoRepositories();
983
+ const members = state.listBindings().map((binding) => {
984
+ const described = describeTodoProjectMember(binding);
985
+ if ('member' in described)
986
+ return described.member;
987
+ const identity = repositories.peek(described.workDir);
988
+ return identity === undefined ? undefined : { ...described, identity };
989
+ });
990
+ return members.every((member) => member !== undefined) ? (0, projects_1.groupTodoProjects)(members, state.getTodoProjectOverrides()) : null;
991
+ }
992
+ /** The bound folders' due repository reads start in the background (boot, a bind), so the next prompts find the grouping known. */
993
+ function warmTodoProjects() {
994
+ if (!readTodoSettings().isEnabled)
995
+ return;
996
+ try {
997
+ peekTodoProjects();
998
+ }
999
+ catch (e) {
1000
+ console.error('[todos] the bound folders\' repositories could not be read:', e instanceof Error ? e.message : e);
1001
+ }
1002
+ }
1003
+ /** The project of `key`'s conversation, waiting for git; `null` = in none. */
1004
+ async function getTodoProjectOf(key) {
1005
+ const conversationKey = (0, sessionKey_1.keyToString)(key);
1006
+ return (await listTodoProjects()).find((project) => project.memberKeys.includes(conversationKey)) ?? null;
1007
+ }
1008
+ /** The project of `key`'s conversation as known now; `null` = in none, `undefined` = not known without waiting. */
1009
+ function peekTodoProjectOf(key) {
1010
+ const conversationKey = (0, sessionKey_1.keyToString)(key);
1011
+ const projects = peekTodoProjects();
1012
+ return projects === null ? undefined : (projects.find((project) => project.memberKeys.includes(conversationKey)) ?? null);
1013
+ }
1014
+ /** How a conversation is named on a board and in the picker: `#topic (folder)`, or the issue `PROJ-123 «summary»` (D55). */
1015
+ function getTodoConversationLabel(conversationKey) {
1016
+ const key = (0, sessionKey_1.tryKeyFromString)(conversationKey);
1017
+ if (key === null)
1018
+ return conversationKey;
1019
+ if (key.platform === 'jira') {
1020
+ const title = todoJiraConversations?.getConversationTitle(key) ?? null;
1021
+ return title === null ? key.thread : `${key.thread} «${title}»`;
1022
+ }
1023
+ const binding = state.getBinding(key);
1024
+ return `#${binding?.topicName ?? `topic ${key.thread}`}${binding ? ` (${binding.subdir})` : ''}`;
1025
+ }
1026
+ function getTodoSessionActivity(conversationKey) {
1027
+ const key = (0, sessionKey_1.tryKeyFromString)(conversationKey);
1028
+ if (key === null)
1029
+ return 'asleep';
1030
+ const adapter = (0, createAdapter_1.getThreadAdapter)(key);
1031
+ if (!adapter.checkIsActive(key))
1032
+ return 'asleep';
1033
+ return adapter.checkIsBusy?.(key) ? 'working' : 'idle';
1034
+ }
1035
+ let todoBoard = null;
1036
+ /** The project boards (`todos/board.ts`), built on first use like the registry. */
1037
+ function getTodoBoard() {
1038
+ todoBoard ??= (0, board_1.createTodoBoard)({
1039
+ store: getTodoRegistry().getStore(),
1040
+ getProjectOf: getTodoProjectOf,
1041
+ peekProjectOf: peekTodoProjectOf,
1042
+ getPeerLabel: getTodoConversationLabel,
1043
+ getSessionActivity: getTodoSessionActivity,
1044
+ getNowMs: Date.now,
1045
+ });
1046
+ return todoBoard;
1047
+ }
1048
+ /** The agent lost its context: its next prompt carries the whole board (D20). Logged, never thrown, on a lifecycle path. */
1049
+ function noteTodoFreshContext(key) {
1050
+ try {
1051
+ getTodoBoard().noteFreshContext(key);
1052
+ }
1053
+ catch (e) {
1054
+ console.error(`[todos] ${(0, sessionKey_1.keyToString)(key)}: the board's seen state could not be reset:`, e instanceof Error ? e.message : e);
1055
+ }
1056
+ }
1057
+ let todoMidTurn = null;
1058
+ /** Mid-turn delivery of the board (`todos/midTurn.ts`): the Claude inbox hook, the note into a running OpenCode turn. */
1059
+ function getTodoMidTurn() {
1060
+ todoMidTurn ??= (0, midTurn_1.createTodoMidTurn)({
1061
+ board: getTodoBoard(),
1062
+ inboxDir: (0, midTurn_1.getTodoInboxDir)(getDataDir()),
1063
+ getClaudeSessionId: (key) => {
1064
+ const adapterName = (0, createAdapter_1.getThreadAdapterNameRaw)(key) ?? state.getAgent(key)?.name;
1065
+ return adapterName !== undefined && (0, createAdapter_1.checkIsClaudeBackend)(adapterName) ? getResumableSessionId(key) : null;
1066
+ },
1067
+ // Into the running turn only (D21): never a new turn, never through the prompt path that resets the turn.
1068
+ postRunningTurnNote: async (key, text) => (0, createAdapter_1.getThreadAdapter)(key).queueNoteIntoRunningTurn?.(key, text) ?? false,
1069
+ });
1070
+ return todoMidTurn;
1071
+ }
1072
+ let todoTools = null;
1073
+ function getTodoTools() {
1074
+ todoTools ??= (0, tools_1.createTodoTools)({ registry: getTodoRegistry(), board: getTodoBoard(), onBoardShown: discardTodoInbox });
1075
+ return todoTools;
1076
+ }
1077
+ /** A turn start the bot makes carries the board itself: a waiting mid-turn inbox is withdrawn (or counted, if taken). */
1078
+ function discardTodoInbox(key) {
1079
+ try {
1080
+ getTodoMidTurn().discardInbox(key);
1081
+ }
1082
+ catch (e) {
1083
+ console.error(`[todos] ${(0, sessionKey_1.keyToString)(key)}: the mid-turn inbox could not be withdrawn:`, e instanceof Error ? e.message : e);
1084
+ }
1085
+ }
1086
+ /**
1087
+ * The board block a prompt forwarded now carries (D20), or `null`; taking it counts as seen. A failure never stops the prompt.
1088
+ * Synchronous: it sits between the forward's decision and the write (see `TodoBoard.takePromptBoard`).
1089
+ */
1090
+ function takeTodoPromptBoard(key) {
1091
+ if (!readTodoSettings().isEnabled)
1092
+ return null;
1093
+ try {
1094
+ return getTodoBoard().takePromptBoard(key);
1095
+ }
1096
+ catch (e) {
1097
+ console.error(`[todos] ${(0, sessionKey_1.keyToString)(key)}: the project board could not be built:`, e instanceof Error ? e.message : e);
1098
+ return null;
1099
+ }
1100
+ }
853
1101
  const threadMessageStates = new Map();
854
1102
  const outputQueues = new Map();
855
1103
  const pendingQuestions = new Map();
@@ -1043,10 +1291,12 @@ function onThreadActivityWhileQuestionPending(key) {
1043
1291
  }
1044
1292
  /**
1045
1293
  * @description Re-post the current pending question as the newest thread message
1046
- * (S3): delete the old question message, then send the current question again
1047
- * via {@link postPendingQuestionAt} (which re-renders with S1 descriptions /
1048
- * S2 current index and re-owns `messageId`). No-op if the question was answered
1049
- * meanwhile, or if it is already the last message (the loop guard re-checked at
1294
+ * (S3): send the current question again via {@link postPendingQuestionAt} (which
1295
+ * re-renders with S1 descriptions / S2 current index and re-owns `messageId`), then
1296
+ * end the old message. The ORIGINAL message of the question is its record: it is closed as «asked again
1297
+ * below» (text stays, buttons go) and never deleted. A copy that an earlier re-post made is only a duplicate of that record
1298
+ * and is deleted, so a question that waits through a long stream of output leaves two messages, not a pile. No-op if the
1299
+ * question was answered meanwhile, or if it is already the last message (the loop guard re-checked at
1050
1300
  * fire time, since the debounce window may have closed the gap).
1051
1301
  */
1052
1302
  async function repostPendingQuestionToBottom(key) {
@@ -1067,12 +1317,77 @@ async function repostPendingQuestionToBottom(key) {
1067
1317
  if (s.isPostInFlight)
1068
1318
  return;
1069
1319
  const oldMessageId = pending.messageId;
1070
- if (oldMessageId !== null) {
1320
+ const oldQuestion = pending.data.questions[pending.currentIndex];
1321
+ // The first message of the question is its record. A state that predates the mark has none: the message found is the
1322
+ // original then, which is never deleted.
1323
+ if (oldMessageId !== null)
1324
+ pending.originalMessageId ??= oldMessageId;
1325
+ await postPendingQuestionAt(key);
1326
+ // The old message is touched only once there IS a copy below. A post that failed leaves the question where it was (its
1327
+ // message id unchanged) and still answerable there.
1328
+ const newMessageId = pendingQuestions.get(kStr)?.messageId ?? null;
1329
+ if (oldMessageId === null || !oldQuestion || newMessageId === null || newMessageId === oldMessageId)
1330
+ return;
1331
+ if (oldMessageId === pending.originalMessageId) {
1332
+ // The original: it stays as the record of the question, saying where the live copy is.
1333
+ await closeQuestionMessage(key, oldMessageId, oldQuestion, 'askedAgain');
1334
+ }
1335
+ else {
1336
+ // An earlier copy: the record already tells the question, so this one only duplicates it.
1071
1337
  await deleteThreadMessage(key, oldMessageId);
1072
1338
  }
1073
- await postPendingQuestionAt(key);
1074
1339
  });
1075
1340
  }
1341
+ /**
1342
+ * @description The note a question's message ends with, by how it ended (see {@link QuestionClosing}). An answer's
1343
+ * note is built where the chosen label is known (`agent.question_answered_note`).
1344
+ */
1345
+ const questionClosingNoteKeys = {
1346
+ unanswered: 'agent.question_unanswered_note',
1347
+ askedAgain: 'agent.question_asked_again_note',
1348
+ };
1349
+ /**
1350
+ * @description End a question's message in the topic WITHOUT deleting it: the buttons are replaced by `note`, and the
1351
+ * header, the question and its options stay, so what the agent asked and how it ended can still be read. Resolves
1352
+ * whether the text edit landed. Every way a question ends goes through this — a delete would take the record away.
1353
+ *
1354
+ * Telegram that cannot parse the Markdown render gets the plain one (no escape marks shown), and a question too long for
1355
+ * the note gets a shortened plain one. An edit that still fails (the message gone, say) leaves the old text, and the
1356
+ * buttons would stay on it dead — so they are taken off by themselves.
1357
+ */
1358
+ async function editQuestionMessageToClosed(key, messageId, question, note) {
1359
+ const markdownText = (0, openCodeQuestionFlow_1.buildClosedQuestionText)(question, note, escapeMarkdown);
1360
+ const plainText = (0, openCodeQuestionFlow_1.buildClosedQuestionPlainText)(question, note, messageSplit_1.MAX_MESSAGE_LEN);
1361
+ // A question that filled a message when it was asked has no room for the note under it, and Telegram refuses the text:
1362
+ // the note is the result, so the body gives way (descriptions first) and goes out as plain text, which cutting cannot break.
1363
+ const isEdited = markdownText.length <= messageSplit_1.MAX_MESSAGE_LEN
1364
+ ? await editThreadMessage(key, messageId, markdownText, { parse_mode: 'Markdown' }, plainText)
1365
+ : await editThreadMessage(key, messageId, plainText);
1366
+ if (!isEdited)
1367
+ await editThreadKeyboard(key, messageId, null, 'questionClose');
1368
+ return isEdited;
1369
+ }
1370
+ /**
1371
+ * {@link editQuestionMessageToClosed} for a question that ended some way ({@link QuestionEnd}). The note speaks the topic's
1372
+ * language whatever called it — a boot or a session teardown runs in no request, so no locale is set by one.
1373
+ */
1374
+ async function closeQuestionMessage(key, messageId, question, ending) {
1375
+ const note = withThreadLocale(key, () => (typeof ending === 'string' ? (0, i18n_1.t)(questionClosingNoteKeys[ending]) : (0, i18n_1.t)('agent.question_answered_note', { answer: ending.answer })));
1376
+ return editQuestionMessageToClosed(key, messageId, question, note);
1377
+ }
1378
+ /**
1379
+ * @description End a question message the bot holds no record of — a copy a restart left while its re-ask was being sent, an
1380
+ * older copy whose close Telegram refused — when somebody taps its buttons: its own text stays (`messageText`, as the tap
1381
+ * delivered it) and «not answered» takes the place of the buttons, so the question does not end with no result at all. Without
1382
+ * the text, or when the note does not fit under it, only the buttons come off. Resolves whether the message was changed at all
1383
+ * (the text edit or the removal of the buttons landed): when not, the buttons are still there and the next tap tries again.
1384
+ */
1385
+ async function closeOrphanQuestionMessage(key, messageId, messageText) {
1386
+ const note = withThreadLocale(key, () => (0, i18n_1.t)(questionClosingNoteKeys.unanswered));
1387
+ const closedText = messageText === null ? null : `${messageText}\n\n${note}`;
1388
+ const isEdited = closedText !== null && closedText.length <= messageSplit_1.MAX_MESSAGE_LEN && await editThreadMessage(key, messageId, closedText);
1389
+ return isEdited || editThreadKeyboard(key, messageId, null, 'reask');
1390
+ }
1076
1391
  // ── auto-retry after a provider-side API error (plan S4/S5/S6) ──
1077
1392
  /** One minute in ms — used only to render the "in N min" notices. */
1078
1393
  const apiRetryMsPerMinute = 60_000;
@@ -1172,6 +1487,13 @@ const wedgeRecoveriesInFlight = new Set();
1172
1487
  */
1173
1488
  function handleApiError(key, cls, options = {}) {
1174
1489
  const k = (0, sessionKey_1.keyToString)(key);
1490
+ // The copy of a limit error that the adopt's replay reads: it ended a turn BEFORE this boot, and the boot recovery has armed
1491
+ // that very episode (with the error's own time, which the replay cannot know). Handling it again announced a limit for a
1492
+ // window that was over, and resumed the topic a second time.
1493
+ if (cls.isReplayed === true && cls.kind === 'usageLimit' && limitEpisodesOwnedByRecovery.has(k)) {
1494
+ console.log(`[limitRecovery] ${k}: the replayed limit error is the episode the recovery re-armed — not handled twice`);
1495
+ return;
1496
+ }
1175
1497
  // A used-up API credit balance: no wait refills it — the account is marked, and the chain or the operator takes over.
1176
1498
  if (cls.kind === 'usageLimit' && cls.isBalanceExhausted === true) {
1177
1499
  void handleBalanceExhausted(key, cls);
@@ -1927,8 +2249,45 @@ const awaitingSessionSelection = new Set();
1927
2249
  * on an invalid name so the user can retry.
1928
2250
  */
1929
2251
  const awaitingFolderName = new Set();
2252
+ /**
2253
+ * The `/bind` picker message a «create new folder» tap came from, per thread: once the typed name is bound, that picker is
2254
+ * deleted. Written only together with the arming of {@link awaitingFolderName}, and read only by the bind that arming
2255
+ * leads to — so a leftover entry of an abandoned attempt is overwritten by the next tap.
2256
+ */
2257
+ const bindPickerMessageIds = new Map();
2258
+ /** The `/bind` picker messages a bind or a leave holds (in flight or done), so a second tap on the same keyboard acts on nothing again. */
2259
+ const bindPickerClaims = new bindPickerClaims_1.BindPickerClaims(ENV.testTimings.bindPickerClaimLimit);
2260
+ /**
2261
+ * The messages open in each topic that offer a bind or ask for a folder's name (every `/bind` picker, the «not bound» replies
2262
+ * that carry one, the «create new folder» prompt): any bind or leave of the topic deletes them all — see
2263
+ * {@link closeOpenBindMessages}. In memory: a picker sent before a restart is not known, and only its own tap removes it.
2264
+ */
2265
+ const openBindMessages = new openBindMessages_1.OpenBindMessages();
2266
+ /**
2267
+ * The welcome each topic last got (per topic, newest last): its ▶️ Claude / ▶️ OpenCode / 📋 Resume buttons act on the topic's
2268
+ * CURRENT folder whatever the message says, so the next bind or a leave strips the buttons of the ones before. In memory, like
2269
+ * {@link openBindMessages}: a welcome sent before a restart keeps its buttons. The same per-topic, bounded, timestamped list of
2270
+ * message ids as {@link openBindMessages}, reused: these messages are never deleted, only stripped.
2271
+ */
2272
+ const openWelcomeMessages = new openBindMessages_1.OpenBindMessages();
1930
2273
  /** Max sessions shown in a `/sessions` list (Telegram-friendly, plan S3). */
1931
2274
  const sessionsDisplayLimit = 10;
2275
+ /**
2276
+ * @description Forum-topic names that arrived via `forum_topic_created`
2277
+ * but couldn't be processed because the creator wasn't an authorised user
2278
+ * (a group admin/creator) (audit S2 / #5). We can't read a thread's title
2279
+ * later via Telegram Bot API in any portable way, so keeping the name here
2280
+ * lets the first message from an authorised user trigger fuzzy auto-bind.
2281
+ *
2282
+ * Also holds the name of a topic an authorised user made (or renamed) that matched
2283
+ * no folder, so the binding made through the picker later can keep it
2284
+ * (`PendingTopicName.isFromAdmin`: only such a name may reach a binding).
2285
+ *
2286
+ * In-memory by design: this is a UX nicety, not a security boundary —
2287
+ * losing the cache on restart just means the user has to bind manually
2288
+ * once. The TTL guards against unbounded growth in a busy group where
2289
+ * non-authorised members keep creating topics.
2290
+ */
1932
2291
  const pendingTopicNames = new Map();
1933
2292
  const PENDING_TOPIC_NAME_TTL_MS = 24 * 60 * 60 * 1000;
1934
2293
  /**
@@ -1958,6 +2317,10 @@ const threadContextMarkers = new Map();
1958
2317
  */
1959
2318
  function clearThreadContextMarker(key) {
1960
2319
  threadContextMarkers.delete((0, sessionKey_1.keyToString)(key));
2320
+ // The same boundary for the project board: the next turn start carries it whole (D20), and an inbox no tool call of
2321
+ // this session will take is withdrawn.
2322
+ noteTodoFreshContext(key);
2323
+ discardTodoInbox(key);
1961
2324
  }
1962
2325
  function getThreadMessageState(key) {
1963
2326
  const k = (0, sessionKey_1.keyToString)(key);
@@ -2004,7 +2367,7 @@ function getOutputQueueState(key) {
2004
2367
  const k = (0, sessionKey_1.keyToString)(key);
2005
2368
  let s = outputQueues.get(k);
2006
2369
  if (!s) {
2007
- s = { pendingOutput: null, pendingIsContinuation: false, isProcessing: false, debounceTimer: null };
2370
+ s = { pendingSegments: [], isProcessing: false, debounceTimer: null };
2008
2371
  outputQueues.set(k, s);
2009
2372
  }
2010
2373
  return s;
@@ -2025,7 +2388,7 @@ function checkIsOutputStreaming(key) {
2025
2388
  const q = outputQueues.get((0, sessionKey_1.keyToString)(key));
2026
2389
  if (!q)
2027
2390
  return false;
2028
- return q.pendingOutput !== null || q.isProcessing || q.debounceTimer !== null;
2391
+ return (0, outputFlushPlan_1.checkHasPendingOutput)(q) || q.isProcessing || q.debounceTimer !== null;
2029
2392
  }
2030
2393
  function getStatusCoalesceState(key) {
2031
2394
  const k = (0, sessionKey_1.keyToString)(key);
@@ -2088,11 +2451,15 @@ function clearThreadQueues(key) {
2088
2451
  // `/quit` release, session `closed`/`stopped`, `/unbind`, and topic-delete —
2089
2452
  // all route through here. CRITICAL ORDER: finalize BEFORE
2090
2453
  // `clearThreadOutputQueues`, because the group finalize drains
2091
- // `q.pendingOutput` and the clear would otherwise null it first (S2). Both
2454
+ // the pending segments and the clear would otherwise drop them first (S2). Both
2092
2455
  // transports capture + reset their in-flight state SYNCHRONOUSLY before any
2093
2456
  // await, so the clear below can't race the drain. Fire-and-forget.
2094
2457
  void getOutboundFor(key).finalize(key);
2095
- (0, clearThreadOutputQueues_1.clearThreadOutputQueues)(outputQueues.get(k), statusCoalescers.get(k));
2458
+ const queue = outputQueues.get(k);
2459
+ // Text that took the new-message mark and is dropped unsent leaves the mark with the thread again.
2460
+ if (queue?.pendingSegments.some((segment) => segment.startsNewMessage))
2461
+ markNeedsNewMessage(key);
2462
+ (0, clearThreadOutputQueues_1.clearThreadOutputQueues)(queue, statusCoalescers.get(k));
2096
2463
  // A new session starts with empty context — forget the last-sent outputs so
2097
2464
  // the identical-output backstop can't suppress a legitimate repeat across a
2098
2465
  // session boundary (the same convergence point that clears the other
@@ -2365,6 +2732,7 @@ function clearInMemoryThreadState(key) {
2365
2732
  disconnectProviderLists.delete(pickerKey);
2366
2733
  }
2367
2734
  awaitingFolderName.delete(k);
2735
+ bindPickerMessageIds.delete(k);
2368
2736
  pinnedStatusTextCache.delete(k);
2369
2737
  statusCoalescers.delete(k);
2370
2738
  // `clearThreadQueues` → `finalizeInFlight` above already finalized any accumulated
@@ -2764,11 +3132,16 @@ async function replyToThread(key, text, extra = {}, options = {}) {
2764
3132
  const send = () => bot.telegram.sendMessage((0, sessionKeyCodec_1.getTelegramChatId)(key), text, sendExtra);
2765
3133
  return options.unpaced ? (0, rateLimiter_1.sendUnpaced)(key, send) : (0, rateLimiter_1.enqueueSend)(key, send);
2766
3134
  };
2767
- try {
2768
- const sent = await sendOnce(buildSendExtra(key, extra));
3135
+ // A sent message is tracked for `/clear_messages`, and a `/bind` picker is remembered to be closed by the next bind or leave.
3136
+ const trackSent = async (sent) => {
2769
3137
  const messageId = sent.message_id;
2770
3138
  await state.pushMessageId(key, messageId);
3139
+ if ((0, bindPicker_1.checkIsBindPickerExtra)(extra))
3140
+ openBindMessages.remember((0, sessionKey_1.keyToString)(key), messageId, Date.now());
2771
3141
  return messageId;
3142
+ };
3143
+ try {
3144
+ return await trackSent(await sendOnce(buildSendExtra(key, extra)));
2772
3145
  }
2773
3146
  catch (e) {
2774
3147
  // Markdown parse failure fallback. Several `t(...)` templates interpolate
@@ -2786,10 +3159,7 @@ async function replyToThread(key, text, extra = {}, options = {}) {
2786
3159
  const plainExtra = { ...extra };
2787
3160
  delete plainExtra.parse_mode;
2788
3161
  try {
2789
- const sent = await sendOnce(buildSendExtra(key, plainExtra));
2790
- const messageId = sent.message_id;
2791
- await state.pushMessageId(key, messageId);
2792
- return messageId;
3162
+ return await trackSent(await sendOnce(buildSendExtra(key, plainExtra)));
2793
3163
  }
2794
3164
  catch (e2) {
2795
3165
  await handleSendError(key, e2);
@@ -2811,13 +3181,17 @@ async function replyToThread(key, text, extra = {}, options = {}) {
2811
3181
  * Returns `true` on success, `false` if the edit failed for any reason —
2812
3182
  * caller decides whether to fall back to a new `replyToThread`. We don't
2813
3183
  * track edits as new ids (the original was already tracked).
3184
+ *
3185
+ * `plainFallbackText`: the text for the retry without `parse_mode` when Telegram cannot parse the Markdown one — the
3186
+ * same text when absent, which shows a Markdown render's escape marks as they are; a caller that has a real plain
3187
+ * rendering passes it.
2814
3188
  */
2815
- async function editThreadMessage(key, messageId, text, extra = {}) {
3189
+ async function editThreadMessage(key, messageId, text, extra = {}, plainFallbackText = text) {
2816
3190
  if (!checkIsTelegramPrimitiveKey(key, 'editThreadMessage'))
2817
3191
  return false;
2818
- const editOnce = (editExtra) => (0, rateLimiter_1.enqueueSend)(key, () => bot.telegram.editMessageText((0, sessionKeyCodec_1.getTelegramChatId)(key), messageId, undefined, text, editExtra));
3192
+ const editOnce = (editText, editExtra) => (0, rateLimiter_1.enqueueSend)(key, () => bot.telegram.editMessageText((0, sessionKeyCodec_1.getTelegramChatId)(key), messageId, undefined, editText, editExtra));
2819
3193
  try {
2820
- await editOnce(extra);
3194
+ await editOnce(text, extra);
2821
3195
  return true;
2822
3196
  }
2823
3197
  catch (e) {
@@ -2837,7 +3211,7 @@ async function editThreadMessage(key, messageId, text, extra = {}) {
2837
3211
  const plainExtra = { ...extra };
2838
3212
  delete plainExtra.parse_mode;
2839
3213
  try {
2840
- await editOnce(plainExtra);
3214
+ await editOnce(plainFallbackText, plainExtra);
2841
3215
  return true;
2842
3216
  }
2843
3217
  catch (e2) {
@@ -2852,14 +3226,63 @@ async function editThreadMessage(key, messageId, text, extra = {}) {
2852
3226
  return false;
2853
3227
  }
2854
3228
  }
2855
- async function deleteThreadMessage(key, messageId) {
3229
+ /**
3230
+ * @description Replace the inline keyboard of a message of the thread (`null` removes it) through the paced send queue.
3231
+ * Returns `true` when the keyboard is as asked — a no-change edit («message is not modified») counts — and `false`
3232
+ * when the edit failed, which is logged under `logTag` and left to the caller to act on (a re-render may simply skip it).
3233
+ */
3234
+ async function editThreadKeyboard(key, messageId, keyboard, logTag) {
3235
+ if (!checkIsTelegramPrimitiveKey(key, 'editThreadKeyboard'))
3236
+ return false;
3237
+ try {
3238
+ await (0, rateLimiter_1.enqueueSend)(key, () => bot.telegram.editMessageReplyMarkup((0, sessionKeyCodec_1.getTelegramChatId)(key), messageId, undefined, keyboard ?? undefined));
3239
+ return true;
3240
+ }
3241
+ catch (e) {
3242
+ const desc = (0, sendErrorClassifier_1.checkIsApiError)(e) ? (0, sendErrorClassifier_1.getErrorDescription)(e) : '';
3243
+ if (/message is not modified/i.test(desc))
3244
+ return true;
3245
+ console.warn(`[${logTag}] keyboard edit failed:`, desc || e);
3246
+ return false;
3247
+ }
3248
+ }
3249
+ async function deleteThreadMessageDetailed(key, messageId) {
2856
3250
  if (!checkIsTelegramPrimitiveKey(key, 'deleteThreadMessage'))
2857
- return;
3251
+ return 'stayed';
2858
3252
  try {
2859
3253
  await (0, rateLimiter_1.enqueueSend)(key, () => bot.telegram.deleteMessage((0, sessionKeyCodec_1.getTelegramChatId)(key), messageId));
3254
+ return 'deleted';
3255
+ }
3256
+ catch (e) {
3257
+ return (0, telegramError_1.checkIsMessageNotFoundError)(e) ? 'gone' : 'stayed';
3258
+ }
3259
+ }
3260
+ /**
3261
+ * @description Delete a message of the thread. Resolves `false` when it is still there — older than 48 hours, refused, or no
3262
+ * Telegram topic's key — which a caller that needs it gone can act on.
3263
+ */
3264
+ async function deleteThreadMessage(key, messageId) {
3265
+ return (await deleteThreadMessageDetailed(key, messageId)) === 'deleted';
3266
+ }
3267
+ /** The most message ids one `deleteMessages` call takes (the Bot API documents 1-100). */
3268
+ const deleteMessagesPerCallLimit = 100;
3269
+ /**
3270
+ * @description Delete several messages of the thread in ONE paced send (`deleteMessages`: «if some of the specified messages
3271
+ * can't be found, they are skipped»). Resolves `true` when the call went through; `false` when Telegram refused it — the docs do
3272
+ * not say what it does with a message that is there but may not be deleted — and the caller tries the messages one by one.
3273
+ */
3274
+ async function deleteThreadMessages(key, messageIds) {
3275
+ if (!checkIsTelegramPrimitiveKey(key, 'deleteThreadMessages'))
3276
+ return false;
3277
+ try {
3278
+ for (let start = 0; start < messageIds.length; start += deleteMessagesPerCallLimit) {
3279
+ const chunk = messageIds.slice(start, start + deleteMessagesPerCallLimit);
3280
+ await (0, rateLimiter_1.enqueueSend)(key, () => bot.telegram.deleteMessages((0, sessionKeyCodec_1.getTelegramChatId)(key), chunk));
3281
+ }
3282
+ return true;
2860
3283
  }
2861
3284
  catch {
2862
- /* messages older than 48h or already deleted — silently ignore */
3285
+ return false;
2863
3286
  }
2864
3287
  }
2865
3288
  /**
@@ -2941,7 +3364,7 @@ function startTypingLoader(key) {
2941
3364
  const q = outputQueues.get((0, sessionKey_1.keyToString)(key));
2942
3365
  console.warn(`[typing] ${(0, sessionKey_1.keyToString)(key)} force-stop: kept alive by leaked output-queue state — ` +
2943
3366
  `isOutputStreaming=${checkIsOutputStreaming(key)} isAdapterBusy=${checkIsAdapterBusy(key)} ` +
2944
- `pendingOutput=${q?.pendingOutput !== null && q?.pendingOutput !== undefined} ` +
3367
+ `pendingOutput=${q !== undefined && (0, outputFlushPlan_1.checkHasPendingOutput)(q)} ` +
2945
3368
  `isProcessing=${q?.isProcessing === true} debounceTimer=${q?.debounceTimer !== null && q?.debounceTimer !== undefined}`);
2946
3369
  if (q?.debounceTimer) {
2947
3370
  clearTimeout(q.debounceTimer);
@@ -2968,7 +3391,7 @@ function checkIsTypingLoopStuck(key) {
2968
3391
  isAdapterBusy: checkIsAdapterBusy(key),
2969
3392
  isCompacting: checkIsThreadCompacting(key),
2970
3393
  isTransportStreaming: getOutboundFor(key).checkIsDelivering(key),
2971
- hasPendingOutput: q?.pendingOutput != null,
3394
+ hasPendingOutput: q !== undefined && (0, outputFlushPlan_1.checkHasPendingOutput)(q),
2972
3395
  isProcessing: q?.isProcessing === true,
2973
3396
  hasDebounceTimer: q?.debounceTimer != null,
2974
3397
  });
@@ -3033,7 +3456,7 @@ function escapeMarkdown(text) {
3033
3456
  * @description Queue an output text for a thread with debounce.
3034
3457
  *
3035
3458
  * Multiple `output` events from the adapter are coalesced into one pending
3036
- * buffer (see {@link appendPendingOutput}), not overwritten — every emitted
3459
+ * buffer (see {@link addPendingOutput}), not overwritten — every emitted
3037
3460
  * chunk reaches Telegram. A continuation tail concatenates as-is (it may be
3038
3461
  * cut mid-word); a standalone output joins the buffer with `\n`. The first
3039
3462
  * batch's `isContinuation` is recorded so the flush knows whether the buffer
@@ -3069,11 +3492,9 @@ function getOutputDelay(chatId) {
3069
3492
  */
3070
3493
  function queueOutput(key, output, isContinuation = false, isFinal = false, isComplete = false, startsNewParagraph = false) {
3071
3494
  const q = getOutputQueueState(key);
3072
- // The continuation flag of the FIRST batch in a fresh buffer decides whether
3073
- // the whole flush extends the last sent message; later batches only append.
3074
- if (q.pendingOutput === null)
3075
- q.pendingIsContinuation = isContinuation;
3076
- q.pendingOutput = (0, outputFlushPlan_1.appendPendingOutput)(q.pendingOutput, output, isContinuation, startsNewParagraph);
3495
+ // A batch joins the newest pending segment; one queued after a turn boundary was marked starts a segment of its own, which
3496
+ // opens a new message, and leaves the earlier segment to flush where it was going.
3497
+ (0, outputFlushPlan_1.addPendingOutput)(q, getThreadMessageState(key), { output, isContinuation, startsNewParagraph });
3077
3498
  // `isFinal` / `isComplete` still drive the immediate-flush timing below (a
3078
3499
  // finished turn must not wait out the debounce) — they no longer mark the
3079
3500
  // buffer for redelivery (retired with the global send pacer, S4).
@@ -3098,7 +3519,8 @@ function queueOutput(key, output, isContinuation = false, isFinal = false, isCom
3098
3519
  // S3: scale the in-cooldown debounce to the live remaining cooldown.
3099
3520
  remainingCooldownMs: (0, rateLimiter_1.getRateLimitRemainingMs)((0, sessionKeyCodec_1.getTelegramChatId)(key)),
3100
3521
  });
3101
- if (timing === 'now') {
3522
+ // An earlier segment, sealed by a boundary, has nothing more to wait for: its turn is over.
3523
+ if (timing === 'now' || q.pendingSegments.length > 1) {
3102
3524
  // Final frame: flush immediately. `processOutputQueue` already guards
3103
3525
  // `isProcessing`, so this is idempotent if a flush is mid-flight (the
3104
3526
  // pending frame will be picked up by the re-trigger timer it arms).
@@ -3112,14 +3534,14 @@ function queueOutput(key, output, isContinuation = false, isFinal = false, isCom
3112
3534
  }
3113
3535
  async function processOutputQueue(key) {
3114
3536
  const q = getOutputQueueState(key);
3115
- if (q.isProcessing || !q.pendingOutput)
3537
+ if (q.isProcessing || !(0, outputFlushPlan_1.checkHasPendingOutput)(q))
3116
3538
  return;
3117
3539
  q.isProcessing = true;
3118
3540
  try {
3119
- const out = q.pendingOutput;
3120
- const isContinuation = q.pendingIsContinuation;
3121
- q.pendingOutput = null;
3122
- const { unsentRemainder } = await sendOutputImmediate(key, out, isContinuation);
3541
+ const flush = (0, outputFlushPlan_1.takePendingOutput)(q);
3542
+ if (!flush)
3543
+ return;
3544
+ const { unsentRemainder } = await sendOutputImmediate(key, flush.output, flush.isContinuation, flush.startsNewMessage);
3123
3545
  // No silent drop: a chunk dropped on a 429 after the retry-after wait comes
3124
3546
  // back as `unsentRemainder`. Put it BACK at the FRONT of the buffer (it is
3125
3547
  // older than anything that arrived during the await) so the re-trigger flush
@@ -3127,17 +3549,12 @@ async function processOutputQueue(key) {
3127
3549
  // `getUnsentRemainder`, so this never re-sends what already reached Telegram.
3128
3550
  // The remainder restarts a fresh message (its predecessors landed / edited in
3129
3551
  // place already), so it is NOT a continuation.
3130
- if (unsentRemainder) {
3131
- q.pendingOutput =
3132
- q.pendingOutput === null
3133
- ? unsentRemainder
3134
- : (0, outputFlushPlan_1.appendPendingOutput)(unsentRemainder, q.pendingOutput, false);
3135
- q.pendingIsContinuation = false;
3136
- }
3552
+ if (unsentRemainder)
3553
+ (0, outputFlushPlan_1.requeueUnsentRemainder)(q, unsentRemainder);
3137
3554
  }
3138
3555
  finally {
3139
3556
  q.isProcessing = false;
3140
- if (q.pendingOutput) {
3557
+ if ((0, outputFlushPlan_1.checkHasPendingOutput)(q)) {
3141
3558
  // Audit S13 / #30: the re-trigger `setTimeout` used to be a bare
3142
3559
  // call whose handle was never stored. A `queueOutput` call between
3143
3560
  // here and the timer firing would `clearTimeout(null)` and
@@ -3149,7 +3566,7 @@ async function processOutputQueue(key) {
3149
3566
  q.debounceTimer = setTimeout(() => {
3150
3567
  q.debounceTimer = null;
3151
3568
  processOutputQueue(key);
3152
- }, getOutputDelay((0, sessionKeyCodec_1.getTelegramChatId)(key)));
3569
+ }, q.pendingSegments.length > 1 ? 0 : getOutputDelay((0, sessionKeyCodec_1.getTelegramChatId)(key)));
3153
3570
  }
3154
3571
  }
3155
3572
  }
@@ -3158,7 +3575,7 @@ async function processOutputQueue(key) {
3158
3575
  * coalesced-but-unsent output against what actually landed, and force-send the
3159
3576
  * remainder so the agent's FINAL answer is never discarded on teardown (S2).
3160
3577
  *
3161
- * Today the group output queue coalesces into `q.pendingOutput` and the
3578
+ * Today the group output queue coalesces into `q.pendingSegments` and the
3162
3579
  * (possibly 429-stretched) debounce flushes it later; a teardown
3163
3580
  * (`idle`/`isFinal` → `handleAgentStopped`, `/new`, `/quit`, `/unbind`,
3164
3581
  * topic-delete) runs `clearThreadOutputQueues` which DISCARDS that buffer — so
@@ -3180,20 +3597,23 @@ async function finalizeGroupOutput(key) {
3180
3597
  const plan = (0, groupFinalizePlan_1.getGroupFinalizePlan)(q);
3181
3598
  if (plan.action === 'noop')
3182
3599
  return; // fully delivered → no-op, no dupe.
3183
- // Snapshot is captured by the plan; null the buffer SYNCHRONOUSLY before the
3600
+ // Snapshot is captured by the plan; empty the buffer SYNCHRONOUSLY before the
3184
3601
  // await so the immediately-following clear / a concurrent flush can't re-send it.
3185
- q.pendingOutput = null;
3602
+ q.pendingSegments = [];
3186
3603
  if (q.debounceTimer) {
3187
3604
  clearTimeout(q.debounceTimer);
3188
3605
  q.debounceTimer = null;
3189
3606
  }
3190
- const { unsentRemainder } = await sendOutputImmediate(key, plan.text, plan.isContinuation);
3191
- // A remainder that still 429'd at teardown is dropped (the buffer is being
3192
- // torn down anyway — unchanged from the pre-S2 discard). Do NOT re-queue: the
3193
- // queue is about to be cleared, and re-queuing would race that clear. At 1/2s
3194
- // global pacing a 429 here is essentially impossible.
3195
- if (unsentRemainder) {
3196
- console.warn(`[send] ${(0, sessionKey_1.keyToString)(key)} finalize remainder 429'd and was dropped at teardown`);
3607
+ for (const segment of plan.segments) {
3608
+ const { unsentRemainder } = await sendOutputImmediate(key, segment.output, segment.isContinuation, segment.startsNewMessage);
3609
+ // A remainder that still 429'd at teardown is dropped (the buffer is being
3610
+ // torn down anyway — unchanged from the pre-S2 discard), and so is everything behind it. Do NOT re-queue: the
3611
+ // queue is about to be cleared, and re-queuing would race that clear. At 1/2s
3612
+ // global pacing a 429 here is essentially impossible.
3613
+ if (unsentRemainder) {
3614
+ console.warn(`[send] ${(0, sessionKey_1.keyToString)(key)} finalize remainder 429'd and was dropped at teardown`);
3615
+ return;
3616
+ }
3197
3617
  }
3198
3618
  }
3199
3619
  /**
@@ -3210,12 +3630,17 @@ async function finalizeGroupOutput(key) {
3210
3630
  * previous text on the same message id, so the user could read only the last
3211
3631
  * tail. Editing is now reserved for explicit continuations of the SAME reply.
3212
3632
  *
3633
+ * `startsNewMessage`: the text was queued after a turn boundary, so it opens a
3634
+ * message of its own — decided when the text was queued ({@link addPendingOutput}),
3635
+ * not read from the thread here, where a flush of EARLIER text finishing after the
3636
+ * boundary would have spent the mark.
3637
+ *
3213
3638
  * On Markdown rejection by Telegram we fall back to plain text (`parse_mode`
3214
3639
  * unset) — the message lands either way. If the in-place edit fails (message
3215
3640
  * deleted / API hiccup) we send every chunk fresh so the full combined text
3216
3641
  * still reaches the user.
3217
3642
  */
3218
- async function sendOutputImmediate(key, output, isContinuation = false) {
3643
+ async function sendOutputImmediate(key, output, isContinuation = false, startsNewMessage = false) {
3219
3644
  // The typing loader is stopped upstream in `handleAgentOutput` (the shared
3220
3645
  // onOutput entry for BOTH transports), so it's not repeated here.
3221
3646
  await deleteStatusMessage(key);
@@ -3223,7 +3648,7 @@ async function sendOutputImmediate(key, output, isContinuation = false) {
3223
3648
  const { chunks: planChunks, shouldEditFirstChunk } = (0, outputFlushPlan_1.getOutputFlushPlan)({
3224
3649
  output,
3225
3650
  isContinuation,
3226
- needsNewMessage: msgState.needsNewMessage,
3651
+ needsNewMessage: startsNewMessage,
3227
3652
  lastMessageId: msgState.lastMessageId,
3228
3653
  lastMessageText: msgState.lastMessageText,
3229
3654
  });
@@ -3261,7 +3686,6 @@ async function sendOutputImmediate(key, output, isContinuation = false) {
3261
3686
  break;
3262
3687
  msgState.lastMessageId = id;
3263
3688
  msgState.lastMessageText = chunks[i];
3264
- msgState.needsNewMessage = false;
3265
3689
  sentCount = i + 1;
3266
3690
  }
3267
3691
  // S3: output just landed below any pending question — bring the question back
@@ -3399,7 +3823,7 @@ const shutdownOutputFlushMaxMs = rateLimiter_1.shutdownDrainMaxMs + 1000;
3399
3823
  * already-emitted answer chunks died in the send pipeline with the
3400
3824
  * process and were never re-sent). Two buffers drain, in order:
3401
3825
  *
3402
- * [A] per-thread COALESCE state — the group/baseline `q.pendingOutput`
3826
+ * [A] per-thread COALESCE state — the group/baseline `q.pendingSegments`
3403
3827
  * buffers (enumerated here from {@link outputQueues}) and the DM
3404
3828
  * transport's active drafts (enumerated via `getInFlightThreadKeys`) —
3405
3829
  * each finalized to a permanent message via the transport's
@@ -3429,7 +3853,7 @@ async function finalizePendingOutputOnShutdown() {
3429
3853
  // queues + the connector's own unfinalized content (deduped by serialised key).
3430
3854
  const pendingKeys = new Map();
3431
3855
  for (const [keyStr, q] of outputQueues) {
3432
- if (q.pendingOutput === null)
3856
+ if (!(0, outputFlushPlan_1.checkHasPendingOutput)(q))
3433
3857
  continue;
3434
3858
  try {
3435
3859
  pendingKeys.set(keyStr, (0, sessionKey_1.keyFromString)(keyStr));
@@ -3447,11 +3871,11 @@ async function finalizePendingOutputOnShutdown() {
3447
3871
  // A DM-surface thread on a non-draft adapter (e.g. terminal) coalesces via
3448
3872
  // the group `queueOutput` path, but its transport finalize handled only the
3449
3873
  // draft — drain any such leftover buffer directly. Group keys were already
3450
- // drained above (their finalize nulls `pendingOutput` synchronously), so
3874
+ // drained above (their finalize empties the pending segments synchronously), so
3451
3875
  // this is a no-op for them.
3452
3876
  const leftoverDrains = [];
3453
3877
  for (const [keyStr, q] of outputQueues) {
3454
- if (q.pendingOutput === null)
3878
+ if (!(0, outputFlushPlan_1.checkHasPendingOutput)(q))
3455
3879
  continue;
3456
3880
  try {
3457
3881
  leftoverDrains.push(finalizeGroupOutput((0, sessionKey_1.keyFromString)(keyStr)));
@@ -3687,9 +4111,18 @@ async function releaseThreadSession(key) {
3687
4111
  // Claude / no pending question.
3688
4112
  (0, createAdapter_1.getThreadAdapter)(key).rejectQuestion?.(key);
3689
4113
  const result = stopAllAdaptersFor(key);
3690
- await state.clearAgentSessionIds(key);
4114
+ await releaseConversationIds(key);
3691
4115
  return result;
3692
4116
  }
4117
+ /**
4118
+ * @description Release a conversation for good: its saved session ids go, so nothing resumes it, and the question the idle
4119
+ * compaction re-asked for it is closed (its message stays, without its buttons). A conversation that is asleep has no process
4120
+ * whose stop would say so, so the release itself has to — every release of a conversation that may be asleep goes through here.
4121
+ */
4122
+ async function releaseConversationIds(key) {
4123
+ await state.clearAgentSessionIds(key);
4124
+ clearThreadCompaction(key);
4125
+ }
3693
4126
  /**
3694
4127
  * @description Switch a thread's adapter and clear the previous adapter's
3695
4128
  * persisted session id, so a later restart can't re-attach to the wrong
@@ -3712,8 +4145,11 @@ async function switchThreadAdapter(key, newName) {
3712
4145
  // Another agent or a shell takes the topic over: the open request belonged to
3713
4146
  // the old conversation, so it closes silently instead of a later wake-up
3714
4147
  // reminding (or, in a terminal, TYPING into) a session that never saw it.
3715
- if (prevName && !(0, createAdapter_1.checkIsSameConversationSwitch)(prevName, newName))
4148
+ if (prevName && !(0, createAdapter_1.checkIsSameConversationSwitch)(prevName, newName)) {
3716
4149
  cancelConversationRequest(key);
4150
+ // Nor does the conversation left behind get its re-asked question answered: its buttons would send the label into the new one.
4151
+ closeReAskedQuestion(key);
4152
+ }
3717
4153
  let switchCutOffWork = null;
3718
4154
  if (prevName && prevName !== newName) {
3719
4155
  try {
@@ -3848,8 +4284,8 @@ function getStartReadyMessage(adapter, subdir, args, model, effort) {
3848
4284
  return (0, i18n_1.t)('agent.ready', { ...vars, infoBlock: getStartReadyInfoBlock(model, effort) });
3849
4285
  }
3850
4286
  /**
3851
- * The bot-MCP tool digest per platform, from the scheduler handle once it is wired
3852
- * at boot; `null` before that (no digest is persisted, which reads as stale).
4287
+ * The bot-MCP tool digest of a conversation (its platform's tools, its «Bot skills» instructions), from the scheduler handle
4288
+ * once it is wired at boot; `null` before that (no digest is persisted, which reads as stale).
3853
4289
  */
3854
4290
  let botMcpToolDigestReader = null;
3855
4291
  /**
@@ -3889,7 +4325,7 @@ function getPerTurnRefusal(key) {
3889
4325
  async function persistSessionStart(key, adapter) {
3890
4326
  await (0, persistAdapterSessionIds_1.persistAdapterSessionIds)(key, adapter, state);
3891
4327
  if ((0, adapterNames_1.checkIsJsonStreamBackend)(adapter.name) && botMcpToolDigestReader) {
3892
- await state.setAgentMcpToolDigest(key, botMcpToolDigestReader(key.platform));
4328
+ await state.setAgentMcpToolDigest(key, botMcpToolDigestReader(key));
3893
4329
  }
3894
4330
  }
3895
4331
  /**
@@ -4231,6 +4667,11 @@ const autoStopUnsupportedLoggedKeys = new Set();
4231
4667
  * and that turn ends here too) or the process still works (a background task,
4232
4668
  * input not yet taken in). The session sleeps; the next trigger resumes it with
4233
4669
  * the prompt cache warm.
4670
+ *
4671
+ * The stop is QUEUED here, in the same tick the adapter marks the turn done (nothing awaits in between): a
4672
+ * `postToSession` that polled the session idle and ensures it again (`postToSession.ts`) therefore always finds
4673
+ * the stop already on the transition queue and runs after it. Keep it that way — an await ahead of the queueing
4674
+ * would let that ensure run beside the stop and forward to the process it ends.
4234
4675
  */
4235
4676
  function handleAgentTurnEnded(key) {
4236
4677
  noteThreadActivity(key);
@@ -4273,7 +4714,7 @@ async function replayBufferedPrompts(key) {
4273
4714
  // like the API-error retry's nudge, hears whether it reached the session).
4274
4715
  await startupPromptBuffer.replayPrompts((0, sessionKey_1.keyToString)(key), {
4275
4716
  isSessionActive,
4276
- forward: (prompt) => forwardPromptToAgent(key, adapter, prompt),
4717
+ forward: async (prompt) => { await forwardPromptToAgent(key, adapter, prompt); },
4277
4718
  });
4278
4719
  if (!isSessionActive)
4279
4720
  return;
@@ -4307,17 +4748,61 @@ async function replayBufferedPrompts(key) {
4307
4748
  * `session.status=retry` will not read queued prompts until the provider retry
4308
4749
  * time, so a new prompt aborts that stale wait before using the current model.
4309
4750
  * An adapter without the method forwards directly.
4751
+ *
4752
+ * Resolves where the prompt went (`LatePromptOutcome`): `forwarded` to the session, `buffered` behind a stop window, `noSession`
4753
+ * (nothing to take it; the person was told), `dropped` (a technical prompt found no live idle session). Only a prompt that waited
4754
+ * for an adopt replay can come out as anything but `forwarded`; most callers have no use for the answer.
4310
4755
  */
4311
4756
  async function forwardPromptToAgent(key, adapter, text, sentAtMs, options = {}) {
4757
+ // An adopted session replays what its process wrote during the downtime first: a prompt written meanwhile would reset
4758
+ // the answer and error state those frames still use, and the replayed text would join the new reply's message (the
4759
+ // «new message» mark would go to the replayed text). Nothing is awaited when no replay is pending: the forward then runs
4760
+ // in the caller's tick, as it always did — an await ahead of it would let a stop queued meanwhile begin first.
4761
+ if (!adapter.checkIsAdoptReplayPending?.(key)) {
4762
+ await forwardPromptToLiveAgent(key, adapter, text, sentAtMs, options);
4763
+ return 'forwarded';
4764
+ }
4765
+ await (0, adoptReplayGate_1.waitForAdoptedReplay)(adapter, key);
4766
+ // The wait let the replay run: the replayed turn's end may have queued the per-turn stop, which holds the window open and
4767
+ // ends the process — a prompt written now would go to a process being killed and be lost. Routed again, as a late prompt is.
4768
+ // A technical prompt (the standalone todo reminder) goes only into a session that is live and idle now, and is otherwise
4769
+ // dropped without a word: never buffered, never a resume, never a "no session" line to the person.
4770
+ const isTechnicalPrompt = options.isTechnicalPrompt === true;
4771
+ const outcome = await (0, promptDelivery_1.deliverLatePrompt)({
4772
+ isTechnical: isTechnicalPrompt,
4773
+ checkIsWindowOpen: () => startupPromptBuffer.checkIsStarting((0, sessionKey_1.keyToString)(key)),
4774
+ checkIsSessionActive: () => adapter.checkIsActive(key) && !(isTechnicalPrompt && (adapter.checkIsBusy?.(key) ?? false)),
4775
+ buffer: async () => {
4776
+ await deliverPromptOrBuffer(key, `${options.requestHeader ?? ''}${getPromptWithThreadReplyQuote(key, text, options.replyContext)}`, true);
4777
+ },
4778
+ wake: () => wakeSleepingSessionForPrompt(key),
4779
+ forward: async () => {
4780
+ await forwardPromptToLiveAgent(key, (0, createAdapter_1.getThreadAdapter)(key), text, sentAtMs, options);
4781
+ return 'forwarded';
4782
+ },
4783
+ reportNoSession: async (isAlreadyTold) => {
4784
+ console.warn(`[adopt] ${(0, sessionKey_1.keyToString)(key)}: no session is left to take the prompt that waited for the replay; it is dropped`);
4785
+ if (!isAlreadyTold)
4786
+ await replyToThread(key, (0, i18n_1.t)('agent.no_session'));
4787
+ },
4788
+ });
4789
+ if (outcome === 'dropped')
4790
+ console.log(`[adopt] ${(0, sessionKey_1.keyToString)(key)}: the technical prompt found no live, idle session after the replay wait; it is dropped`);
4791
+ return outcome;
4792
+ }
4793
+ /** @description The forward itself, for a session that is ready to take the prompt: {@link forwardPromptToAgent} minus the replay wait. */
4794
+ async function forwardPromptToLiveAgent(key, adapter, text, sentAtMs, options = {}) {
4312
4795
  // A forwarded prompt is thread activity — reset the compact-on-idle watchdog (F2).
4313
4796
  noteThreadActivity(key);
4797
+ const isTechnicalPrompt = options.isTechnicalPrompt ?? false;
4314
4798
  // Skip slash commands (not worth replaying), the recovery replay itself (keeps
4315
- // the once-per-episode guard intact) and a request reminder (the request's own
4316
- // prompt stays the one to replay).
4799
+ // the once-per-episode guard intact), a request reminder (the request's own
4800
+ // prompt stays the one to replay) and a bot-made technical prompt.
4317
4801
  const isReplayable = (0, wedgeRecovery_1.checkIsReplayablePrompt)({
4318
4802
  isSlashCommand: (0, threadContextPreamble_1.checkShouldSkipPreambleForText)(text),
4319
4803
  isRecoveryReplay: options.isRecoveryReplay ?? false,
4320
4804
  isRequestReminder: options.isRequestReminder ?? false,
4805
+ isTechnicalPrompt,
4321
4806
  });
4322
4807
  // R23: prompts held during a usage-limit wait ride the first such prompt
4323
4808
  // forwarded to a live session once no wait holds them — after it, in arrival
@@ -4366,6 +4851,18 @@ async function forwardPromptToAgent(key, adapter, text, sentAtMs, options = {})
4366
4851
  if (state.checkIsTimestampsEnabled(key) && !(0, threadContextPreamble_1.checkShouldSkipPreambleForText)(text)) {
4367
4852
  promptText = `${(0, isoTimestamp_1.formatIsoLocalOffset)(sentAtMs ?? Date.now())}\n\n${promptText}`;
4368
4853
  }
4854
+ // The project board rides every turn start the bot makes (D20: whole on a fresh context, else only what changed),
4855
+ // then the todo list's staleness reminder rides the next real prompt from any source (D11 form a) — never a slash
4856
+ // command or a shell line; the reminder never rides the standalone reminder itself.
4857
+ const isTodoDecorated = !(0, threadContextPreamble_1.checkShouldSkipPreambleForText)(text) && adapter.name !== 'terminal';
4858
+ if (isTodoDecorated)
4859
+ discardTodoInbox(key);
4860
+ const todoBoardBlock = isTodoDecorated ? takeTodoPromptBoard(key) : null;
4861
+ if (todoBoardBlock !== null)
4862
+ promptText = `${promptText}\n\n${todoBoardBlock}`;
4863
+ const todoReminderLine = isTodoDecorated && !isTechnicalPrompt ? takeTodoPromptReminder(key) : null;
4864
+ if (todoReminderLine !== null)
4865
+ promptText = `${promptText}\n\n${todoReminderLine}`;
4369
4866
  markNeedsNewMessage(key);
4370
4867
  // A new prompt is a new turn: lift the S2 idle-suppress latch and re-base the
4371
4868
  // working-status elapsed so the next busy period shows a fresh 0:00 counter
@@ -4380,7 +4877,8 @@ async function forwardPromptToAgent(key, adapter, text, sentAtMs, options = {})
4380
4877
  // persists while output is streaming OR the agent is busy — self-stopping only
4381
4878
  // when the topic drains + idles.
4382
4879
  getOutboundFor(key).setActivity(key, 'working');
4383
- if (adapter.interruptAndWaitIdle) {
4880
+ // A technical prompt is posted only into an idle session: there is no turn to interrupt or sub-agent to detach.
4881
+ if (adapter.interruptAndWaitIdle && !isTechnicalPrompt) {
4384
4882
  await adapter.interruptAndWaitIdle(key);
4385
4883
  }
4386
4884
  // OpenCode: if a sub-agent is synchronously blocking this session, detach it to
@@ -4388,7 +4886,7 @@ async function forwardPromptToAgent(key, adapter, text, sentAtMs, options = {})
4388
4886
  // the whole (possibly long) sub-agent run — the sub-agent keeps working and its
4389
4887
  // result is injected back when it finishes. No-op for other adapters and when
4390
4888
  // no sub-agent is running. Best-effort; never blocks the forward on failure.
4391
- if (adapter.detachRunningSubagents) {
4889
+ if (adapter.detachRunningSubagents && !isTechnicalPrompt) {
4392
4890
  await adapter.detachRunningSubagents(key);
4393
4891
  }
4394
4892
  adapter.sendInput(key, promptText);
@@ -4567,6 +5065,7 @@ const botCore = {
4567
5065
  getState: () => state,
4568
5066
  replyToThread,
4569
5067
  editThreadMessage,
5068
+ editThreadKeyboard,
4570
5069
  deleteThreadMessage,
4571
5070
  authoriseContext,
4572
5071
  withThreadLocale,
@@ -4575,17 +5074,25 @@ const botCore = {
4575
5074
  cancelConversationRequest,
4576
5075
  wakeSleepingSession,
4577
5076
  };
4578
- const { checkIsThreadCompacting, noteThreadActivity, noteThreadUserActivity, markThreadTurnProducedOutput, clearThreadCompaction, rearmThreadIdleTimer, armDeferredCompaction, checkIsDeferredCompactionArmed, threadsCompacting, reAskedQuestionOptions, registerCompactionCommands, registerCompactionCallbacks, } = (0, compaction_1.createCompaction)({
5077
+ const { checkIsThreadCompacting, noteThreadActivity, noteThreadUserActivity, markThreadTurnProducedOutput, clearThreadCompaction, rearmThreadIdleTimer, armDeferredCompaction, checkIsDeferredCompactionArmed, threadsCompacting, answerReAskedQuestionByNumber, reAskInterruptedQuestion, restoreReAskedQuestion, closeSavedReAskedQuestion, closeReAskedQuestion, registerCompactionCommands, registerCompactionCallbacks, } = (0, compaction_1.createCompaction)({
4579
5078
  ...botCore,
4580
5079
  startTypingLoader,
4581
- forwardPromptToAgent,
5080
+ forwardPromptToAgent: async (key, adapter, text) => { await forwardPromptToAgent(key, adapter, text); },
4582
5081
  pendingQuestions,
4583
5082
  clearPendingQuestion,
5083
+ closeQuestionMessage,
4584
5084
  idleWindowMs: (0, compactOnIdle_1.getAgentIdleMs)(process.env.AGENT_IDLE_MINUTES),
4585
5085
  checkIsLimitWaitArmed: (key) => getArmedApiRetry(key)?.kind === 'usageLimit',
4586
5086
  suspendThreadSession,
4587
5087
  // L-D7: a sleeping per-turn session is woken for its idle compaction — a resume only, never a fresh start.
4588
5088
  resumeSleepingSessionForCompaction: async (key) => (await ensureAgentSession(key, { isResumeOnly: true })).ok && (0, createAdapter_1.getThreadAdapter)(key).checkIsActive(key),
5089
+ getThreadAdapter: createAdapter_1.getThreadAdapter,
5090
+ // An answer to the agent's own question is never a request (S7), even delivered as a prompt.
5091
+ deliverLabel: (key, label) => withThreadLocale(key, () => deliverActivePrompt(key, (0, createAdapter_1.getThreadAdapter)(key), label, null)),
5092
+ resumeSleepingSessionForPrompt: (key) => resumeSleepingSessionForPrompt(key),
5093
+ checkIsStarting: (key) => startupPromptBuffer.checkIsStarting((0, sessionKey_1.keyToString)(key)),
5094
+ closeOrphanQuestionMessage,
5095
+ notePersonActivity,
4589
5096
  });
4590
5097
  /**
4591
5098
  * @description True iff the neutral router owns `name`, i.e. a `/name` message
@@ -4703,6 +5210,11 @@ command('start', async (_ctx, key) => {
4703
5210
  '/quit — quit current agent\n' +
4704
5211
  '/status — show status');
4705
5212
  });
5213
+ /** The `/status` agent line (`Claude Code (claude)`), flagged when the backend is the deprecated tmux one. */
5214
+ function formatStatusAgentLine(label, adapterName) {
5215
+ const agentLine = `${label} (${adapterName})`;
5216
+ return adapterName === adapterNames_1.claudeTmuxAdapterName ? (0, i18n_1.t)('status.thread_agentTmuxDeprecated', { agent: agentLine }) : agentLine;
5217
+ }
4706
5218
  command('status', async (_ctx, key) => {
4707
5219
  // In General the per-thread status is meaningless (no binding ever) — so
4708
5220
  // we promote /status to a workspace-wide overview there. Topical threads
@@ -4725,7 +5237,7 @@ command('status', async (_ctx, key) => {
4725
5237
  const adapter = (0, createAdapter_1.getThreadAdapter)(key);
4726
5238
  const isActive = adapter.checkIsActive(key);
4727
5239
  const adapterName = (0, createAdapter_1.getThreadAdapterNameRaw)(key);
4728
- const agentLine = adapterName ? `${adapter.label} (${adapterName})` : (0, i18n_1.t)('status.thread_no_agent');
5240
+ const agentLine = adapterName ? formatStatusAgentLine(adapter.label, adapterName) : (0, i18n_1.t)('status.thread_no_agent');
4729
5241
  const binding = state.getBinding(key);
4730
5242
  const subdir = binding?.subdir ?? (0, i18n_1.t)('status.thread_no_binding');
4731
5243
  let workDir = null;
@@ -4901,6 +5413,19 @@ command('timezone', async (_ctx, key, parsed) => {
4901
5413
  const view = buildTimezoneRegionView();
4902
5414
  await replyToThread(key, view.text, view.keyboard);
4903
5415
  });
5416
+ /**
5417
+ * @description How the welcome's shared-folder row writes `peerKey`, a topic of the SAME chat as the one being bound: by the
5418
+ * name stored on its binding, else by the link that opens it (a person can follow it; a session key means nothing to them).
5419
+ * The link is the numeric `t.me/c/…` form, which opens a topic for any member of the group. `null` for a topic that can be
5420
+ * neither named nor linked (a thread of a private chat, say) — it is only counted, never shown as an id.
5421
+ */
5422
+ function describeSharedTopic(peerKey) {
5423
+ const name = state.getBinding(peerKey)?.topicName;
5424
+ const link = (0, topicCommand_1.buildTopicLink)({ chatId: (0, sessionKeyCodec_1.getTelegramChatId)(peerKey), username: null }, (0, sessionKeyCodec_1.getTelegramThreadId)(peerKey));
5425
+ if (name === undefined && link === null)
5426
+ return null;
5427
+ return { name, link: link ?? '' };
5428
+ }
4904
5429
  async function applyBinding(key, rawSubdir, options = {}) {
4905
5430
  let subdir;
4906
5431
  try {
@@ -4920,18 +5445,88 @@ async function applyBinding(key, rawSubdir, options = {}) {
4920
5445
  // user should know they're about to join an existing workspace rather
4921
5446
  // than start a fresh one.
4922
5447
  const peers = state.listKeysForSubdir(subdir).filter(k => (0, sessionKeyCodec_1.checkIsTelegramKey)(k) && (0, sessionKey_1.keyToString)(k) !== (0, sessionKey_1.keyToString)(key));
4923
- await state.setBinding(key, subdir, options.topicName !== undefined ? { topicName: options.topicName } : {});
4924
- const message = peers.length > 0
4925
- ? (0, i18n_1.t)('thread.bind_collision', {
4926
- subdir,
4927
- threads: peers.map(k => `\`${(0, sessionKey_1.keyToString)(k)}\``).join(', '),
4928
- })
4929
- : (0, i18n_1.t)('thread.bound', { subdir });
5448
+ const topicName = options.topicName ?? (0, pendingTopicName_1.getTrustedPendingTopicName)(pendingTopicNames.get((0, sessionKey_1.keyToString)(key)), Date.now(), PENDING_TOPIC_NAME_TTL_MS);
5449
+ await state.setBinding(key, subdir, topicName !== undefined ? { topicName } : {});
5450
+ // Bound: the set-aside name has done its part (an unbound topic is the only reader of it), and a «create new folder» wait
5451
+ // armed on a picker is over — the next plain message is nobody's folder name any more.
5452
+ const boundKey = (0, sessionKey_1.keyToString)(key);
5453
+ pendingTopicNames.delete(boundKey);
5454
+ awaitingFolderName.delete(boundKey);
5455
+ bindPickerMessageIds.delete(boundKey);
5456
+ warmTodoProjects();
5457
+ // Only topics of THIS chat are named: a member of one group is not told what another group (or a private chat) calls its
5458
+ // topics. The rest — and a topic of this chat that can be neither named nor linked — are counted.
5459
+ const sharedWith = [];
5460
+ let sharedMore = 0;
5461
+ for (const peerKey of peers) {
5462
+ const described = peerKey.space === key.space ? describeSharedTopic(peerKey) : null;
5463
+ if (described === null)
5464
+ sharedMore += 1;
5465
+ else
5466
+ sharedWith.push(described);
5467
+ }
4930
5468
  // Rebind resume (S8): a thread that was unbound paused its schedules; binding
4931
5469
  // it again (this thread, possibly a different folder) resumes them. Best
4932
5470
  // effort — a folder pick must succeed even if the resume bookkeeping hiccups.
4933
5471
  await resumeThreadSchedulesOnRebind(key).catch((e) => console.warn(`[scheduler] resume-on-rebind failed for ${(0, sessionKey_1.keyToString)(key)}:`, e));
4934
- return { ok: true, message, subdir };
5472
+ return { ok: true, subdir, sharedWith, sharedMore };
5473
+ }
5474
+ /**
5475
+ * @description Answer a bind attempt in the topic: the refusal's message, or — once bound — the rich welcome, the one
5476
+ * message announcing the binding (with the shared-folder row when other topics use the folder, and whatever else the
5477
+ * caller knows of this bind: `notes`). Shared by `/bind <subdir>`, the `bind_<subdir>` picker button and the picker's
5478
+ * «create new folder».
5479
+ */
5480
+ async function replyToBindResult(key, result, notes = {}) {
5481
+ if (!result.ok) {
5482
+ await replyToThread(key, result.message);
5483
+ return;
5484
+ }
5485
+ await sendBindingWelcome(key, result.subdir, { ...notes, sharedWith: result.sharedWith, sharedMore: result.sharedMore });
5486
+ }
5487
+ /**
5488
+ * @description The `/bind` picker is one-shot (§ Inline keyboards): once a folder is bound through it — or the topic left its
5489
+ * folder from it — the message is deleted, since its text («not bound, pick from the list», «currently bound: …») is no
5490
+ * longer true and the welcome replaces it; a stale tap cannot rebind the topic either. A pick that bound nothing never gets
5491
+ * here, and paging or the create-folder prompt leave the picker alone. A message that stayed ({@link deleteThreadMessage}
5492
+ * resolved `false`: older than 48 hours, say) loses at least its buttons.
5493
+ */
5494
+ async function removeBindPicker(key, pickerMessageId) {
5495
+ openBindMessages.forget((0, sessionKey_1.keyToString)(key), pickerMessageId);
5496
+ // A message that is gone has no buttons to strip (and Telegram would only answer «not found»).
5497
+ if (await deleteThreadMessageDetailed(key, pickerMessageId) === 'stayed')
5498
+ await editThreadKeyboard(key, pickerMessageId, null, 'bind');
5499
+ }
5500
+ /**
5501
+ * @description Delete every message still open in the topic that offers a bind or asks for a folder's name — each `/bind`
5502
+ * picker (every plain message in an unbound topic posted another), the «not bound» replies, the «send the folder name»
5503
+ * prompt — once the topic is bound or has left its folder, whichever way that happened (a tap, a typed `/bind <folder>`, the
5504
+ * bot's own auto-bind, a leave): their text is no longer true, and a tap on a stale one would rebind the topic. A message
5505
+ * the bot has no record of (sent before a restart) is not touched.
5506
+ *
5507
+ * Every one of them is held first ({@link BindPickerClaims.retire}, outside the limited claim list), so a tap is refused
5508
+ * whatever becomes of its message; the hold is let go of only for the ones with nothing left to tap. ONE bulk call deletes them
5509
+ * (each single delete is a paced send, and there can be dozens). A message too old for Telegram to delete loses its buttons
5510
+ * instead, and so does every one of a bulk call Telegram refused — no delete per picker — but only the newest `keyboardStripsPerSweepLimit` of them; the
5511
+ * older rest keeps its buttons, refused by the hold (`sweepOpenBindMessages`). The hold lives in memory: after a restart such a
5512
+ * picker works again. Never rejects: the callers that do not wait for it ({@link sendBindingWelcome}) have nothing to catch.
5513
+ */
5514
+ async function closeOpenBindMessages(key) {
5515
+ try {
5516
+ await (0, bindSweep_1.sweepOpenBindMessages)(openBindMessages.take((0, sessionKey_1.keyToString)(key)), Date.now(), {
5517
+ retire: (messageId) => bindPickerClaims.retire(key, messageId),
5518
+ unretire: (messageId) => bindPickerClaims.unretire(key, messageId),
5519
+ deleteMessages: (messageIds) => deleteThreadMessages(key, messageIds),
5520
+ removeKeyboard: (messageId) => editThreadKeyboard(key, messageId, null, 'bind'),
5521
+ });
5522
+ }
5523
+ catch (e) {
5524
+ console.warn(`[bind] closing the open pickers of ${(0, sessionKey_1.keyToString)(key)} failed:`, e);
5525
+ }
5526
+ }
5527
+ /** Remove the buttons of the topic's earlier welcomes (the newest few; see `stripStaleKeyboards`). Never rejects. */
5528
+ async function stripWelcomeKeyboards(key, messageIds) {
5529
+ await (0, bindSweep_1.stripStaleKeyboards)(messageIds, (messageId) => editThreadKeyboard(key, messageId, null, 'welcome'));
4935
5530
  }
4936
5531
  /**
4937
5532
  * @description Pause the AGENT-PROMPT schedules owned by `key` because its topic
@@ -5002,16 +5597,18 @@ async function resumeThreadSchedulesOnRebind(key) {
5002
5597
  * armed-mode branches in the text handler never overlap (only one armed
5003
5598
  * mode is active per thread at a time).
5004
5599
  */
5005
- function armFolderCreation(key) {
5600
+ function armFolderCreation(key, pickerMessageId) {
5006
5601
  const kStr = (0, sessionKey_1.keyToString)(key);
5007
5602
  awaitingModelSelection.delete(kStr);
5008
5603
  awaitingSessionSelection.delete(kStr);
5009
5604
  // The `/topic` prompts are other readers of the next plain message.
5010
5605
  cancelTopicInput(key);
5011
5606
  // A stale pending question would otherwise consume the message AFTER the
5012
- // folder name as an answer to the old (pre-rebind) agent question.
5013
- clearPendingQuestion(key);
5607
+ // folder name as an answer to the old (pre-rebind) agent question. Its message stays, closed as unanswered.
5608
+ retirePendingQuestionInBackground(key);
5014
5609
  awaitingFolderName.add(kStr);
5610
+ if (pickerMessageId !== null)
5611
+ bindPickerMessageIds.set(kStr, pickerMessageId);
5015
5612
  }
5016
5613
  /** Map a `validateNewFolderName` rejection to its localised reply. */
5017
5614
  function mapNewFolderError(reason) {
@@ -5027,10 +5624,10 @@ function mapNewFolderError(reason) {
5027
5624
  /**
5028
5625
  * @description Create a new folder under WORK_ROOT from a user-typed name:
5029
5626
  * validate the name first (no traversal/slashes/dots), then `mkdir` (recursive:
5030
- * an already-existing folder is fine — the caller just uses it, and the topic is
5031
- * told so). A refusal or a filesystem failure is reported to `key`'s topic here;
5032
- * the outcome says which, so the caller knows whether a retry is still wanted
5033
- * (`invalid`) or not (`failed`).
5627
+ * an already-existing folder is fine — the caller just uses it, and says so in its
5628
+ * own way: `alreadyExisted`). A refusal or a filesystem failure is reported to
5629
+ * `key`'s topic here; the outcome says which, so the caller knows whether a retry
5630
+ * is still wanted (`invalid`) or not (`failed`).
5034
5631
  */
5035
5632
  async function createWorkFolder(key, rawName) {
5036
5633
  const validated = (0, folderName_1.validateNewFolderName)(rawName);
@@ -5052,10 +5649,7 @@ async function createWorkFolder(key, rawName) {
5052
5649
  // Folder freshly on disk → the cached subdir list is stale; drop it so the
5053
5650
  // next picker render includes the new folder.
5054
5651
  subdirCache.clear();
5055
- if (alreadyExisted) {
5056
- await replyToThread(key, (0, i18n_1.t)('bind.create_exists', { subdir: validated.name }));
5057
- }
5058
- return { outcome: 'ready', subdir: validated.name };
5652
+ return { outcome: 'ready', subdir: validated.name, alreadyExisted };
5059
5653
  }
5060
5654
  /**
5061
5655
  * @description Create a new folder under WORK_ROOT from a user-typed name and
@@ -5068,21 +5662,36 @@ async function createWorkFolder(key, rawName) {
5068
5662
  * filesystem failure disarms it.
5069
5663
  */
5070
5664
  async function createAndBindFolder(key, rawName) {
5071
- const folder = await createWorkFolder(key, rawName);
5072
- if (folder.outcome === 'invalid')
5073
- return { ok: false };
5074
- if (folder.outcome === 'failed')
5075
- return { ok: true };
5076
- const result = await applyBinding(key, folder.subdir);
5077
- await replyToThread(key, result.message);
5078
- if (result.ok)
5079
- await sendBindingWelcome(key, result.subdir);
5080
- return { ok: true };
5665
+ const kStr = (0, sessionKey_1.keyToString)(key);
5666
+ // Read before the bind: a bind drops it (and the wait) itself; deleted below for a refused one, which does not.
5667
+ const pickerMessageId = bindPickerMessageIds.get(kStr) ?? null;
5668
+ let isNameRefused = false;
5669
+ // The picker this name was asked on is held from BEFORE the folder is made, for the whole bind, like a tap on a folder holds
5670
+ // it: a tap on it that lands behind the typed name is told the picker was used instead of binding the topic a second time, and
5671
+ // a name that arrives when a tap already holds the picker makes no (empty) folder — it is told so, not dropped in silence.
5672
+ const run = await bindPickerClaims.runClaimed(key, pickerMessageId, async () => {
5673
+ const folder = await createWorkFolder(key, rawName);
5674
+ if (folder.outcome === 'invalid') {
5675
+ isNameRefused = true;
5676
+ return false;
5677
+ }
5678
+ if (folder.outcome === 'failed')
5679
+ return false;
5680
+ const result = await applyBinding(key, folder.subdir);
5681
+ bindPickerMessageIds.delete(kStr);
5682
+ if (result.ok && pickerMessageId !== null)
5683
+ await removeBindPicker(key, pickerMessageId);
5684
+ await replyToBindResult(key, result, { isExistingFolder: folder.alreadyExisted });
5685
+ return result.ok;
5686
+ });
5687
+ if (run === 'busy')
5688
+ await replyToThread(key, (0, i18n_1.t)('cb.bind_picker_used'));
5689
+ return { ok: !isNameRefused };
5081
5690
  }
5082
5691
  /**
5083
5692
  * @description Bind a topic the bot has just created (`/topic`) to `subdir` and greet it with the usual binding welcome:
5084
- * the folder's stats and the start buttons. No agent starts by itself: it is started there as in any bound topic. The binding's own notice
5085
- * is left out: its warning about other topics on the folder would warn of what the person just asked for. The topic's name
5693
+ * the folder's stats and the start buttons. No agent starts by itself: it is started there as in any bound topic. The welcome's
5694
+ * shared-folder row is left out (`sharedWith` is not passed on): it would warn of what the person just asked for. The topic's name
5086
5695
  * rides on the binding for the thread-context preamble. Resolves why it could not bind, `null` once it is bound.
5087
5696
  */
5088
5697
  async function bindNewTopic(key, subdir, topicName) {
@@ -5092,6 +5701,22 @@ async function bindNewTopic(key, subdir, topicName) {
5092
5701
  await sendBindingWelcome(key, result.subdir);
5093
5702
  return null;
5094
5703
  }
5704
+ /**
5705
+ * @description Bind a topic the bot matched to the folder `subdirName` by the topic's own name, and welcome it. It goes
5706
+ * through {@link applyBinding} like every other bind (validation, the schedules a rebind resumes, the collision lookup), so
5707
+ * the welcome says why the bot did it AND names the topics already on the folder. The topic's name rides on the binding for
5708
+ * the thread-context preamble. Resolves whether the topic is bound: a refused folder is only logged, and the caller falls
5709
+ * back to the folder picker.
5710
+ */
5711
+ async function autoBindTopicByName(key, subdirName, topicName) {
5712
+ const result = await applyBinding(key, subdirName, topicName ? { topicName } : {});
5713
+ if (!result.ok) {
5714
+ console.warn(`[auto-bind] "${subdirName}" refused for ${(0, sessionKey_1.keyToString)(key)}: ${result.message}`);
5715
+ return false;
5716
+ }
5717
+ await sendBindingWelcome(key, result.subdir, { isAutoBound: true, sharedWith: result.sharedWith, sharedMore: result.sharedMore });
5718
+ return true;
5719
+ }
5095
5720
  command('bind', async (_ctx, key, parsed) => {
5096
5721
  if (checkIsGeneral(key)) {
5097
5722
  await replyToThread(key, (0, i18n_1.t)('bind.in_general'));
@@ -5118,10 +5743,7 @@ command('bind', async (_ctx, key, parsed) => {
5118
5743
  await replyToThread(key, usage, (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!binding));
5119
5744
  return;
5120
5745
  }
5121
- const result = await applyBinding(key, arg);
5122
- await replyToThread(key, result.message);
5123
- if (result.ok)
5124
- await sendBindingWelcome(key, result.subdir);
5746
+ await replyToBindResult(key, await applyBinding(key, arg));
5125
5747
  });
5126
5748
  /**
5127
5749
  * @description Unbind a thread from its folder: stop any live session, drop the
@@ -5142,6 +5764,11 @@ async function unbindThread(key) {
5142
5764
  // Order matters: drop the pin FIRST (while binding.pinnedStatusMessageId
5143
5765
  // is still readable), then stop the session, then wipe the binding.
5144
5766
  await clearPinnedStatus(key);
5767
+ // A question still on screen ends here with its «not answered» note: the stop below is reported only after the process
5768
+ // is gone (json-stream stops in the background), by which time the teardown has cleared the question's state and the
5769
+ // report finds nothing to close — the buttons would stay, dead, with no note.
5770
+ if (pendingQuestions.has(kStr))
5771
+ await retirePendingQuestion(key);
5145
5772
  // Stop any running session before discarding the binding so we don't leave
5146
5773
  // an orphan tmux/SSE stream pointing at a directory we no longer track.
5147
5774
  const adapter = (0, createAdapter_1.getThreadAdapter)(key);
@@ -5160,7 +5787,7 @@ async function unbindThread(key) {
5160
5787
  }
5161
5788
  // Release persisted session ids too — otherwise re-binding this thread
5162
5789
  // later + a bot restart would resurrect the old session the user unbound.
5163
- await state.clearAgentSessionIds(key);
5790
+ await releaseConversationIds(key);
5164
5791
  // Pause this thread's schedules BEFORE dropping the binding: a fire against
5165
5792
  // an unbound topic can't deliver, so the jobs park (paused + disarmed) and
5166
5793
  // the next /bind resumes them. `removeBinding` only touches bindings/agents/
@@ -5480,11 +6107,15 @@ function buildStartAgentKeyboard() {
5480
6107
  }
5481
6108
  /**
5482
6109
  * @description Compose and send the rich post-bind welcome described in
5483
- * plan §20.5: header with the bound subdir, three optional fact rows
5484
- * (CLAUDE.md / .mcp.json / git), and an inline keyboard to start an agent.
5485
- * Falls back gracefully when stats can't be probed.
5486
- */
5487
- async function sendBindingWelcome(key, subdir) {
6110
+ * plan §20.5: header with the bound subdir, the optional rows (why it was bound
6111
+ * when the bot did it by itself, that the folder was there already, CLAUDE.md /
6112
+ * .mcp.json / git, which other topics use the folder), and an inline keyboard to start an agent. It is the ONE
6113
+ * message every way of binding leaves in the topic. Falls back gracefully when
6114
+ * stats can't be probed.
6115
+ */
6116
+ async function sendBindingWelcome(key, subdir, notes = {}) {
6117
+ // The welcomes this topic got before are outdated the moment this one goes out: their buttons would act on the CURRENT folder.
6118
+ const outdatedWelcomeIds = openWelcomeMessages.take((0, sessionKey_1.keyToString)(key)).map((entry) => entry.messageId);
5488
6119
  // Pin the status banner first so the user sees it land near the top of
5489
6120
  // the thread before the slower stats probe finishes. Fire-and-forget —
5490
6121
  // a failed pin (missing permissions, closed topic) just logs a warning
@@ -5499,6 +6130,10 @@ async function sendBindingWelcome(key, subdir) {
5499
6130
  stats = { claudeMdSize: null, mcpServerCount: null, gitBranch: null, gitClean: null };
5500
6131
  }
5501
6132
  const lines = [(0, i18n_1.t)('binding.welcome.header', { subdir })];
6133
+ if (notes.isAutoBound)
6134
+ lines.push((0, i18n_1.t)('binding.welcome.auto_bound'));
6135
+ if (notes.isExistingFolder)
6136
+ lines.push((0, i18n_1.t)('binding.welcome.existed'));
5502
6137
  if (stats.claudeMdSize)
5503
6138
  lines.push((0, i18n_1.t)('binding.welcome.claude_md', { size: stats.claudeMdSize }));
5504
6139
  if (stats.mcpServerCount !== null) {
@@ -5513,12 +6148,23 @@ async function sendBindingWelcome(key, subdir) {
5513
6148
  else if (fs.existsSync(path.join(workDir, '.git')) === false) {
5514
6149
  lines.push((0, i18n_1.t)('binding.welcome.git_none'));
5515
6150
  }
6151
+ const sharedMore = notes.sharedMore ?? 0;
6152
+ if ((notes.sharedWith && notes.sharedWith.length > 0) || sharedMore > 0) {
6153
+ const moreLabel = sharedMore > 0 ? (0, i18n_1.t)('binding.welcome.sharedMore', { count: sharedMore }) : null;
6154
+ lines.push((0, i18n_1.t)('binding.welcome.shared', { threads: (0, bindingWelcome_1.formatSharedTopicList)(notes.sharedWith ?? [], moreLabel) }));
6155
+ }
5516
6156
  lines.push('');
5517
6157
  lines.push((0, i18n_1.t)('binding.welcome.start_prompt'));
5518
- await replyToThread(key, lines.join('\n'), {
6158
+ const welcomeMessageId = await replyToThread(key, lines.join('\n'), {
5519
6159
  parse_mode: 'Markdown',
5520
6160
  ...buildStartAgentKeyboard(),
5521
6161
  });
6162
+ if (welcomeMessageId !== null)
6163
+ openWelcomeMessages.remember((0, sessionKey_1.keyToString)(key), welcomeMessageId, Date.now());
6164
+ // Every bind ends in this welcome, so every bind sweeps here — after the welcome, and NOT waited for: what follows a bind
6165
+ // (an agent start, when the first message was a start phrase) must not hang on the deletions and edits, which are paced sends.
6166
+ void stripWelcomeKeyboards(key, outdatedWelcomeIds);
6167
+ void closeOpenBindMessages(key);
5522
6168
  }
5523
6169
  // `open_sessions` callback wires the [📋 Resume…] button to the `/sessions`
5524
6170
  // flow — same `handleSessionsList` helper the slash command calls into, so
@@ -5740,10 +6386,9 @@ async function handleAgentStart(key, adapterName, args) {
5740
6386
  await replyToThread(key, (0, i18n_1.t)('thread.no_binding'), extra);
5741
6387
  return;
5742
6388
  }
5743
- // "Claude Code" is one user-facing choice with two backends (tmux-scrape vs
5744
- // json-stream); open the thread's picked backend, default tmux-scrape (for now).
5745
- if (adapterName === 'claude')
5746
- adapterName = (0, createAdapter_1.resolveClaudeBackendName)(key);
6389
+ // "Claude Code" is one user-facing choice with several backends (json-stream,
6390
+ // per-turn, the deprecated tmux-scrape); open the thread's picked backend, else the default.
6391
+ adapterName = (0, createAdapter_1.resolveStartAdapterName)(key, adapterName);
5747
6392
  // The other backend, with a conversation to hand over: ask first; the answer makes the switch.
5748
6393
  const targetFamily = (0, backendSwitchPlan_1.getBackendFamily)(adapterName);
5749
6394
  if (targetFamily !== null && await askBeforeCrossBackendSwitch(key, targetFamily, args))
@@ -5785,19 +6430,32 @@ command('claude', (_ctx, key, parsed) => handleStartCommand(parsed, key, 'claude
5785
6430
  command(['opencode', 'oc'], (_ctx, key, parsed) => handleStartCommand(parsed, key, 'opencode'));
5786
6431
  command('terminal', (_ctx, key, parsed) => handleStartCommand(parsed, key, 'terminal'));
5787
6432
  registerProviderCommands();
5788
- /** Human label for a Claude backend name (the two adapters share `label`
6433
+ /** Human label for a Claude backend name (the adapters share `label`
5789
6434
  * "Claude Code", so the picker/notices need a distinguishing name). */
5790
6435
  function getClaudeBackendLabel(name) {
5791
6436
  if (name === adapterNames_1.claudeJsonStreamAdapterName)
5792
6437
  return '⚡ JSON-stream';
5793
6438
  if (name === adapterNames_1.claudePerTurnAdapterName)
5794
6439
  return '🔁 Per-turn';
5795
- return '🖥 Terminal-scrape';
6440
+ return '🖥 tmux';
6441
+ }
6442
+ /** The `/claude_mode` button text of a backend: its label, the deprecated tmux backend marked as such. A notice
6443
+ * keeps the plain label instead — the deprecation hint under it already says it. */
6444
+ function getClaudeBackendButtonLabel(name) {
6445
+ return name === adapterNames_1.claudeTmuxAdapterName ? (0, i18n_1.t)('claudeMode.tmuxButtonLabel') : getClaudeBackendLabel(name);
6446
+ }
6447
+ /** The one-line hint that the tmux backend is deprecated and which backend to use instead. */
6448
+ function getTmuxDeprecatedHint() {
6449
+ return (0, i18n_1.t)('claudeMode.tmuxDeprecatedHint', { recommended: getClaudeBackendLabel((0, createAdapter_1.getDefaultClaudeBackendName)()) });
6450
+ }
6451
+ /** `text` with the tmux deprecation hint under it when `backendName` is the tmux backend; unchanged otherwise. */
6452
+ function appendTmuxDeprecatedHint(backendName, text) {
6453
+ return backendName === adapterNames_1.claudeTmuxAdapterName ? `${text}\n${getTmuxDeprecatedHint()}` : text;
5796
6454
  }
5797
6455
  /** Build the `/claude_mode` picker: one button per Claude backend, `✓` on the
5798
6456
  * current one. Callback data `ccmode_<adapterName>`. */
5799
6457
  function buildClaudeModeKeyboard(current) {
5800
- const buttons = [adapterNames_1.claudeJsonStreamAdapterName, adapterNames_1.claudePerTurnAdapterName, 'claude'].map((name) => telegraf_1.Markup.button.callback(name === current ? `${getClaudeBackendLabel(name)} ✓` : getClaudeBackendLabel(name), `ccmode_${name}`));
6458
+ const buttons = [adapterNames_1.claudeJsonStreamAdapterName, adapterNames_1.claudePerTurnAdapterName, adapterNames_1.claudeTmuxAdapterName].map((name) => telegraf_1.Markup.button.callback(name === current ? `${getClaudeBackendButtonLabel(name)} ✓` : getClaudeBackendButtonLabel(name), `ccmode_${name}`));
5801
6459
  return telegraf_1.Markup.inlineKeyboard(buttons, { columns: 1 });
5802
6460
  }
5803
6461
  /**
@@ -5864,14 +6522,14 @@ command('claude_mode', async (_ctx, key, parsed) => {
5864
6522
  return;
5865
6523
  }
5866
6524
  if (action.kind === 'already') {
5867
- await replyToThread(key, (0, i18n_1.t)('claudeMode.already', { label: getClaudeBackendLabel(action.backendName) }));
6525
+ await replyToThread(key, appendTmuxDeprecatedHint(action.backendName, (0, i18n_1.t)('claudeMode.already', { label: getClaudeBackendLabel(action.backendName) })));
5868
6526
  return;
5869
6527
  }
5870
6528
  if (action.kind === 'switch') {
5871
- await replyToThread(key, await applyClaudeBackendSwitch(key, action.backendName));
6529
+ await replyToThread(key, appendTmuxDeprecatedHint(action.backendName, await applyClaudeBackendSwitch(key, action.backendName)));
5872
6530
  return;
5873
6531
  }
5874
- await replyToThread(key, (0, i18n_1.t)('claudeMode.pick', { label: getClaudeBackendLabel(action.currentBackendName) }), buildClaudeModeKeyboard(action.currentBackendName));
6532
+ await replyToThread(key, (0, i18n_1.t)('claudeMode.pick', { label: getClaudeBackendLabel(action.currentBackendName), hint: getTmuxDeprecatedHint() }), buildClaudeModeKeyboard(action.currentBackendName));
5875
6533
  });
5876
6534
  registerModelCommands();
5877
6535
  const { registerDisplayModeCommands, registerDisplayModeCallbacks, } = (0, displayModes_1.createDisplayModes)(botCore);
@@ -5909,6 +6567,24 @@ const { applyAutoContinueLimits, consumeAutoContinueLimitsPicker, registerAutoCo
5909
6567
  getRequestWakeUpEngine: () => requestWakeUpEngine,
5910
6568
  });
5911
6569
  registerAutoContinueLimitsCommands();
6570
+ // ── «Bot skills» switch (`/bot_skills`): `connectors/telegram/commands/botSkills.ts`, `utils/botSkills.ts` ──
6571
+ const { registerBotSkillsCommands, registerBotSkillsCallbacks } = (0, botSkills_2.createBotSkills)({
6572
+ ...botCore,
6573
+ readBotSkills: botSkillsReader,
6574
+ readBotSkillsInstanceValue: () => (0, botSkills_1.readBotSkillsInstanceValue)(botSkillsSources),
6575
+ checkIsOpenCodeTopic: (key) => ((0, createAdapter_1.getThreadAdapterNameRaw)(key) ?? state.getAgent(key)?.name) === adapterNames_1.openCodeAdapterName,
6576
+ });
6577
+ registerBotSkillsCommands();
6578
+ // ── The cap on tracker task agents (`/task_agents`): `connectors/telegram/commands/taskAgents.ts`, `trackers/` ──
6579
+ const { registerTaskAgentsCommands, registerTaskAgentsCallbacks } = (0, taskAgents_1.createTaskAgents)({
6580
+ ...botCore,
6581
+ readTaskAgentLimit: () => (0, taskAgentLimit_1.readTaskAgentLimit)(taskAgentLimitSources),
6582
+ // A General value just written may raise the cap: the waiting issues start at once.
6583
+ noteTaskAgentLimitChanged: () => taskAgentSlots?.noteLimitMayHaveChanged(),
6584
+ // The slots decide nothing on an instance that serves no tracker: its screen says nothing of them.
6585
+ readTaskAgentStatus: () => ([...ENV.servedPlatforms].some((platform) => platform !== 'telegram') ? taskAgentSlots?.getStatus() ?? null : null),
6586
+ });
6587
+ registerTaskAgentsCommands();
5912
6588
  // ── Claude account profiles (`/account`): `utils/claudeProfiles.ts`, `commands/claudeAccounts.ts` ──
5913
6589
  /**
5914
6590
  * @description The Claude config directory a conversation's NEXT process starts with: its resolved account
@@ -5941,7 +6617,13 @@ async function readClaudeAuthStatusUnder(configDir) {
5941
6617
  }
5942
6618
  catch (e) {
5943
6619
  // A logged-out directory makes the CLI exit non-zero WITH its JSON on stdout: that is the answer.
5944
- return (0, claudeProfiles_1.parseClaudeAuthStatus)((0, claudeProfiles_1.readProcessErrorStdout)(e));
6620
+ const status = (0, claudeProfiles_1.parseClaudeAuthStatus)((0, claudeProfiles_1.readProcessErrorStdout)(e));
6621
+ // No answer at all (a probe killed by its timeout on a loaded host, a CLI that did not start): one line, so a
6622
+ // missing status can be told from a signed-out one.
6623
+ if (status === null) {
6624
+ console.log(`[accounts] the auth status probe under ${configDir ?? 'the default directory'} gave no status: ${(0, claudeProfiles_1.getProcessFailureReason)(e, claudeAuthStatusProbeTimeoutMs)}`);
6625
+ }
6626
+ return status;
5945
6627
  }
5946
6628
  }
5947
6629
  const claudeAuthStatusProbeTimeoutMs = 5000;
@@ -6153,12 +6835,39 @@ function getTopicFallbackPosition(key) {
6153
6835
  const parsed = label ? (0, fallbackChain_1.parseOpenCodeModelLabel)(label) : null;
6154
6836
  return parsed ? { backend: 'opencode', ...parsed } : null;
6155
6837
  }
6838
+ /**
6839
+ * @description Whether the account or model `key` runs on is marked limited now, and whether it is billed to API
6840
+ * credits — what the todo list's standalone reminder never fires into (D12).
6841
+ */
6842
+ function getConversationAccountState(key) {
6843
+ const position = getTopicFallbackPosition(key);
6844
+ if (position === null)
6845
+ return { isLimited: false, isApiBilled: false };
6846
+ const nowMs = Date.now();
6847
+ const checkIsFuture = (instant) => instant !== undefined && Date.parse(instant) > nowMs;
6848
+ if (position.backend === 'opencode')
6849
+ return { isLimited: checkIsFuture(state.getOpenCodeModelLimitedUntil((0, fallbackChain_1.buildOpenCodeModelLabel)(position))), isApiBilled: false };
6850
+ const info = state.getClaudeProfileInfo(position.profile);
6851
+ return { isLimited: checkIsFuture(info?.limitedUntil) || info?.creditExhaustedAt !== undefined, isApiBilled: info?.billing === 'api' };
6852
+ }
6853
+ /**
6854
+ * @description A person wrote in the conversation (a message, voice, file, album; an issue's request): the
6855
+ * compact-on-idle latch clears (D2), the todo reminder's silence after ignored reminders ends (D13), and a question the
6856
+ * idle compaction re-asked is closed as unanswered — they wrote something else instead of tapping it, like a live
6857
+ * question a message cancels, and its buttons must not be tapped later onto whatever the agent asks next. A tap on the
6858
+ * re-asked question takes its record before it gets here, so it is not closed by its own answer.
6859
+ */
6860
+ function notePersonActivity(key) {
6861
+ noteThreadUserActivity(key);
6862
+ getTodoRegistry().notePersonActivity(key);
6863
+ closeReAskedQuestion(key);
6864
+ }
6156
6865
  /**
6157
6866
  * @description What a usage limit on `key` leads to (`utils/fallbackChain.ts`): the rung the topic is on is
6158
6867
  * marked limited until the reset the error named (a Claude profile: else its session window, else the retry's
6159
6868
  * blind delay; an OpenCode model: else the blind delay), the usage snapshots are brought up to date, and the
6160
6869
  * chain's AVAILABLE rungs are listed in priority order. Without a configured chain a Claude topic falls back to
6161
- * the within-Claude headroom ranking (`utils/claudeLimitSwitch.ts`) — the behaviour before the chain existed. An
6870
+ * the within-Claude ranking by the soonest weekly reset (`utils/claudeLimitSwitch.ts`) — the behaviour before the chain existed. An
6162
6871
  * offer is remembered per thread under its episode so only ITS buttons can act on it. `none` for a non-Telegram
6163
6872
  * conversation, a topic with no account to switch from, or when no rung can take over.
6164
6873
  */
@@ -6261,35 +6970,58 @@ function getBalanceSpentLine(from) {
6261
6970
  function readRunningModelBeforeMove(key, from, to) {
6262
6971
  return from?.backend === 'claude' && to.backend === 'claude' ? getThreadClaudeModel(key) : null;
6263
6972
  }
6973
+ /** Where the topic's agent keeps its conversation: its adapter, the bound folder (`null` when unbound) and the persisted session id (`null` when it holds none yet). */
6974
+ function getConversationSource(key) {
6975
+ const adapter = (0, createAdapter_1.getThreadAdapter)(key);
6976
+ const decision = getWorkDirStartDecision(key);
6977
+ const sessionId = (0, ensureSessionPlan_1.getPersistedSessionIdForAdapter)(state.getAgent(key) ?? null, adapter.name, (0, createAdapter_1.checkIsClaudeBackend)(adapter.name));
6978
+ return { adapter, workDir: decision.ok ? decision.workDir : null, sessionId };
6979
+ }
6264
6980
  /**
6265
6981
  * @description The last `limit` turns (oldest first) of the conversation the topic's agent holds, off its own
6266
6982
  * transcript. `[]` when it has none yet (no session id persisted); `null` when they cannot be read (no bound
6267
6983
  * folder, an adapter without a transcript reader, a read that failed).
6268
6984
  */
6269
6985
  async function readConversationTurns(key, limit) {
6270
- const adapter = (0, createAdapter_1.getThreadAdapter)(key);
6271
- const decision = getWorkDirStartDecision(key);
6272
- const sessionId = (0, ensureSessionPlan_1.getPersistedSessionIdForAdapter)(state.getAgent(key) ?? null, adapter.name, (0, createAdapter_1.checkIsClaudeBackend)(adapter.name));
6986
+ const { adapter, workDir, sessionId } = getConversationSource(key);
6273
6987
  if (!sessionId)
6274
6988
  return [];
6275
- if (!decision.ok || !adapter.getRecentTurns)
6989
+ if (workDir === null || !adapter.getRecentTurns)
6276
6990
  return null;
6277
6991
  try {
6278
- return await adapter.getRecentTurns(key, decision.workDir, sessionId, limit);
6992
+ return await adapter.getRecentTurns(key, workDir, sessionId, limit);
6279
6993
  }
6280
6994
  catch (e) {
6281
6995
  console.warn(`[fallback] ${(0, sessionKey_1.keyToString)(key)}: the previous agent's turns are unreadable:`, e instanceof Error ? e.message : e);
6282
6996
  return null;
6283
6997
  }
6284
6998
  }
6999
+ /**
7000
+ * @description The summary the topic's agent's own compaction left in its conversation (`AgentAdapter.getSessionCompactionSummary`),
7001
+ * for the context bridge to hand on beside the turns; `null` when it has none, the backend keeps none, or it cannot be read.
7002
+ */
7003
+ async function readConversationCompactionSummary(key) {
7004
+ const { adapter, workDir, sessionId } = getConversationSource(key);
7005
+ if (!sessionId || workDir === null || !adapter.getSessionCompactionSummary)
7006
+ return null;
7007
+ try {
7008
+ return await adapter.getSessionCompactionSummary(key, workDir, sessionId);
7009
+ }
7010
+ catch (e) {
7011
+ console.warn(`[fallback] ${(0, sessionKey_1.keyToString)(key)}: the previous agent's compaction summary is unreadable:`, e instanceof Error ? e.message : e);
7012
+ return null;
7013
+ }
7014
+ }
6285
7015
  /**
6286
7016
  * @description The context bridge for a cross-backend switch (`utils/fallbackBridge.ts`), built BEFORE the old
6287
- * adapter is stopped: its last turns off its own transcript (an unreadable one yields a bridge without turns).
7017
+ * adapter is stopped: its last turns off its own transcript (an unreadable one yields a bridge without turns) and the
7018
+ * summary of its own earlier compaction, if any.
6288
7019
  * `reason` is why the agent changes — a usage limit or the operator's own switch.
6289
7020
  */
6290
7021
  async function buildFallbackBridge(key, input) {
6291
7022
  const turns = await readConversationTurns(key, fallbackBridge_1.fallbackBridgeTurnLimit) ?? [];
6292
- return (0, fallbackBridge_1.buildFallbackBridgePrompt)({ ...input, turns, replyLanguage: (0, i18n_1.getLocaleEndonym)((0, i18n_1.getActiveLang)()) });
7023
+ const compactionSummary = await readConversationCompactionSummary(key);
7024
+ return (0, fallbackBridge_1.buildFallbackBridgePrompt)({ ...input, turns, compactionSummary, replyLanguage: (0, i18n_1.getLocaleEndonym)((0, i18n_1.getActiveLang)()) });
6293
7025
  }
6294
7026
  /** The backend the topic's agent runs on, by name (`Claude` / `OpenCode`), for a label whose rung is unknown. */
6295
7027
  function formatThreadBackend(key) {
@@ -6372,19 +7104,31 @@ async function switchTopicToFallbackEntry(key, from, to, options) {
6372
7104
  }
6373
7105
  firstPrompt = { text: bridge, isRequestPrompt: false };
6374
7106
  }
6375
- cancelApiRetry(key); // retires the offer too: the switch consumed it
6376
- const notice = `${getFallbackSwitchedNotice(from, to, runningModel)}${isStoppingBackground ? `\n${(0, i18n_1.t)('fallback.backgroundStopped')}` : ''}`;
6377
- console.log(`[fallback] ${k}: switched from ${fromLabel} to ${toLabel} after a usage limit${firstPrompt ? ' (cross-backend, bridged)' : ''}; resuming`);
6378
- await say(notice);
6379
- await resumeTopicOnNewSession(key, {
6380
- modelToPin: (0, fallbackChain_1.getFallbackModelToPin)(to, runningModel),
6381
- // Read after the session is up: the open requests may have changed while it started.
6382
- getFirstPrompt: () => firstPrompt ?? (0, limitHeldPrompts_1.getLimitResumeMessage)({
6383
- continueNudge: (0, i18n_1.t)('apiRetry.continueNudge'),
6384
- untakenRequestPrompt: (0, requestHeader_1.joinPromptsNotTakenIn)(requestLimitWaitAnswerDeps?.ledger.listOpenRequestsOf(key) ?? []),
6385
- }),
6386
- });
6387
- return { isSwitched: true, notice };
7107
+ // From the end of the wait until the continue prompt is forwarded, the idle the limit left behind is not a turn end (the
7108
+ // same hold a retry kick has): the model pin below re-spawns the session for seconds, and a wake-up engine that polled
7109
+ // then would remind the agent on top of the prompt this move is about to send. A kick that moved the topic holds it already.
7110
+ const isHoldingTurnEnd = !apiRetryKicksInFlight.has(k);
7111
+ if (isHoldingTurnEnd)
7112
+ apiRetryKicksInFlight.add(k);
7113
+ try {
7114
+ cancelApiRetry(key); // retires the offer too: the switch consumed it
7115
+ const notice = `${getFallbackSwitchedNotice(from, to, runningModel)}${isStoppingBackground ? `\n${(0, i18n_1.t)('fallback.backgroundStopped')}` : ''}`;
7116
+ console.log(`[fallback] ${k}: switched from ${fromLabel} to ${toLabel} after a usage limit${firstPrompt ? ' (cross-backend, bridged)' : ''}; resuming`);
7117
+ await say(notice);
7118
+ await resumeTopicOnNewSession(key, {
7119
+ modelToPin: (0, fallbackChain_1.getFallbackModelToPin)(to, runningModel),
7120
+ // Read after the session is up: the open requests may have changed while it started.
7121
+ getFirstPrompt: () => firstPrompt ?? (0, limitHeldPrompts_1.getLimitResumeMessage)({
7122
+ continueNudge: (0, i18n_1.t)('apiRetry.continueNudge'),
7123
+ untakenRequestPrompt: (0, requestHeader_1.joinPromptsNotTakenIn)(requestLimitWaitAnswerDeps?.ledger.listOpenRequestsOf(key) ?? []),
7124
+ }),
7125
+ });
7126
+ return { isSwitched: true, notice };
7127
+ }
7128
+ finally {
7129
+ if (isHoldingTurnEnd)
7130
+ apiRetryKicksInFlight.delete(k);
7131
+ }
6388
7132
  }
6389
7133
  /**
6390
7134
  * @description The move ACROSS backends both the usage-limit switch and the operator's manual switch make, first
@@ -6611,10 +7355,14 @@ async function takeFallbackSwitchOffer(key, episodeId, index) {
6611
7355
  const { notice } = await switchTopicToFallbackEntry(key, offer.from, decision.to, { isAnnounced: false });
6612
7356
  return notice;
6613
7357
  }
6614
- /** The `claude_profiles` MCP tool's answer: snapshots refreshed on demand (a minute's cache), then the ranking. */
6615
- async function listClaudeProfilesForAgent(model) {
6616
- await claudeUsageRefresher.refreshAll({ maxAgeMs: claudeUsageRefresher_1.claudeUsageOnDemandMaxAgeMs });
6617
- return (0, claudeUsage_1.buildClaudeProfilesReport)((0, claudeUsage_1.buildClaudeProfileUsageViews)({ dataDir: (0, state_1.resolveDataDir)(), records: state.getAllClaudeProfileInfos() }), model, Date.now());
7358
+ /**
7359
+ * The `claude_profiles` MCP tool's answer: snapshots refreshed on demand (a minute's cache; the account a resumed session
7360
+ * was stopped on is re-read at once), then the launch pick — a new agent, or the session's own account when it is warm.
7361
+ * The accounts the operator switched off in the `/limits` list are never picked for a new agent.
7362
+ */
7363
+ async function listClaudeProfilesForAgent(query) {
7364
+ await (0, claudeUsageRefresher_1.refreshClaudeUsageForLaunch)(claudeUsageRefresher, { currentProfile: query.currentProfile, isStoppedByLimit: query.isStoppedByLimit, maxAgeMs: claudeUsageRefresher_1.claudeUsageOnDemandMaxAgeMs });
7365
+ return (0, claudeUsage_1.buildClaudeProfilesReport)((0, claudeUsage_1.buildClaudeProfileUsageViews)({ dataDir: (0, state_1.resolveDataDir)(), records: state.getAllClaudeProfileInfos() }), query.model, Date.now(), { currentProfile: query.currentProfile, isStoppedByLimit: query.isStoppedByLimit, excludedNames: (0, fallbackChain_1.getDisabledClaudeProfileNames)(state.getFallbackChain()) });
6618
7366
  }
6619
7367
  const { registerFallbackChainCommands, registerFallbackChainCallbacks, openAutoSwitchView, runAutoSwitchCommand, applyAutoSwitch } = (0, fallbackChain_2.createFallbackChain)({
6620
7368
  ...botCore,
@@ -6626,6 +7374,15 @@ const { registerFallbackChainCommands, registerFallbackChainCallbacks, openAutoS
6626
7374
  getTopicPosition: getTopicFallbackPosition,
6627
7375
  takeSwitchOffer: takeFallbackSwitchOffer,
6628
7376
  });
7377
+ const { registerTodoCommands, registerTodoCallbacks } = (0, todo_1.createTodo)({
7378
+ ...botCore,
7379
+ getTodoRegistry,
7380
+ readTodoSettings,
7381
+ getSettingsPath: () => (0, instanceSettings_1.getInstanceSettingsPath)(),
7382
+ listTodoProjects,
7383
+ getConversationLabel: getTodoConversationLabel,
7384
+ checkIsConversationAsleep: (conversationKey) => getTodoSessionActivity(conversationKey) === 'asleep',
7385
+ });
6629
7386
  const { registerLimitsBoardCommands, registerLimitsBoardCallbacks } = (0, limitsBoard_1.createLimitsBoard)({
6630
7387
  ...botCore,
6631
7388
  getInstanceTimezone: getCurrentTimezone,
@@ -6685,6 +7442,7 @@ registerClaudeAccountCommands();
6685
7442
  registerTopicCommand();
6686
7443
  registerFallbackChainCommands();
6687
7444
  registerLimitsBoardCommands();
7445
+ registerTodoCommands();
6688
7446
  registerSttCommands();
6689
7447
  const { registerSelfUpdateCommands, registerSelfUpdateCallbacks } = (0, selfUpdate_1.createSelfUpdate)({
6690
7448
  ...botCore,
@@ -6801,8 +7559,10 @@ async function resumeSessionByIndex(key, idx, onExpired) {
6801
7559
  // The ONLY resume path that posts the "last N messages" context block —
6802
7560
  // silent re-attach (bot restart) and crash recovery must stay quiet.
6803
7561
  await adapter.resumeSession(key, workDir, sessionId, { isWithRecentContext: true });
6804
- // Another session took over this topic; it does not know the open request.
7562
+ // Another session took over this topic; it does not know the open request, and the question the idle compaction re-asked
7563
+ // for the conversation it replaces would send its label into the picked one.
6805
7564
  cancelConversationRequest(key);
7565
+ closeReAskedQuestion(key);
6806
7566
  // Persist the PICKED id — without this the next restart re-attaches to
6807
7567
  // whatever id the last fresh start wrote, silently dropping the user's
6808
7568
  // pick (live incident 2026-06-10).
@@ -6819,7 +7579,7 @@ async function resumeSessionByIndex(key, idx, onExpired) {
6819
7579
  }
6820
7580
  command(['sessions', 'resume'], (_ctx, key) => handleSessionsList(key));
6821
7581
  /**
6822
- * @description `/quit-all` — kill every active agent across every thread.
7582
+ * @description `/quitall` — kill every active agent across every thread.
6823
7583
  *
6824
7584
  * General-only because it's a workspace-wide operation that's easy to
6825
7585
  * fire by mistake; restricting to General + needing an explicit command
@@ -6832,7 +7592,7 @@ command(['sessions', 'resume'], (_ctx, key) => handleSessionsList(key));
6832
7592
  * adapter that thread is configured for and stop only if it's active —
6833
7593
  * idempotent, so re-running is safe.
6834
7594
  */
6835
- command(['quit-all', 'quitall'], async (_ctx, key) => {
7595
+ command('quitall', async (_ctx, key) => {
6836
7596
  if (!checkIsGeneral(key)) {
6837
7597
  await replyToThread(key, (0, i18n_1.t)('quit_all.general_only'));
6838
7598
  return;
@@ -6852,7 +7612,7 @@ command(['quit-all', 'quitall'], async (_ctx, key) => {
6852
7612
  active += result.attempted;
6853
7613
  stopped += result.stopped.length;
6854
7614
  // Same release-for-good semantics as `/quit`, per swept thread.
6855
- await state.clearAgentSessionIds(bKey);
7615
+ await releaseConversationIds(bKey);
6856
7616
  }
6857
7617
  if (active === 0) {
6858
7618
  await replyToThread(key, (0, i18n_1.t)('quit_all.none_active'));
@@ -6907,7 +7667,7 @@ command(['quit', 'q'], async (_ctx, key) => {
6907
7667
  if (!primaryActive) {
6908
7668
  // A sleeping conversation is released for good: the next message starts a fresh one.
6909
7669
  if (getResumableSessionId(key) !== null) {
6910
- await state.clearAgentSessionIds(key);
7670
+ await releaseConversationIds(key);
6911
7671
  await replyToThread(key, (0, i18n_1.t)('agent.sleeping_released'));
6912
7672
  return;
6913
7673
  }
@@ -6928,8 +7688,14 @@ command(['quit', 'q'], async (_ctx, key) => {
6928
7688
  await replyToThread(key, (0, i18n_1.t)('agent.exit_signal_sent', { label: adapter.label }));
6929
7689
  return;
6930
7690
  }
6931
- // OpenCode, terminal, any future adapter: tear down via stopSession.
6932
- adapter.stopSession(key);
7691
+ // OpenCode, terminal, any future adapter: tear down via stopSession. «Stopped» is said once it is: a `/claude` sent on the
7692
+ // reply must find the session gone, not «already running» — as one transition, so a start or resume waits behind it.
7693
+ await sessionTransitions.run((0, sessionKey_1.keyToString)(key), async () => {
7694
+ if (adapter.stopSessionAndWait)
7695
+ await adapter.stopSessionAndWait(key);
7696
+ else
7697
+ adapter.stopSession(key);
7698
+ });
6933
7699
  // Explicit quit releases the session for good — no auto-reattach later.
6934
7700
  await state.clearAgentSessionIds(key);
6935
7701
  await replyToThread(key, (0, i18n_1.t)('agent.stopped', { label: adapter.label }));
@@ -7198,8 +7964,8 @@ command('schedule', async (ctx, key, parsed) => {
7198
7964
  await replyToThread(key, result.message);
7199
7965
  const isStarting = startupPromptBuffer.checkIsStarting((0, sessionKey_1.keyToString)(key));
7200
7966
  const opening = await openTopicRequest(key, wrappedPrompt, { source: 'schedule', requesterId: (0, topicRequest_1.getTopicRequesterId)(ctx.from) });
7201
- await deliverPromptOrBuffer(key, `${opening.header}${wrappedPrompt}`, isStarting);
7202
- trackTopicRequestTurn(key, opening);
7967
+ const delivery = await deliverPromptOrBuffer(key, `${opening.header}${wrappedPrompt}`, isStarting);
7968
+ await settleTopicRequestAfterDelivery(key, opening, delivery);
7203
7969
  });
7204
7970
  const reminderWizards = new Map();
7205
7971
  const { cancelReminderWizard, claimReminderTextCapture, expireReminderWizard, finishReminderWizard, registerReminderCommands, registerReminderCallbacks, } = (0, reminders_1.createReminders)({
@@ -7278,16 +8044,18 @@ command('clear_messages', async (ctx, key) => {
7278
8044
  // ═══════════════════════════════════════════════════════════════════════════════
7279
8045
  // Slash tokens the text handler must NOT forward to the agent. Covers every
7280
8046
  // bot-owned command (incl. the raw-key controls c/y/n/enter/up/down/tab/esc)
7281
- // PLUS the retired commands (agent/cancel/unbind/where/stop/stopall/stop-all):
7282
- // their handlers were removed, but keeping the tokens here makes a stray
7283
- // `/where` (or `/stop`) inert instead of typing a meaningless prompt into the
7284
- // agent.
8047
+ // PLUS the retired commands (agent/cancel/unbind/where/stop/stopall/stop-all)
8048
+ // and the spelling `quit-all`: their handlers were removed (`quit-all` never
8049
+ // worked — Telegram ends a command at the hyphen, so a typed `/quit-all` is
8050
+ // routed as `/quit`), but keeping the tokens here makes a stray `/where` (or
8051
+ // `/stop`, or an entity-less `/quit-all`) inert instead of typing a meaningless
8052
+ // prompt into the agent.
7285
8053
  const botCommands = new Set([
7286
8054
  'start', 'claude', 'opencode', 'oc', 'terminal', 'agent', 'sessions', 'resume', 'cancel', 'model', 'connect',
7287
- 'disconnect', 'stop', 'stopall', 'stop-all', 'status', 'c', 'y', 'n', 'enter', 'up', 'down', 'tab', 'esc', 'escape', 'interrupt', 'output', 'clear_messages',
8055
+ 'disconnect', 'stop', 'stopall', 'stop-all', 'quit-all', 'status', 'c', 'y', 'n', 'enter', 'up', 'down', 'tab', 'esc', 'escape', 'interrupt', 'output', 'clear_messages',
7288
8056
  'bind', 'topic', 'unbind', 'where', 'ls', 'list', 'new', 'clear_session', 'whoami', 'version', 'help', 'language', 'lang',
7289
- 'doctor', 'mcp', 'rename_session', 'compact', 'compact_on_idle', 'compact_summary', 'auto_continue_limits', 'limits', 'limits_autoswitch', 'trace', 'timestamps', 'timezone', 'schedule', 'reminders', 'thinking', 'tool_results',
7290
- 'subagent', 'claude_mode', 'effort', 'verbosity', 'quit', 'q', 'quit-all', 'quitall', 'pair', 'account', 'accounts', 'fallback', 'stt', 'voice', 'speech', 'update',
8057
+ 'doctor', 'mcp', 'rename_session', 'compact', 'compact_on_idle', 'compact_summary', 'bot_skills', 'task_agents', 'auto_continue_limits', 'limits', 'limits_autoswitch', 'trace', 'timestamps', 'timezone', 'schedule', 'reminders', 'todo', 'thinking', 'tool_results',
8058
+ 'subagent', 'claude_mode', 'effort', 'verbosity', 'quit', 'q', 'quitall', 'pair', 'account', 'accounts', 'fallback', 'stt', 'voice', 'speech', 'update',
7291
8059
  ]);
7292
8060
  /**
7293
8061
  * @description The bare `/clear` is no longer bot-owned (it was renamed to
@@ -7432,6 +8200,9 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
7432
8200
  await bufferPromptDuringStartup(key, text, { source: 'text', requesterId: (0, topicRequest_1.getTopicRequesterId)(ctx.from) }, getReplyQuoteBlock(key, ctx.message));
7433
8201
  return;
7434
8202
  }
8203
+ // Set when this very message bound the topic: its welcome is already the answer, so the idle-thread hint at the end
8204
+ // stays out — but the message itself is still read as what it is (a start phrase starts the agent).
8205
+ let isJustAutoBound = false;
7435
8206
  // Audit S2 / #5: if `forum_topic_created` was emitted by a non-allowed
7436
8207
  // admin, we stashed the topic name in `pendingTopicNames` without
7437
8208
  // binding. Now that an allowed user is engaging this thread, retry the
@@ -7441,29 +8212,28 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
7441
8212
  // explicitly chose to create a new folder, so this message is the name —
7442
8213
  // don't let a stale topic-name cache hijack it into a different bind.
7443
8214
  if (!checkIsGeneral(key) && !state.getBinding(key) && !awaitingFolderName.has(kStr)) {
7444
- const pending = pendingTopicNames.get(kStr);
7445
- if (pending && Date.now() - pending.ts < PENDING_TOPIC_NAME_TTL_MS) {
8215
+ const pendingEntry = pendingTopicNames.get(kStr);
8216
+ const pending = (0, pendingTopicName_1.getFreshPendingTopicName)(pendingEntry, Date.now(), PENDING_TOPIC_NAME_TTL_MS);
8217
+ if (pending) {
7446
8218
  const match = (0, validation_1.findAutobindSubdir)(pending.name, listAvailableSubdirs(ENV.workRoot));
7447
8219
  if (match) {
7448
8220
  try {
7449
- const subdir = (0, validation_1.validateSubdir)(ENV.workRoot, match);
7450
- // The pending entry carries the topic name (cached at creation) —
7451
- // copy it onto the binding for the thread-context preamble (S1).
7452
- await state.setBinding(key, subdir, pending.name ? { topicName: pending.name } : {});
7453
8221
  pendingTopicNames.delete(kStr);
7454
- await replyToThread(key, (0, i18n_1.t)('thread.welcome_bound', { subdir }));
7455
- await sendBindingWelcome(key, subdir);
8222
+ // The name rides on the binding for the thread-context preamble (S1): an authorised user's as given, a member's
8223
+ // replaced by the folder's own name — the match is all the bot vets of it.
8224
+ isJustAutoBound = await autoBindTopicByName(key, match, (0, pendingTopicName_1.getAutoBindTopicName)(pending, match));
7456
8225
  }
7457
8226
  catch (e) {
7458
8227
  console.warn(`[deferred-autobind] failed for "${match}":`, e);
7459
- pendingTopicNames.delete(kStr);
7460
8228
  }
7461
8229
  }
7462
- else {
8230
+ else if (!pending.isFromAdmin) {
8231
+ // No folder matches: a name only a non-admin gave has nothing else to do. An authorised user's stays for the
8232
+ // bind the picker leads to, which keeps it (until it is bound or the TTL runs out).
7463
8233
  pendingTopicNames.delete(kStr);
7464
8234
  }
7465
8235
  }
7466
- else if (pending) {
8236
+ else if (pendingEntry) {
7467
8237
  // TTL expired — clean the entry.
7468
8238
  pendingTopicNames.delete(kStr);
7469
8239
  }
@@ -7533,12 +8303,13 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
7533
8303
  if (requestedFamily !== null && state.getBinding(key) && await askBeforeCrossBackendSwitch(key, requestedFamily, startMatch.args))
7534
8304
  return;
7535
8305
  // The shared `ensureAgentSession` does the bind-check + switch + start.
7536
- // The explicit matched adapter wins (`preferredAdapterName`); the phrase's
7537
- // trailing args ride along. On `unbound` we keep this path's own reply:
7538
- // the folder picker (plan §11 Этап 4 — starting without a binding would
7539
- // silently launch against WORK_ROOT, which is almost never wanted).
8306
+ // The explicit matched adapter wins (`preferredAdapterName`), named the way
8307
+ // `/claude` names it: `claude` is the topic's Claude backend, never the bare
8308
+ // tmux one. The phrase's trailing args ride along. On `unbound` we keep this
8309
+ // path's own reply: the folder picker (plan §11 Этап 4 — starting without a
8310
+ // binding would silently launch against WORK_ROOT, which is almost never wanted).
7540
8311
  const result = await ensureAgentSession(key, {
7541
- preferredAdapterName: startMatch.adapterName,
8312
+ preferredAdapterName: (0, createAdapter_1.resolveStartAdapterName)(key, startMatch.adapterName),
7542
8313
  args: startMatch.args,
7543
8314
  });
7544
8315
  if (!result.ok && result.reason === 'unbound') {
@@ -7628,6 +8399,8 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
7628
8399
  await replyToThread(key, (0, i18n_1.t)('thread.no_binding'), extra);
7629
8400
  return;
7630
8401
  }
8402
+ if (isJustAutoBound)
8403
+ return;
7631
8404
  await replyToThread(key, (0, i18n_1.t)('thread.no_agent_with_binding', { subdir: binding.subdir }));
7632
8405
  });
7633
8406
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -7750,7 +8523,7 @@ async function processVoiceJob(key, fileId, durationSeconds, requesterId, sentAt
7750
8523
  // Same choke point as the text trigger (L-D4): a sleeping conversation is
7751
8524
  // woken, never replaced by a fresh start; `unbound` keeps the folder picker.
7752
8525
  const result = await ensureAgentSession(key, {
7753
- preferredAdapterName: startMatch.adapterName,
8526
+ preferredAdapterName: (0, createAdapter_1.resolveStartAdapterName)(key, startMatch.adapterName),
7754
8527
  args: startMatch.args,
7755
8528
  });
7756
8529
  if (!result.ok && result.reason === 'unbound') {
@@ -7946,12 +8719,12 @@ const albumCollector = (0, mediaGroupCollector_1.createMediaGroupCollector)({
7946
8719
  // cancel it silently, like the text/voice handlers.
7947
8720
  cancelApiRetry(key);
7948
8721
  // A user album is genuine user activity → clear the compact-on-idle latch (D2).
7949
- noteThreadUserActivity(key);
8722
+ notePersonActivity(key);
7950
8723
  try {
7951
8724
  const opening = await openTopicRequest(key, promptText, { source: 'album', requesterId: orderedItems[0].requesterId });
7952
8725
  const quotedPrompt = getPromptWithThreadReplyQuote(key, promptText, replyContext);
7953
- await deliverPromptOrBuffer(key, `${opening.header}${quotedPrompt}`, isStarting);
7954
- trackTopicRequestTurn(key, opening);
8726
+ const delivery = await deliverPromptOrBuffer(key, `${opening.header}${quotedPrompt}`, isStarting);
8727
+ await settleTopicRequestAfterDelivery(key, opening, delivery);
7955
8728
  }
7956
8729
  catch (err) {
7957
8730
  console.error('[Bot] album flush failed:', err);
@@ -8030,12 +8803,12 @@ async function handleIncomingFile(ctx, key) {
8030
8803
  // cancel it silently, like the text/voice handlers.
8031
8804
  cancelApiRetry(key);
8032
8805
  // A user file is genuine user activity → clear the compact-on-idle latch (D2).
8033
- noteThreadUserActivity(key);
8806
+ notePersonActivity(key);
8034
8807
  const opening = await openTopicRequest(key, promptText, { source: 'file', requesterId: (0, topicRequest_1.getTopicRequesterId)(ctx.from) });
8035
8808
  // A file sent as a REPLY carries the message it answers, as a typed or spoken reply does.
8036
8809
  const quotedPrompt = getPromptWithThreadReplyQuote(key, promptText, getReplyQuoteBlock(key, ctx.message));
8037
- await deliverPromptOrBuffer(key, `${opening.header}${quotedPrompt}`, isStarting);
8038
- trackTopicRequestTurn(key, opening);
8810
+ const delivery = await deliverPromptOrBuffer(key, `${opening.header}${quotedPrompt}`, isStarting);
8811
+ await settleTopicRequestAfterDelivery(key, opening, delivery);
8039
8812
  }
8040
8813
  bot.on(
8041
8814
  // anyOf-composed per-kind filter — `message('photo', 'document', …)` would
@@ -8139,7 +8912,7 @@ bot.on((0, filters_1.message)('forum_topic_created'), async (ctx) => {
8139
8912
  const existing = state.getBinding(key);
8140
8913
  // Already bound — service event arrived after the user's first message
8141
8914
  // (or on restart). Don't overwrite; quietly skip the welcome too, the
8142
- // user has already seen the binding ack.
8915
+ // user has already seen the welcome.
8143
8916
  if (existing)
8144
8917
  return;
8145
8918
  // Audit S2 / #5: topic creation may be open to all members (group setting),
@@ -8156,7 +8929,7 @@ bot.on((0, filters_1.message)('forum_topic_created'), async (ctx) => {
8156
8929
  return;
8157
8930
  if (action === 'cacheNameOnly') {
8158
8931
  if (topicName) {
8159
- pendingTopicNames.set((0, sessionKey_1.keyToString)(key), { name: topicName, ts: Date.now() });
8932
+ pendingTopicNames.set((0, sessionKey_1.keyToString)(key), { name: topicName, ts: Date.now(), isFromAdmin: false });
8160
8933
  }
8161
8934
  console.warn(`[security] forum_topic_created in chat ${ctx.chat.id} by user ${userId ?? '?'} (not a group admin) — name cached, no auto-bind`);
8162
8935
  return;
@@ -8169,19 +8942,20 @@ bot.on((0, filters_1.message)('forum_topic_created'), async (ctx) => {
8169
8942
  const match = (0, validation_1.findAutobindSubdir)(topicName, subdirs);
8170
8943
  if (match) {
8171
8944
  try {
8172
- const subdir = (0, validation_1.validateSubdir)(ENV.workRoot, match);
8173
- // The creation event carries the topic name — persist it on the binding
8174
- // for the thread-context preamble (S1).
8175
- await state.setBinding(key, subdir, topicName ? { topicName } : {});
8176
- await replyToThread(key, (0, i18n_1.t)('thread.welcome_bound', { subdir }));
8177
- await sendBindingWelcome(key, subdir);
8178
- return;
8945
+ // The creation event carries the topic name — it rides on the binding for the thread-context preamble (S1).
8946
+ if (await autoBindTopicByName(key, match, topicName))
8947
+ return;
8179
8948
  }
8180
8949
  catch (e) {
8181
8950
  console.warn(`[forum_topic_created] auto-bind failed for "${match}":`, e);
8182
8951
  // Fall through to picker.
8183
8952
  }
8184
8953
  }
8954
+ // The picker is the way on, and the bind through it is the topic's first: set the name an authorised user gave aside, so
8955
+ // that bind keeps it (the shared-folder row of a later topic names this one by it).
8956
+ if (topicName && !state.getBinding(key)) {
8957
+ pendingTopicNames.set((0, sessionKey_1.keyToString)(key), { name: topicName, ts: Date.now(), isFromAdmin: true });
8958
+ }
8185
8959
  const extra = (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
8186
8960
  await replyToThread(key, (0, i18n_1.t)('thread.welcome_pick'), extra);
8187
8961
  });
@@ -8213,7 +8987,7 @@ bot.on((0, filters_1.message)('forum_topic_edited'), async (ctx) => {
8213
8987
  }
8214
8988
  else {
8215
8989
  // No binding yet — keep the freshest name for a later fuzzy auto-bind.
8216
- pendingTopicNames.set((0, sessionKey_1.keyToString)(key), { name: newName, ts: Date.now() });
8990
+ pendingTopicNames.set((0, sessionKey_1.keyToString)(key), { name: newName, ts: Date.now(), isFromAdmin: true });
8217
8991
  }
8218
8992
  });
8219
8993
  // Audit S2 / #5: closed/reopened events can come from a member who isn't an
@@ -8297,16 +9071,11 @@ bot.action(/^bind_page_(\d+)$/, async (ctx) => {
8297
9071
  const page = parseInt(ctx.match[1], 10);
8298
9072
  const subdirs = listAvailableSubdirs(ENV.workRoot);
8299
9073
  const keyboard = (0, bindPicker_1.buildBindKeyboard)(subdirs, page, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
8300
- try {
8301
- await ctx.editMessageReplyMarkup(keyboard.reply_markup);
8302
- }
8303
- catch (e) {
8304
- const desc = (0, sendErrorClassifier_1.checkIsApiError)(e) ? (0, sendErrorClassifier_1.getErrorDescription)(e) : '';
8305
- if (!/message is not modified/i.test(desc)) {
8306
- console.warn(`[bind_page] edit failed:`, desc || e);
8307
- }
8308
- }
9074
+ // Answered BEFORE the edit: the edit waits in the paced send queue, which can outlive the callback.
8309
9075
  await ctx.answerCbQuery();
9076
+ const pickerMessageId = (0, callbackMessage_1.getCallbackMessageId)(ctx);
9077
+ if (pickerMessageId !== null)
9078
+ await editThreadKeyboard(key, pickerMessageId, keyboard.reply_markup, 'bind_page');
8310
9079
  });
8311
9080
  // Middle "N/M" pill in the nav row — pure UI, no state change.
8312
9081
  bot.action(bindPicker_1.bindPageNoopCallback, async (ctx) => {
@@ -8326,9 +9095,18 @@ bot.action(bindPicker_1.bindCreateFolderCallback, async (ctx) => {
8326
9095
  await ctx.answerCbQuery((0, i18n_1.t)('cb.bind_only_topical'));
8327
9096
  return;
8328
9097
  }
9098
+ const pickerMessageId = (0, callbackMessage_1.getCallbackMessageId)(ctx);
9099
+ // A picker a bind (or a leave) holds is spent: a name asked for now would bind a second time.
9100
+ if (pickerMessageId !== null && bindPickerClaims.has(key, pickerMessageId)) {
9101
+ await (0, callbackToast_1.answerCallbackQuerySafely)(ctx, (0, i18n_1.t)('cb.bind_picker_used'));
9102
+ return;
9103
+ }
8329
9104
  await ctx.answerCbQuery((0, i18n_1.t)('bind.create_cb'));
8330
- armFolderCreation(key);
8331
- await replyToThread(key, (0, i18n_1.t)('bind.create_prompt'));
9105
+ armFolderCreation(key, pickerMessageId);
9106
+ // Remembered like a picker: the bind this question leads to deletes it (a refused name keeps it, the question stays open).
9107
+ const promptMessageId = await replyToThread(key, (0, i18n_1.t)('bind.create_prompt'));
9108
+ if (promptMessageId !== null)
9109
+ openBindMessages.remember((0, sessionKey_1.keyToString)(key), promptMessageId, Date.now());
8332
9110
  });
8333
9111
  // «Leave current dir» — the folded-in `/unbind`, shown only in a bound topic.
8334
9112
  // Callback id deliberately avoids the `bind_` prefix so it can't be matched by
@@ -8343,8 +9121,20 @@ bot.action(bindPicker_1.bindLeaveCurrentCallback, async (ctx) => {
8343
9121
  await ctx.answerCbQuery((0, i18n_1.t)('cb.bind_only_topical'));
8344
9122
  return;
8345
9123
  }
8346
- await ctx.answerCbQuery();
8347
- await unbindThread(key);
9124
+ // Claimed like a bind: two taps in a row unbind once, and a picker a bind holds is no more a place to leave from.
9125
+ const pickerMessageId = (0, callbackMessage_1.getCallbackMessageId)(ctx);
9126
+ const run = await bindPickerClaims.runClaimed(key, pickerMessageId, async () => {
9127
+ await (0, callbackToast_1.answerCallbackQuerySafely)(ctx);
9128
+ await unbindThread(key);
9129
+ if (pickerMessageId !== null)
9130
+ await removeBindPicker(key, pickerMessageId);
9131
+ await closeOpenBindMessages(key);
9132
+ // The welcome's buttons have no folder to act on now.
9133
+ await stripWelcomeKeyboards(key, openWelcomeMessages.take((0, sessionKey_1.keyToString)(key)).map((entry) => entry.messageId));
9134
+ return true;
9135
+ });
9136
+ if (run === 'busy')
9137
+ await (0, callbackToast_1.answerCallbackQuerySafely)(ctx, (0, i18n_1.t)('cb.bind_picker_used'));
8348
9138
  });
8349
9139
  // `/topic`'s buttons: patterns of their own (`topic_…`), disjoint from the `bind_<subdir>` regex below.
8350
9140
  registerTopicCallbacks();
@@ -8358,12 +9148,26 @@ bot.action(/^bind_(.+)$/, async (ctx) => {
8358
9148
  await ctx.answerCbQuery((0, i18n_1.t)('cb.bind_only_topical'));
8359
9149
  return;
8360
9150
  }
9151
+ // The picker is claimed before any await: a bind takes seconds (the folder's facts are probed, the sends are paced), and a
9152
+ // second tap landing meanwhile must not bind — or welcome — a second time. A bind that does not happen (a refused folder,
9153
+ // a failure on the way) gives the picker back.
8361
9154
  const subdir = ctx.match[1];
8362
- await ctx.answerCbQuery((0, i18n_1.t)('cb.binding_to', { subdir }));
8363
- const result = await applyBinding(key, subdir);
8364
- await replyToThread(key, result.message);
8365
- if (result.ok)
8366
- await sendBindingWelcome(key, result.subdir);
9155
+ const pickerMessageId = (0, callbackMessage_1.getCallbackMessageId)(ctx);
9156
+ const run = await bindPickerClaims.runClaimed(key, pickerMessageId, async () => {
9157
+ // A toast that comes too late (the query expired) changes nothing about the bind the person asked for.
9158
+ await (0, callbackToast_1.answerCallbackQuerySafely)(ctx, (0, i18n_1.t)('cb.binding_to', { subdir }));
9159
+ const result = await applyBinding(key, subdir);
9160
+ if (!result.ok) {
9161
+ await replyToBindResult(key, result);
9162
+ return false;
9163
+ }
9164
+ if (pickerMessageId !== null)
9165
+ await removeBindPicker(key, pickerMessageId);
9166
+ await replyToBindResult(key, result);
9167
+ return true;
9168
+ });
9169
+ if (run === 'busy')
9170
+ await (0, callbackToast_1.answerCallbackQuerySafely)(ctx, (0, i18n_1.t)('cb.bind_picker_used'));
8367
9171
  });
8368
9172
  // ─── /language inline picker callbacks ───────────────────────────────────────
8369
9173
  //
@@ -8501,10 +9305,13 @@ bot.action(timezonePicker_1.zonePickCallbackRe, async (ctx) => {
8501
9305
  registerModelCallbacks();
8502
9306
  registerCompactionCallbacks();
8503
9307
  registerAutoContinueLimitsCallbacks();
9308
+ registerBotSkillsCallbacks();
9309
+ registerTaskAgentsCallbacks();
8504
9310
  registerBackendSwitchCallbacks();
8505
9311
  registerClaudeAccountCallbacks();
8506
9312
  registerFallbackChainCallbacks();
8507
9313
  registerLimitsBoardCallbacks();
9314
+ registerTodoCallbacks();
8508
9315
  registerSttCallbacks();
8509
9316
  registerSelfUpdateCallbacks();
8510
9317
  // «⏭ Skip once» on the `/limits` board and the `/limits_autoswitch` screen: drop THIS armed resume and
@@ -8535,43 +9342,6 @@ bot.action(new RegExp(`^${autoContinueOnLimit_1.skipArmedRetryCallbackPrefix}(\\
8535
9342
  await consumeAutoContinueLimitsPicker(key, ctx.callbackQuery?.message, (0, i18n_1.t)('autoContinueLimits.skippedNotice'));
8536
9343
  });
8537
9344
  });
8538
- // D1: a tap on an idle-compaction RE-ASKED question button. The original request
8539
- // was rejected server-side during the compaction, so the chosen option label is
8540
- // delivered to the (now compacted) session as a FRESH prompt, not answered.
8541
- bot.action(/^reask_(\d+)$/, async (ctx) => {
8542
- const key = await authoriseContext(ctx);
8543
- if (!key) {
8544
- await ctx.answerCbQuery((0, i18n_1.t)('cb.access_denied'));
8545
- return;
8546
- }
8547
- const optIdx = parseInt(ctx.match[1], 10);
8548
- const labels = reAskedQuestionOptions.get((0, sessionKey_1.keyToString)(key));
8549
- const label = labels?.[optIdx];
8550
- if (!label) {
8551
- await ctx.answerCbQuery((0, i18n_1.t)('cb.no_pending_question'));
8552
- return;
8553
- }
8554
- // The idle fire posts the re-asked question just BEFORE it stops the process, and a stopping process still reads as
8555
- // active: a tap inside that window (or inside a start) is buffered like a typed prompt and replayed once a session is up.
8556
- const isStarting = startupPromptBuffer.checkIsStarting((0, sessionKey_1.keyToString)(key));
8557
- // The answer to a re-asked question wakes a sleeping conversation (L-D4) — the question is its own.
8558
- if (!isStarting && !(0, createAdapter_1.getThreadAdapter)(key).checkIsActive(key) && !(await resumeSleepingSessionForPrompt(key))) {
8559
- await ctx.answerCbQuery((0, i18n_1.t)('agent.no_session'));
8560
- return;
8561
- }
8562
- const adapter = (0, createAdapter_1.getThreadAdapter)(key);
8563
- reAskedQuestionOptions.delete((0, sessionKey_1.keyToString)(key));
8564
- // The label goes to the agent as a prompt and nothing else in the topic shows it whole (the buttons clip long
8565
- // labels): a label too long for the toast is posted in full, as the record of the answer that was sent.
8566
- await (0, callbackToast_1.answerCallbackWithWholeText)(ctx, label, (wholeLabel) => replyToThread(key, wholeLabel));
8567
- // An answer to the agent's own question is never a request (S7), even delivered as a prompt. Routed at delivery,
8568
- // not on the `isStarting` read above: the stop may have ended during the toast, and a label buffered into its closed
8569
- // window was never replayed.
8570
- const outcome = await withThreadLocale(key, () => deliverActivePrompt(key, adapter, label, null));
8571
- // The label reached no session (the topic was told why): the question is still open, so are its buttons.
8572
- if (outcome === 'noSession' && labels && !reAskedQuestionOptions.has((0, sessionKey_1.keyToString)(key)))
8573
- reAskedQuestionOptions.set((0, sessionKey_1.keyToString)(key), labels);
8574
- });
8575
9345
  registerReminderCallbacks();
8576
9346
  bot.action(/^ccmode_(.+)$/, async (ctx) => {
8577
9347
  const key = await authoriseContext(ctx);
@@ -8593,18 +9363,11 @@ bot.action(/^ccmode_(.+)$/, async (ctx) => {
8593
9363
  return;
8594
9364
  }
8595
9365
  await ctx.answerCbQuery((0, i18n_1.t)('cb.claudeMode_switching'));
8596
- await replyToThread(key, await applyClaudeBackendSwitch(key, target));
9366
+ await replyToThread(key, appendTmuxDeprecatedHint(target, await applyClaudeBackendSwitch(key, target)));
8597
9367
  // Re-render the picker so the `✓` follows the new backend (mirrors effort_cb).
8598
- const cbMsg = ctx.callbackQuery?.message;
8599
- if (cbMsg) {
8600
- try {
8601
- await (0, rateLimiter_1.enqueueSend)(key, () => bot.telegram.editMessageReplyMarkup((0, sessionKeyCodec_1.getTelegramChatId)(key), cbMsg.message_id, undefined, buildClaudeModeKeyboard((0, createAdapter_1.resolveClaudeBackendName)(key)).reply_markup));
8602
- }
8603
- catch (e) {
8604
- const desc = (0, sendErrorClassifier_1.checkIsApiError)(e) ? (0, sendErrorClassifier_1.getErrorDescription)(e) : '';
8605
- if (!/message is not modified/i.test(desc))
8606
- console.warn('[ccmode_cb] re-render:', desc || e);
8607
- }
9368
+ const pickerMessageId = (0, callbackMessage_1.getCallbackMessageId)(ctx);
9369
+ if (pickerMessageId !== null) {
9370
+ await editThreadKeyboard(key, pickerMessageId, buildClaudeModeKeyboard((0, createAdapter_1.resolveClaudeBackendName)(key)).reply_markup, 'ccmode_cb');
8608
9371
  }
8609
9372
  });
8610
9373
  registerDisplayModeCallbacks();
@@ -8632,18 +9395,9 @@ bot.action(/^agent_(.+)$/, async (ctx) => {
8632
9395
  // instead of staying stuck on the previous one (B16, mirrors B12). The
8633
9396
  // start keeps the picker message visible (it is neither edited nor deleted),
8634
9397
  // so the stale marker would otherwise persist.
8635
- const cbMsg = ctx.callbackQuery?.message;
8636
- if (cbMsg) {
8637
- const keyboard = buildAgentKeyboard((0, createAdapter_1.getAvailableAdapters)(), adapterName);
8638
- try {
8639
- await (0, rateLimiter_1.enqueueSend)(key, () => bot.telegram.editMessageReplyMarkup((0, sessionKeyCodec_1.getTelegramChatId)(key), cbMsg.message_id, undefined, keyboard.reply_markup));
8640
- }
8641
- catch (e) {
8642
- const desc = (0, sendErrorClassifier_1.checkIsApiError)(e) ? (0, sendErrorClassifier_1.getErrorDescription)(e) : '';
8643
- if (!/message is not modified/i.test(desc)) {
8644
- console.warn('[agent_cb] keyboard re-render failed:', desc || e);
8645
- }
8646
- }
9398
+ const pickerMessageId = (0, callbackMessage_1.getCallbackMessageId)(ctx);
9399
+ if (pickerMessageId !== null) {
9400
+ await editThreadKeyboard(key, pickerMessageId, buildAgentKeyboard((0, createAdapter_1.getAvailableAdapters)(), adapterName).reply_markup, 'agent_cb');
8647
9401
  }
8648
9402
  }
8649
9403
  catch {
@@ -8716,14 +9470,14 @@ bot.action(/^qa_(\d+)_(\d+)$/, async (ctx) => {
8716
9470
  const selectedLabel = question.options[optIdx].label;
8717
9471
  const isAnswerShown = await applyQuestionAnswer(key, [selectedLabel]);
8718
9472
  if (isAnswerShown) {
8719
- // The question message was just edited to "✅ <header>: <answer>", which carries the whole answer: the toast only
9473
+ // The question message was just edited to carry the whole answer under the question: the toast only
8720
9474
  // confirms the tap, so a label too long for it is cut there and not posted a second time.
8721
- await (0, callbackToast_1.answerCallbackWithFittedText)(ctx, selectedLabel);
9475
+ await (0, callbackToast_2.answerCallbackWithFittedText)(ctx, selectedLabel);
8722
9476
  }
8723
9477
  else {
8724
9478
  // The message could not be edited (it is gone, or the edit failed): nothing in the topic shows the answer, so a
8725
9479
  // label too long for the toast is posted in full.
8726
- await (0, callbackToast_1.answerCallbackWithWholeText)(ctx, selectedLabel, (wholeLabel) => replyToThread(key, wholeLabel));
9480
+ await (0, callbackToast_2.answerCallbackWithWholeText)(ctx, selectedLabel, (wholeLabel) => replyToThread(key, wholeLabel));
8727
9481
  }
8728
9482
  });
8729
9483
  /**
@@ -8735,6 +9489,13 @@ bot.action(/^qa_(\d+)_(\d+)$/, async (ctx) => {
8735
9489
  * the agent without the message it points at.
8736
9490
  */
8737
9491
  async function bufferPromptDuringStartup(key, text, intake, replyContext) {
9492
+ // The same for a bare option number typed in that window: it answers the question asked again.
9493
+ if (await answerReAskedQuestionByNumber(key, text))
9494
+ return;
9495
+ // A person wrote, in a window where the session is starting or being stopped: it counts like a message that reaches
9496
+ // `deliverActivePrompt` — the idle latch clears, and a question the idle compaction re-asked is closed (the idle fire
9497
+ // posts it just before the stop, so this window is where a message instead of a tap most often lands).
9498
+ notePersonActivity(key);
8738
9499
  const opening = await openTopicRequest(key, text, intake);
8739
9500
  const quotedText = getPromptWithThreadReplyQuote(key, text, replyContext);
8740
9501
  await deliverPromptOrBuffer(key, `${opening.header}${quotedText}`, true);
@@ -8753,15 +9514,18 @@ async function bufferPromptDuringStartup(key, text, intake, replyContext) {
8753
9514
  * user before forwarding so the prompt doesn't queue behind it forever.
8754
9515
  * 3. Forward the prompt (a bare `/clear` first purges thread context + files).
8755
9516
  *
8756
- * Claude/terminal never reach step 1 (their routing returns earlier in the text
8757
- * handler, and `pendingQuestions`/`answerQuestion` are OpenCode-only). For them
9517
+ * Step 1 is the OpenCode AND the Claude stream-json backends (both raise the `question` event and implement
9518
+ * `answerQuestion`). The tmux-scrape Claude and the terminal never reach it (no `pendingQuestions` entry): for them
8758
9519
  * `forwardPromptToAgent` interrupts the TUI selector itself.
8759
9520
  */
8760
9521
  async function deliverActivePrompt(key, adapter, text, intake, sentAtMs, replyContext) {
9522
+ // A bare option number answers the question the idle compaction asked again (a tap's own label comes in with no intake).
9523
+ if (intake !== null && await answerReAskedQuestionByNumber(key, text))
9524
+ return null;
8761
9525
  // A genuine USER message (text or voice) — clear the compact-on-idle latch so
8762
9526
  // the next idle period can compact again (D2), whether it answers a pending
8763
9527
  // question or is a fresh prompt.
8764
- noteThreadUserActivity(key);
9528
+ notePersonActivity(key);
8765
9529
  const pending = pendingQuestions.get((0, sessionKey_1.keyToString)(key));
8766
9530
  if (pending && adapter.answerQuestion) {
8767
9531
  const currentQuestion = pending.data.questions[pending.currentIndex];
@@ -8775,8 +9539,8 @@ async function deliverActivePrompt(key, adapter, text, intake, sentAtMs, replyCo
8775
9539
  }
8776
9540
  // Free-form text cancels the question and is a fresh prompt — a request (S7).
8777
9541
  const opening = await openTopicRequest(key, text, intake);
8778
- await cancelPendingQuestionAndForward(key, adapter, text, sentAtMs, replyContext, opening.header);
8779
- trackTopicRequestTurn(key, opening);
9542
+ const delivery = await cancelPendingQuestionAndForward(key, adapter, text, sentAtMs, replyContext, opening.header);
9543
+ await settleTopicRequestAfterDelivery(key, opening, delivery);
8780
9544
  return null;
8781
9545
  }
8782
9546
  // A bare `/clear` forwarded to the agent wipes its context (Claude TUI), so
@@ -8805,6 +9569,8 @@ async function deliverActivePrompt(key, adapter, text, intake, sentAtMs, replyCo
8805
9569
  // durable by the time the agent reads it.
8806
9570
  const opening = await openTopicRequest(key, text, intake);
8807
9571
  // Routed now, not when the message arrived: the idle stop may have begun or ended during the awaits since.
9572
+ // The forward can itself end in no session — a prompt that waited for an adopt replay found the session gone and nothing to wake;
9573
+ // it has told the person already (`forwardPromptToAgent`), so what is left here is the request this prompt opened.
8808
9574
  const outcome = await (0, promptDelivery_1.deliverLatePrompt)({
8809
9575
  checkIsWindowOpen: () => startupPromptBuffer.checkIsStarting((0, sessionKey_1.keyToString)(key)),
8810
9576
  checkIsSessionActive: () => adapter.checkIsActive(key),
@@ -8814,36 +9580,44 @@ async function deliverActivePrompt(key, adapter, text, intake, sentAtMs, replyCo
8814
9580
  wake: () => wakeSleepingSessionForPrompt(key),
8815
9581
  forward: () => forwardPromptToAgent(key, (0, createAdapter_1.getThreadAdapter)(key), text, sentAtMs, { replyContext, requestHeader: opening.header }),
8816
9582
  reportNoSession: async (isAlreadyTold) => {
8817
- // The prompt reaches no agent: the request it opened would only wait for a turn that never comes.
8818
- if (opening.requestId !== null)
8819
- await topicRequestLedger?.closeRequest(opening.requestId, 'cancelled');
8820
9583
  if (!isAlreadyTold)
8821
9584
  await replyToThread(key, (0, i18n_1.t)('agent.no_session'));
8822
9585
  },
8823
9586
  });
8824
- if (outcome !== 'noSession')
8825
- trackTopicRequestTurn(key, opening);
9587
+ await settleTopicRequestAfterDelivery(key, opening, outcome);
8826
9588
  return outcome;
8827
9589
  }
8828
9590
  /**
8829
- * @description Retire the on-screen state of a pending OpenCode question the user walked away from: clear the bot's
8830
- * pending-question state and neutralize the stale buttons message (edit it to a "cancelled" label so a late tap can't
8831
- * re-answer an aborted question). Closing the question on the server is the caller's, before it aborts the turn.
8832
- * Returns whether the buttons message itself was relabelled — it IS the cancellation notice then.
9591
+ * @description Close the on-screen state of a pending question that ends without an answer: clear the bot's
9592
+ * pending-question state (which unpins it) and edit the question's message so its buttons are gone and a note says that
9593
+ * nobody answered it. The message is never deleted — the question and its options
9594
+ * stay readable. Closing the question on the agent is the caller's, before it stops or aborts the turn.
9595
+ * Returns whether the message itself was edited — it IS the notice then.
8833
9596
  *
8834
9597
  * The edit runs through `runQuestionLifecycleOp` (same serializer as the answer path) so it can't race a concurrent
8835
- * re-post-to-bottom. The `messageId` + header are captured BEFORE clearing, since `clearPendingQuestion` drops the state.
9598
+ * re-post-to-bottom. The `messageId` + question are captured BEFORE clearing, since `clearPendingQuestion` drops the state.
8836
9599
  */
8837
9600
  async function retirePendingQuestion(key) {
8838
9601
  const pending = pendingQuestions.get((0, sessionKey_1.keyToString)(key));
8839
- const cancelledMessageId = pending?.messageId ?? null;
8840
- const cancelledQuestion = pending?.data.questions[pending.currentIndex];
8841
9602
  clearPendingQuestion(key);
8842
- if (cancelledMessageId === null || !cancelledQuestion)
9603
+ return closeCapturedQuestionMessage(key, pending?.messageId ?? null, pending?.data.questions[pending.currentIndex]);
9604
+ }
9605
+ /**
9606
+ * @description The edit half of {@link retirePendingQuestion}, for a question whose id and text were captured before its
9607
+ * state was cleared (also: read from the saved state at boot, where no memory entry ever existed). `false` when there is no
9608
+ * message to edit, else whether the edit landed.
9609
+ */
9610
+ async function closeCapturedQuestionMessage(key, messageId, question) {
9611
+ if (messageId === null || !question)
8843
9612
  return false;
8844
- const header = cancelledQuestion.header || cancelledQuestion.question;
8845
- await runQuestionLifecycleOp(key, () => editThreadMessage(key, cancelledMessageId, (0, i18n_1.t)('agent.question_cancelled_msg_label', { header })));
8846
- return true;
9613
+ return runQuestionLifecycleOp(key, () => closeQuestionMessage(key, messageId, question, 'unanswered'));
9614
+ }
9615
+ /**
9616
+ * @description {@link retirePendingQuestion} for the teardown and boot paths that do not wait for the edit (it goes through
9617
+ * the send pacer): a failure is logged, never an unhandled rejection.
9618
+ */
9619
+ function retirePendingQuestionInBackground(key) {
9620
+ retirePendingQuestion(key).catch((e) => console.error('[pendingQuestion] retire failed:', e));
8847
9621
  }
8848
9622
  /**
8849
9623
  * @description Cancel a pending OpenCode question because the user sent free-form
@@ -8853,7 +9627,7 @@ async function retirePendingQuestion(key) {
8853
9627
  * abort`), post the "previous question cancelled" notice, then forward.
8854
9628
  */
8855
9629
  async function cancelPendingQuestionAndForward(key, adapter, text, sentAtMs, replyContext, requestHeader) {
8856
- // When the question bubble itself was relabelled to "❌ … cancelled", that IS the
9630
+ // When the question bubble itself was closed with its «not answered» note, that IS the
8857
9631
  // cancellation notice — a second standalone "previous question cancelled" line is
8858
9632
  // pure noise (the reported triple message). The standalone notice is kept only
8859
9633
  // for the no-bubble case.
@@ -8871,7 +9645,7 @@ async function cancelPendingQuestionAndForward(key, adapter, text, sentAtMs, rep
8871
9645
  if (!didLabelQuestionMessage) {
8872
9646
  await replyToThread(key, (0, i18n_1.t)('agent.question_cancelled_for_prompt'));
8873
9647
  }
8874
- await forwardPromptToAgent(key, adapter, text, sentAtMs, { replyContext, requestHeader });
9648
+ return forwardPromptToAgent(key, adapter, text, sentAtMs, { replyContext, requestHeader });
8875
9649
  }
8876
9650
  /**
8877
9651
  * @description Record one answer for the CURRENTLY shown OpenCode question and
@@ -8895,8 +9669,9 @@ async function applyQuestionAnswerInner(key, answerForCurrent) {
8895
9669
  return false;
8896
9670
  const answeredQuestion = pending.data.questions[pending.currentIndex];
8897
9671
  const { nextState, action } = (0, openCodeQuestionFlow_1.recordAnswerAndAdvance)(pending, answerForCurrent);
8898
- // Mark the answered question's message as done (the chosen answer in place).
8899
- const isAnswerShown = pending.messageId !== null && answeredQuestion !== undefined && await editThreadMessage(key, pending.messageId, `✅ ${answeredQuestion.header || answeredQuestion.question}: ${answerForCurrent.join(', ')}`);
9672
+ // Mark the answered question's message as done: the question stays as asked, the chosen answer takes the place of
9673
+ // its buttons.
9674
+ const isAnswerShown = pending.messageId !== null && answeredQuestion !== undefined && await editQuestionMessageToClosed(key, pending.messageId, answeredQuestion, (0, i18n_1.t)('agent.question_answered_note', { answer: answerForCurrent.join(', ') }));
8900
9675
  if (action.kind === 'submit') {
8901
9676
  const adapter = (0, createAdapter_1.getThreadAdapter)(key);
8902
9677
  clearPendingQuestion(key);
@@ -8910,7 +9685,8 @@ async function applyQuestionAnswerInner(key, answerForCurrent) {
8910
9685
  }
8911
9686
  // More questions remain: persist the advanced state, then post the next one
8912
9687
  // (which re-owns `messageId`). DO NOT reply to the agent yet.
8913
- setPendingQuestion(key, nextState);
9688
+ // The next question is a new message of its own: it has no original yet, the one of the question answered is not its record.
9689
+ setPendingQuestion(key, { ...nextState, originalMessageId: null });
8914
9690
  await postPendingQuestionAt(key);
8915
9691
  return isAnswerShown;
8916
9692
  }
@@ -9922,6 +10698,9 @@ function handleAgentQuestion(key, questionData) {
9922
10698
  if (persistedQuestion && persistedQuestion.data.requestId === questionData.requestId) {
9923
10699
  return;
9924
10700
  }
10701
+ // The agent asks anew: a question the idle compaction re-asked earlier is superseded, and its buttons would now answer
10702
+ // the wrong question — it is closed as unanswered.
10703
+ closeReAskedQuestion(key);
9925
10704
  // A pending status frame is now stale — the question UI replaces it. Stop the
9926
10705
  // liveness loop first so it can't recreate a frame under the question prompt
9927
10706
  // (Claude-only; a no-op for OpenCode threads, which never arm it).
@@ -10037,6 +10816,7 @@ async function postPendingQuestionAt(key) {
10037
10816
  const existing = pendingQuestions.get(kStr);
10038
10817
  if (existing && existing.data === pending.data && existing.currentIndex === qIdx) {
10039
10818
  existing.messageId = messageId;
10819
+ existing.originalMessageId ??= messageId;
10040
10820
  setPendingQuestion(key, existing);
10041
10821
  markQuestionMessageSent(key, messageId);
10042
10822
  // Pin the question so the muted topic fires a notification (S2). The
@@ -10048,11 +10828,12 @@ async function postPendingQuestionAt(key) {
10048
10828
  void pinThreadQuestion(key, messageId);
10049
10829
  }
10050
10830
  else {
10051
- // The question advanced / was answered while our send sat in the
10831
+ // The question advanced / was answered / was closed while our send sat in the
10052
10832
  // queue (a fast digit reply can beat the post — seen live 2026-06-10:
10053
10833
  // a stale unanswered-looking "❓" landed and lingered). The message we
10054
- // just sent describes a question that no longer exists — remove it.
10055
- void deleteThreadMessage(key, messageId);
10834
+ // just sent describes a question that no longer exists: it is closed as
10835
+ // unanswered (its buttons would be dead), never deleted.
10836
+ await closeQuestionMessage(key, messageId, question, 'unanswered');
10056
10837
  }
10057
10838
  }
10058
10839
  }
@@ -10090,7 +10871,7 @@ function handleAgentClosed(key) {
10090
10871
  clearThinkingMessage(key);
10091
10872
  // A closed session has no running delegation — remove the sub-agent status.
10092
10873
  clearSubagentStatus(key);
10093
- clearPendingQuestion(key);
10874
+ retirePendingQuestionInBackground(key);
10094
10875
  // A closed session has nothing to resume — drop any armed retry silently.
10095
10876
  cancelApiRetry(key);
10096
10877
  clearAuthNotice(key); // session closed → retire any pinned logged-out notice
@@ -10197,7 +10978,7 @@ function handleAgentError(key, error) {
10197
10978
  deleteStatusMessage(key).catch(() => { });
10198
10979
  clearThinkingMessage(key);
10199
10980
  clearSubagentStatus(key);
10200
- clearPendingQuestion(key);
10981
+ retirePendingQuestionInBackground(key);
10201
10982
  replyToThread(key, `Error: ${error.message}`).catch(() => { });
10202
10983
  }
10203
10984
  /**
@@ -10245,8 +11026,8 @@ function handleAgentStarted(key) {
10245
11026
  * the delete); the "thinking …" frame and the sub-agent status for the same reason;
10246
11027
  * and the pending question (F1): no stop path fires the adapter's `questionGone`
10247
11028
  * (Claude's pane is killed; OpenCode emits no resolve), so a pinned question would
10248
- * leak forever — clearing it chains `unpinThreadQuestion`, matching
10249
- * `handleAgentClosed` / `handleAgentError`.
11029
+ * leak forever — retiring it chains `unpinThreadQuestion` and closes its message as unanswered (the question stays
11030
+ * in the topic, its buttons would be dead), matching `handleAgentClosed` / `handleAgentError`.
10250
11031
  */
10251
11032
  function clearGoneProcessFrames(key) {
10252
11033
  clearThreadQueues(key);
@@ -10254,11 +11035,11 @@ function clearGoneProcessFrames(key) {
10254
11035
  deleteStatusMessage(key).catch(() => { });
10255
11036
  clearThinkingMessage(key);
10256
11037
  clearSubagentStatus(key);
10257
- clearPendingQuestion(key);
11038
+ retirePendingQuestionInBackground(key);
10258
11039
  }
10259
11040
  function handleAgentStopped(key) {
10260
11041
  // Single convergence point for every `stopSession`-driven stop path —
10261
- // `/quit` (OpenCode/terminal), `/quit-all`, `/new`, `/unbind`, and adapter
11042
+ // `/quit` (OpenCode/terminal), `/quitall`, `/new`, `/unbind`, and adapter
10262
11043
  // switch all emit `stopped`.
10263
11044
  clearGoneProcessFrames(key);
10264
11045
  // Session ended — a future session starts with empty context, so forget the
@@ -10321,7 +11102,7 @@ exports.COMMANDS_MENU = [
10321
11102
  { command: 'claude', description: '▶️ Start Claude Code' },
10322
11103
  { command: 'opencode', description: '▶️ Start OpenCode' },
10323
11104
  { command: 'terminal', description: '🖥 Open a raw shell in the bound folder' },
10324
- { command: 'claude_mode', description: '🔀 Claude backend: json-stream ⇄ per-turn ⇄ tmux-scrape' },
11105
+ { command: 'claude_mode', description: '🔀 Claude backend: json-stream ⇄ per-turn ⇄ tmux (deprecated)' },
10325
11106
  { command: 'new', description: '🆕 Restart session (alias /clear_session)' },
10326
11107
  { command: 'clear_session', description: '🆕 Restart session (alias /new)' },
10327
11108
  { command: 'model', description: '🧠 Switch model' },
@@ -10338,6 +11119,8 @@ exports.COMMANDS_MENU = [
10338
11119
  { command: 'compact', description: '🧹 Compact agent context' },
10339
11120
  { command: 'compact_on_idle', description: '🧹 Auto-compact after idle (toggle)' },
10340
11121
  { command: 'compact_summary', description: '📄 Post the compaction summary (toggle)' },
11122
+ { command: 'bot_skills', description: '🧰 The bot\'s own skills for agents (on/off)' },
11123
+ { command: 'task_agents', description: '🧵 How many tracker task agents work at once' },
10341
11124
  { command: 'accounts', description: '👤 Claude accounts and OpenCode providers (switch, add, re-login, remove, connect, disconnect)' },
10342
11125
  { command: 'limits', description: '🚧 Usage limits — switch this topic' },
10343
11126
  { command: 'limits_autoswitch', description: '🔁 Automatic switching on a usage limit' },
@@ -10345,6 +11128,7 @@ exports.COMMANDS_MENU = [
10345
11128
  { command: 'update', description: '🔄 Bot updates: automatic, notify only, off' },
10346
11129
  { command: 'schedule', description: '⏰ Schedule a prompt (agent does the work)' },
10347
11130
  { command: 'reminders', description: '🔔 Reminders — add, list, delete (no agent)' },
11131
+ { command: 'todo', description: '📋 Agents\' published todo lists' },
10348
11132
  { command: 'status', description: '📊 Show status' },
10349
11133
  { command: 'output', description: '📜 Last 500 lines' },
10350
11134
  { command: 'trace', description: '🛰 Output-trace recorder on/off' },
@@ -10650,7 +11434,7 @@ async function reattachExistingSessions(opts = { quietReattach: false }) {
10650
11434
  // A per-turn process that outlived the bot is stopped as soon as its turn (if any) ends (L5).
10651
11435
  stopAdoptedPerTurnSessionWhenIdle(key, ownerAdapter);
10652
11436
  }
10653
- if ((0, adoptedToolList_1.checkIsAdoptedToolListStale)({ persistedDigest: agent.mcpToolDigest, currentDigest: botMcpToolDigestReader?.(key.platform) ?? '' })) {
11437
+ if ((0, adoptedToolList_1.checkIsAdoptedToolListStale)({ persistedDigest: agent.mcpToolDigest, currentDigest: botMcpToolDigestReader?.(key) ?? '' })) {
10654
11438
  console.log(`[reattach] ${(0, sessionKey_1.keyToString)(key)}: adopted with a stale tool list; it gets the current tools when it resumes after its idle stop`);
10655
11439
  }
10656
11440
  // The surviving process keeps the account it was spawned with. A switch the user picked while it worked is
@@ -10797,7 +11581,8 @@ async function reattachExistingSessions(opts = { quietReattach: false }) {
10797
11581
  * A persisted question is restored ONLY when its thread's session reattached
10798
11582
  * and is currently active (`adapter.checkIsActive`). Otherwise the question is
10799
11583
  * unreachable — the agent that asked it is gone — so it is dropped from the
10800
- * store, preventing a stale entry from lingering across boots. The persisted
11584
+ * store, preventing a stale entry from lingering across boots; its message is
11585
+ * kept, closed as unanswered, and its pin released. The persisted
10801
11586
  * `messageId` is preserved, so the OLD buttons resolve correctly; the question
10802
11587
  * is never re-posted.
10803
11588
  *
@@ -10830,12 +11615,62 @@ function restorePendingQuestions() {
10830
11615
  restored += 1;
10831
11616
  }
10832
11617
  else {
11618
+ // The agent that asked it is gone, so nobody can answer it: it is closed like any question that ends unanswered —
11619
+ // its message stays with the «not answered» note in place of the buttons and its pin is released. The pin map is
11620
+ // lost with the restart, so it is seeded from the saved message id for the clear to unpin that message.
11621
+ const orphaned = (0, openCodeQuestionFlow_1.migratePendingQuestionState)(value);
11622
+ if (orphaned.messageId !== null)
11623
+ questionPinnedMessageId.set(keyStr, orphaned.messageId);
10833
11624
  clearPendingQuestion(key);
11625
+ closeCapturedQuestionMessage(key, orphaned.messageId, orphaned.data.questions[orphaned.currentIndex])
11626
+ .catch((e) => console.error('[pendingQuestion] closing the dropped question failed:', e));
10834
11627
  dropped += 1;
10835
11628
  }
10836
11629
  }
10837
11630
  console.log(`[reattach] pending questions: restored ${restored}, dropped ${dropped}`);
10838
11631
  }
11632
+ /**
11633
+ * @description Bring back the questions the idle compaction re-asked, at boot. One whose conversation can still be
11634
+ * resumed (a live adopted process, or a sleeping conversation whose id is kept) gets its buttons working again — a tap
11635
+ * wakes it, as before the restart. One whose conversation is gone (released while the bot was down), or beside which a newer
11636
+ * question waits already, cannot be answered / must not be: its message is closed as unanswered, never left with buttons that
11637
+ * do nothing or that would answer the wrong question, and the saved record is dropped.
11638
+ * MUST run AFTER {@link reattachExistingSessions} so session activity is known. A platform this instance does not serve
11639
+ * is left alone, like {@link restorePendingQuestions}. A record saved BEFORE its re-ask was posted (`isPosted: false`: the bot
11640
+ * stopped during the idle compaction) names the message the question waited in: when the conversation can be resumed the
11641
+ * question is asked again now (nobody has had the chance to answer it) and that message says so; otherwise it is closed as
11642
+ * unanswered like any other.
11643
+ */
11644
+ function restoreReAskedQuestions() {
11645
+ for (const [keyStr, saved] of Object.entries(state.getReAskedQuestions())) {
11646
+ let key;
11647
+ try {
11648
+ key = (0, sessionKey_1.keyFromString)(keyStr);
11649
+ }
11650
+ catch {
11651
+ continue;
11652
+ }
11653
+ if (!(0, connectorSet_1.checkIsServedConversation)(key, ENV.servedPlatforms))
11654
+ continue;
11655
+ // A question waits already (restored a moment ago, or asked anew by the agent that reattached): the copy's buttons would
11656
+ // deliver their label into IT, and a question asked again now would sit beside it. Nor is there anything to deliver a label
11657
+ // to once the conversation is gone.
11658
+ const isConversationResumable = !pendingQuestions.has(keyStr) && ((0, createAdapter_1.getThreadAdapter)(key).checkIsActive(key) || getResumableSessionId(key) !== null);
11659
+ if (isConversationResumable) {
11660
+ if (saved.isPosted === false) {
11661
+ // The bot stopped between the start of an idle compaction and its re-ask: the question was rejected on the agent's side
11662
+ // and nobody has answered it — it is asked again now, and the message it waited in (what is saved) says so.
11663
+ reAskInterruptedQuestion(key, saved).catch((e) => console.error('[reAskedQuestion] asking the interrupted question again failed:', e));
11664
+ }
11665
+ else {
11666
+ restoreReAskedQuestion(key, saved);
11667
+ }
11668
+ continue;
11669
+ }
11670
+ // The copy is closed, never brought back; an interrupted question's message ends as unanswered, the one thing known of it.
11671
+ closeSavedReAskedQuestion(key, saved);
11672
+ }
11673
+ }
10839
11674
  /**
10840
11675
  * @description Re-arm persisted API-error retries (S6) so a pending kick —
10841
11676
  * especially a multi-hour usage-limit wait — survives a bot restart. MUST run
@@ -10905,23 +11740,20 @@ function markLimitEpisodeSettled(key) {
10905
11740
  }
10906
11741
  /**
10907
11742
  * @description When the parked `errorText` of a json-stream conversation was written, from its Claude transcript's
10908
- * API-error entry ({@link getParkedErrorWrittenAtMs}); `null` when the transcript tail cannot be read or holds no such
11743
+ * API-error entry ({@link readTranscriptErrorWrittenAtMs}); `null` when the transcript tail cannot be read or holds no such
10909
11744
  * entry — the recovery then goes by the stdout log's mtime.
10910
11745
  */
10911
11746
  function readParkedErrorWrittenAtMs(key, claudeSessionId, errorText) {
10912
11747
  const workDirDecision = getWorkDirStartDecision(key);
10913
11748
  if (!workDirDecision.ok)
10914
11749
  return null;
10915
- const transcriptPath = (0, claudeCliAdapter_1.getClaudeTranscriptPath)(workDirDecision.workDir, claudeSessionId);
10916
- try {
10917
- const sizeBytes = fs.statSync(transcriptPath).size;
10918
- const tail = (0, jsonStreamHost_1.readFileByteRange)(transcriptPath, Math.max(0, sizeBytes - limitEpisodeRecovery_1.limitEpisodeTailMaxBytes), sizeBytes);
10919
- return (0, limitEpisodeRecovery_1.getParkedErrorWrittenAtMs)(tail.toString('utf8'), errorText);
10920
- }
10921
- catch {
10922
- return null; // no transcript on disk — the mtime decides
10923
- }
11750
+ return (0, transcriptErrorTime_1.readTranscriptErrorWrittenAtMs)((0, claudeCliAdapter_1.getClaudeTranscriptPath)(workDirDecision.workDir, claudeSessionId), errorText);
10924
11751
  }
11752
+ /**
11753
+ * @description The threads whose limit episode the boot recovery armed and whose adopt is still replaying the downtime tail
11754
+ * ({@link handleApiError} leaves the replay's copy of that error alone). An entry lives until the replay is over.
11755
+ */
11756
+ const limitEpisodesOwnedByRecovery = new Set();
10925
11757
  /**
10926
11758
  * @description Recover a usage/session-limit episode that ENDED BEFORE this boot
10927
11759
  * and left no armed retry behind — the case {@link restoreApiRetries} cannot help
@@ -11005,6 +11837,10 @@ function recoverLimitEpisodesFromDisk() {
11005
11837
  void state
11006
11838
  .setLimitEpisodeRecovered(key, log)
11007
11839
  .catch(e => console.error('[limitRecovery] marker persist failed:', e));
11840
+ // This episode is the recovery's alone: the adopt replays the same error a moment later (the tail was read from the
11841
+ // saved offset), and `handleApiError` leaves that copy out until the replay is over.
11842
+ limitEpisodesOwnedByRecovery.add(keyStr);
11843
+ void ((0, createAdapter_1.getAdapter)(agent.name).whenAdoptReplayed?.(key) ?? Promise.resolve()).then(() => limitEpisodesOwnedByRecovery.delete(keyStr));
11008
11844
  void withThreadLocale(key, () => handleApiError(key, decision.cls, { isRecoveredEpisode: true }));
11009
11845
  recovered += 1;
11010
11846
  }
@@ -11204,6 +12040,8 @@ let connectorConversationHooks = new Map();
11204
12040
  * {@link clearThreadContextMarker}, which a stop clears too.
11205
12041
  */
11206
12042
  function noteConversationContextReset(key, reason) {
12043
+ // A completed compaction or a fresh session: the agent forgot the board too (D20).
12044
+ noteTodoFreshContext(key);
11207
12045
  connectorConversationHooks.get(key.platform)?.onContextReset(key, reason);
11208
12046
  }
11209
12047
  /**
@@ -11267,6 +12105,36 @@ async function remindAnswerTail(key, text) {
11267
12105
  await forwardPromptToAgent(key, (0, createAdapter_1.getThreadAdapter)(key), text, undefined, { isRequestReminder: true });
11268
12106
  return true;
11269
12107
  }
12108
+ /**
12109
+ * @description How a conversation's session stands right now (`requests/sessionTurnProbe.ts`) — the one probe the
12110
+ * wake-up engine and the todo reminder both poll.
12111
+ */
12112
+ const probeThreadTurn = (0, sessionTurnProbe_1.createSessionTurnProbe)({
12113
+ getAdapter: createAdapter_1.getThreadAdapter,
12114
+ checkHasPendingQuestion: (keyString) => pendingQuestions.has(keyString),
12115
+ checkHasQuestionPin: (keyString) => questionPinnedMessageId.has(keyString),
12116
+ checkIsCompacting: (keyString) => threadsCompacting.has(keyString),
12117
+ getApiRetryTimer: (keyString) => apiRetryTimers.get(keyString)?.timer,
12118
+ checkIsWedgeRecoveryInFlight: (keyString) => wedgeRecoveriesInFlight.has(keyString),
12119
+ checkIsRetryKickInFlight: (keyString) => apiRetryKicksInFlight.has(keyString),
12120
+ checkIsSessionStarting: (keyString) => startupPromptBuffer.checkIsStarting(keyString),
12121
+ serializeKey: sessionKey_1.keyToString,
12122
+ });
12123
+ /**
12124
+ * @description The todo list's standalone reminder (D11 form b): a technical prompt, posted only into a live session
12125
+ * that is idle right now — never a resume, never an interrupt (`postToIdleSession`). It opens no request and is never
12126
+ * replayed; in a Jira conversation it is no hand-over and nothing of its turn reaches the issue (D58).
12127
+ */
12128
+ async function postTodoReminder(key, text) {
12129
+ const adapter = (0, createAdapter_1.getThreadAdapter)(key);
12130
+ // A reminder dropped after an adopt replay (the session was no longer live and idle) was not posted.
12131
+ let isDropped = false;
12132
+ const isPosted = await (0, postToSession_1.postToIdleSession)({
12133
+ checkIsIdle: () => adapter.checkIsActive(key) && !(adapter.checkIsBusy?.(key) ?? false),
12134
+ forward: async () => { isDropped = (await forwardPromptToAgent(key, adapter, text, undefined, { isTechnicalPrompt: true })) === 'dropped'; },
12135
+ });
12136
+ return isPosted && !isDropped;
12137
+ }
11270
12138
  /**
11271
12139
  * @description The person took over the conversation (interrupt, quit, restart,
11272
12140
  * switch or resume of the session, a switch to another agent or a shell, leaving
@@ -11302,8 +12170,8 @@ function createSessionPostDeps() {
11302
12170
  // A scheduled run in a topic with requests on is a request (S7); a tracker's
11303
12171
  // post arrives with its request already open and its header in the text.
11304
12172
  const opening = await openTopicRequest(key, text, { source: 'scheduledRun', requesterId: topicRequest_1.scheduledRunRequester });
11305
- await deliverPromptOrBuffer(key, `${opening.header}${text}`, startupPromptBuffer.checkIsStarting(conversationKey));
11306
- trackTopicRequestTurn(key, opening);
12173
+ const delivery = await deliverPromptOrBuffer(key, `${opening.header}${text}`, startupPromptBuffer.checkIsStarting(conversationKey));
12174
+ await settleTopicRequestAfterDelivery(key, opening, delivery);
11307
12175
  },
11308
12176
  now: () => Date.now(),
11309
12177
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
@@ -11342,6 +12210,15 @@ function wireScheduler(wiring) {
11342
12210
  await state.upsertSchedule({ ...record, isCheckFailing: isFailing, updatedAt: new Date().toISOString() });
11343
12211
  await state.flush();
11344
12212
  },
12213
+ recordAlertPin: async (jobId, messageId) => {
12214
+ const record = state.getSchedules()[jobId];
12215
+ if (!record)
12216
+ return;
12217
+ const alertPinMessageIds = [...(record.alertPinMessageIds ?? []), messageId];
12218
+ await state.upsertSchedule({ ...record, alertPinMessageIds, updatedAt: new Date().toISOString() });
12219
+ // Saved at once, like the failing flag: an id lost in a crash would leave its alert pinned for good.
12220
+ await state.flush();
12221
+ },
11345
12222
  });
11346
12223
  /**
11347
12224
  * The engine's deliver callback: the S4 pipeline plus the S8 unbound-pause
@@ -11377,6 +12254,11 @@ function wireScheduler(wiring) {
11377
12254
  const key = (0, sessionKey_1.keyFromString)(threadKeyStr);
11378
12255
  return (0, createAdapter_1.getThreadAdapterNameRaw)(key) ?? state.getAgent(key)?.name;
11379
12256
  },
12257
+ unpinMessages: async (threadKeyStr, messageIds) => {
12258
+ const key = (0, sessionKey_1.keyFromString)(threadKeyStr);
12259
+ for (const messageId of messageIds)
12260
+ await unpinMessageQuiet(key, messageId);
12261
+ },
11380
12262
  // Routed through the outbound seam: the agent-facing file tool is a
11381
12263
  // connector delivery, not a direct call into the Telegram send pipeline.
11382
12264
  // The MCP surface is string-keyed (its `threadKey` arrives as text), so the
@@ -11391,13 +12273,26 @@ function wireScheduler(wiring) {
11391
12273
  sendMessagesToThread,
11392
12274
  compactConversation: (threadKeyStr) => armDeferredCompaction((0, sessionKey_1.keyFromString)(threadKeyStr)),
11393
12275
  listClaudeProfiles: listClaudeProfilesForAgent,
12276
+ isRemoteRunOffered: () => (0, runnersConfig_1.checkIsRemoteRunOffered)(),
12277
+ // A refused tool call leaves one line in the bounded diagnostic log (agent-diag.log): the SDK refuses before the bot sees it.
12278
+ logDiagnostic: diagLog_1.appendDiagLog,
11394
12279
  ...(jiraConnector
11395
12280
  ? { fetchJiraAttachment: (threadKeyStr, attachmentId) => jiraConnector.fetchAttachment((0, sessionKey_1.keyFromString)(threadKeyStr), attachmentId) }
11396
12281
  : {}),
12282
+ // Read per request: General can turn the shared lists off, and the tools go with them.
12283
+ get todos() {
12284
+ return readTodoSettings().isEnabled
12285
+ ? {
12286
+ update: (threadKeyStr, args) => getTodoTools().update((0, sessionKey_1.keyFromString)(threadKeyStr), args),
12287
+ read: (threadKeyStr, args) => getTodoTools().read((0, sessionKey_1.keyFromString)(threadKeyStr), args),
12288
+ }
12289
+ : undefined;
12290
+ },
11397
12291
  answerRequest: (args) => (0, answerRequest_1.answerRequest)({
11398
12292
  ledger: wiring.requestLedger,
11399
12293
  answerSinks: wiring.answerSinks,
11400
12294
  onAnswerDelivered: (key, requestId, kind) => requestWakeUpEngine?.noteAnswerDelivered(key, requestId, kind),
12295
+ getFinalAnswerNote: (key, requestCreatedAt) => (readTodoSettings().isEnabled ? getTodoRegistry().getFinalAnswerNote(key, requestCreatedAt) : null),
11401
12296
  }, args),
11402
12297
  whenSessionsRestored: wiring.whenSessionsRestored,
11403
12298
  getSecret: () => state.getSchedulerMcpSecret(),
@@ -11616,10 +12511,11 @@ async function prepareJiraConnectorOrExit() {
11616
12511
  * J5): the issue's binding, the request ledger, and the shared session post —
11617
12512
  * after which the wake-up engine watches the turn the request started.
11618
12513
  */
11619
- function createJiraSessionDeps(requestLedger, adapterName) {
12514
+ function createJiraSessionDeps(requestLedger, slots, adapterName) {
11620
12515
  // D5: one conversation per issue — a session that sleeps between requests is resumed by the ensure (L-D4).
11621
12516
  const sessionPostDeps = createSessionPostDeps();
11622
12517
  return {
12518
+ slots,
11623
12519
  bindConversation: async (key, folder) => {
11624
12520
  if (state.getBinding(key)?.subdir !== folder)
11625
12521
  await state.setBinding(key, folder);
@@ -11629,7 +12525,7 @@ function createJiraSessionDeps(requestLedger, adapterName) {
11629
12525
  postRequest: async (key, requestId, prompt) => {
11630
12526
  // A request is the issue's "user message" (L-D5): it clears the spent idle-compaction
11631
12527
  // latch like a topic's message does, or the issue would never compact again.
11632
- noteThreadUserActivity(key);
12528
+ notePersonActivity(key);
11633
12529
  const posted = await (0, postToSession_1.postToSession)(sessionPostDeps, (0, sessionKey_1.keyToString)(key), prompt, adapterName);
11634
12530
  if (!posted.ok) {
11635
12531
  // R28: retried at about 1, 5 and 15 min, outside the wake-up cap.
@@ -11652,7 +12548,7 @@ async function startBot() {
11652
12548
  console.log(`Default Claude backend: ${(0, createAdapter_1.getDefaultClaudeBackendName)()}`);
11653
12549
  console.log(`Available agents: ${(0, createAdapter_1.getAvailableAdapters)().map(a => a.name).join(', ')}`);
11654
12550
  // 1. State store. The `[startup] DATA_DIR=` shape is load-bearing: the
11655
- // README's two-instances troubleshooting tells the operator to compare this
12551
+ // docs/troubleshooting.md's two-instances section tells the operator to compare this
11656
12552
  // exact line across instances.
11657
12553
  state = await (0, state_1.getStateStore)({ saveDebounceMs: ENV.testTimings.stateSaveDebounceMs });
11658
12554
  console.log(`[startup] DATA_DIR=${path.dirname(state.stateFilePath)}`);
@@ -11682,6 +12578,8 @@ async function startBot() {
11682
12578
  // It starts polling once the sessions are restored.
11683
12579
  // Every Claude spawn from here on reads the conversation's account profile (`/account`).
11684
12580
  (0, createAdapter_1.registerClaudeConfigDirReader)(resolveThreadClaudeConfigDir);
12581
+ // The «Bot skills» switch (`/bot_skills`): every agent start, the OpenCode server's start and the bot MCP's instructions read it.
12582
+ (0, botSkills_1.setBotSkillsReader)(botSkillsReader);
11685
12583
  (0, createAdapter_1.registerClaudeRateLimitSink)(onClaudeRateLimitEvent);
11686
12584
  (0, createAdapter_1.registerClaudeTurnCostSink)(onClaudeTurnCost);
11687
12585
  if (ENV.testTimings.openCodeServerUpBudgetMs !== undefined)
@@ -11690,6 +12588,7 @@ async function startBot() {
11690
12588
  claudeUsageRefresher.start();
11691
12589
  void claudeUsageRefresher.refreshAll();
11692
12590
  const jiraConnector = ENV.servedPlatforms.has('jira') ? await prepareJiraConnectorOrExit() : null;
12591
+ todoJiraConversations = jiraConnector;
11693
12592
  if (jiraConnector) {
11694
12593
  const { launchDefaults } = jiraConnector;
11695
12594
  (0, createAdapter_1.registerSessionLaunchDefaultsReader)((key) => (key.platform === 'jira' ? launchDefaults : null));
@@ -11709,24 +12608,26 @@ async function startBot() {
11709
12608
  onRequestCreated: (key) => announceLimitWaitToNewRequest(key),
11710
12609
  // C4: a connector counts what a prompt carried as sent only once its agent took it in.
11711
12610
  onPromptSettled: (key, requestId, outcome) => connectorConversationHooks.get(key.platform)?.onPromptSettled(key, requestId, outcome),
12611
+ // D10: a closed hand-over may free a task-agent slot for a waiting issue.
12612
+ onRequestClosed: (record) => taskAgentSlots?.noteRequestClosed(record),
12613
+ // D10: a request the wake-up engine gave up on no longer holds a slot either.
12614
+ onRequestWakeStopped: (_key, request) => taskAgentSlots?.noteRequestWakeStopped(request),
11712
12615
  });
11713
12616
  await requestLedger.load();
12617
+ const slots = new taskAgentSlots_1.TaskAgentSlots({
12618
+ getMaxConcurrent: () => (0, taskAgentLimit_1.readTaskAgentLimit)(taskAgentLimitSources).maxConcurrent,
12619
+ ledger: requestLedger,
12620
+ store: state,
12621
+ });
12622
+ // A place held for a connector this process no longer serves could never start, and would hold up the others.
12623
+ slots.dropUnserved((key) => (0, connectorSet_1.checkIsServedConversation)(key, ENV.servedPlatforms));
12624
+ taskAgentSlots = slots;
11714
12625
  topicRequestLedger = requestLedger;
11715
12626
  // The wake-up engine (S4) watches the turns requests start. It is created now
11716
12627
  // but started only once the sessions are restored (its sweep reads them).
11717
12628
  requestWakeUpEngine = new wakeUpEngine_1.RequestWakeUpEngine({
11718
12629
  ledger: requestLedger,
11719
- probeTurn: (0, sessionTurnProbe_1.createSessionTurnProbe)({
11720
- getAdapter: createAdapter_1.getThreadAdapter,
11721
- checkHasPendingQuestion: (keyString) => pendingQuestions.has(keyString),
11722
- checkHasQuestionPin: (keyString) => questionPinnedMessageId.has(keyString),
11723
- checkIsCompacting: (keyString) => threadsCompacting.has(keyString),
11724
- getApiRetryTimer: (keyString) => apiRetryTimers.get(keyString)?.timer,
11725
- checkIsWedgeRecoveryInFlight: (keyString) => wedgeRecoveriesInFlight.has(keyString),
11726
- checkIsRetryKickInFlight: (keyString) => apiRetryKicksInFlight.has(keyString),
11727
- checkIsSessionStarting: (keyString) => startupPromptBuffer.checkIsStarting(keyString),
11728
- serializeKey: sessionKey_1.keyToString,
11729
- }),
12630
+ probeTurn: probeThreadTurn,
11730
12631
  prepareWakeUpSession: (key, message) => prepareRequestWakeUpSession(key, message, key.platform === 'jira' ? jiraConnector?.adapterName : undefined),
11731
12632
  forwardWakeUp: forwardRequestWakeUp,
11732
12633
  deliverAlert: async (key, request, reason) => {
@@ -11739,6 +12640,7 @@ async function startBot() {
11739
12640
  return result.ok ? (result.alertRef ?? null) : null;
11740
12641
  },
11741
12642
  backstopMs: (0, wakeUpRules_1.getRequestBackstopMs)(process.env.REQUEST_BACKSTOP_MINUTES),
12643
+ pollIntervalMs: ENV.testTimings.requestTurnPollMs,
11742
12644
  sweepIntervalMs: ENV.testTimings.requestSweepIntervalMs,
11743
12645
  firstPostRetryDelayMs: ENV.testTimings.requestFirstPostRetryDelayMs,
11744
12646
  // Only a Telegram topic in the answers-only view hides the stream from someone who expects to see the work.
@@ -11746,6 +12648,25 @@ async function startBot() {
11746
12648
  remindAnswerTail,
11747
12649
  });
11748
12650
  requestLimitWaitAnswerDeps = { ledger: requestLedger, engine: requestWakeUpEngine, answerSinks };
12651
+ // The todo list's staleness reminder (D10–D12) polls the same turns; started once the sessions are restored.
12652
+ todoReminderEngine = new reminderEngine_1.TodoReminderEngine({
12653
+ registry: getTodoRegistry(),
12654
+ listConversations: () => state.listBindings().map(({ key }) => key),
12655
+ probeTurn: probeThreadTurn,
12656
+ getGate: (key) => ({
12657
+ hasOpenRequest: requestLedger.listOpenRequestsOf(key).length > 0,
12658
+ isStoppingAfterTurn: (0, createAdapter_1.getThreadAdapter)(key).name === adapterNames_1.claudePerTurnAdapterName,
12659
+ isAnswerTailFollowed: requestWakeUpEngine?.checkIsAnswerTailFollowed(key) ?? false,
12660
+ ...getConversationAccountState(key),
12661
+ }),
12662
+ getIntervalMs: getTodoReminderIntervalMsNow,
12663
+ checkIsEnabled: () => readTodoSettings().isEnabled,
12664
+ postReminder: postTodoReminder,
12665
+ midTurn: getTodoMidTurn(),
12666
+ pollIntervalMs: ENV.testTimings.todoReminderPollMs,
12667
+ });
12668
+ getTodoMidTurn().clearInboxes();
12669
+ warmTodoProjects();
11749
12670
  // Snapshot the persisted transient status-frame ids (S2) NOW, before reattach
11750
12671
  // can run any frame-id setter. A reattached session's first frame lifecycle
11751
12672
  // call persists the FRESH (all-null) in-memory state and would otherwise
@@ -11801,8 +12722,9 @@ async function startBot() {
11801
12722
  // that gives a conversation of another platform only its request-side handling
11802
12723
  // (Jira connector plan J2b, R2 — the routing table is unit-tested per event).
11803
12724
  const noteTurnOutput = (key) => {
11804
- // Output means a turn is under way — the wake-up engine's busy-onset signal.
12725
+ // Output means a turn is under way — the wake-up engine's busy-onset signal, and the todo reminder's.
11805
12726
  requestWakeUpEngine?.noteAgentOutput(key);
12727
+ todoReminderEngine?.noteAgentOutput(key);
11806
12728
  };
11807
12729
  (0, createAdapter_1.registerAdapterEventHandlers)({
11808
12730
  onOutput: (key, output, meta) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'output', () => {
@@ -11841,7 +12763,12 @@ async function startBot() {
11841
12763
  }),
11842
12764
  onStopped: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'stopped', () => withThreadLocale(key, () => handleAgentStopped(key)), () => clearForeignSessionState(key)),
11843
12765
  onSuspended: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'suspended', () => withThreadLocale(key, () => handleAgentSuspended(key)), () => scheduleLostWorkNote(key, takeLostWorkOf(key), 'holdExpired')),
11844
- onTurnEnded: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'turnEnded', () => handleAgentTurnEnded(key)),
12766
+ onTurnEnded: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'turnEnded', () => {
12767
+ // Every platform's (the route is `all`): the todo reminder's turn end, which the per-turn stop below would
12768
+ // otherwise hide from its poll.
12769
+ void todoReminderEngine?.noteAdapterTurnEnd(key).catch((e) => console.warn(`[todos] the turn end of ${(0, sessionKey_1.keyToString)(key)} was not noted:`, e instanceof Error ? e.message : e));
12770
+ handleAgentTurnEnded(key);
12771
+ }),
11845
12772
  onContextCompacted: (key, trigger) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'contextCompacted', () => noteConversationContextReset(key, `compaction (${trigger ?? 'bot'})`)),
11846
12773
  onConversationLost: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'conversationLost', () => withThreadLocale(key, () => handleConversationLost(key)), () => { dropLostConversationWatermark(key); noteConversationContextReset(key, 'its transcript was lost'); }),
11847
12774
  onError: (key, error) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'error', () => withThreadLocale(key, () => handleAgentError(key, error)), () => {
@@ -11943,7 +12870,7 @@ async function startBot() {
11943
12870
  });
11944
12871
  // Known before any session starts or is adopted: every json-stream start persists the digest of the
11945
12872
  // tools it connected to, and the reattach compares an adopted process's against it (L4).
11946
- botMcpToolDigestReader = (platform) => schedulerMcpHandle.getToolDigest(platform);
12873
+ botMcpToolDigestReader = (key) => schedulerMcpHandle.getToolDigest(key.platform, (0, botSkills_1.checkIsBotSkillsEnabled)(key));
11947
12874
  const isSchedulerMcpStarted = await (0, mcpBoot_1.runSessionBootPhase)({
11948
12875
  startBotMcp: () => (0, mcpBoot_1.startSchedulerMcpForBoot)({
11949
12876
  handle: schedulerMcpHandle,
@@ -11964,6 +12891,9 @@ async function startBot() {
11964
12891
  // existing option buttons keep working. A question is restored only for a
11965
12892
  // thread whose session came back; otherwise it is unreachable, dropped.
11966
12893
  restorePendingQuestions();
12894
+ // The questions the idle compaction re-asked: their buttons come back while the conversation can be resumed,
12895
+ // else the message is closed.
12896
+ restoreReAskedQuestions();
11967
12897
  // Re-arm persisted API-error retries (S6) so a pending kick survives the
11968
12898
  // restart. AFTER restorePendingQuestions so the kick lands in a live
11969
12899
  // session; a past fireAt fires one delayed catch-up.
@@ -11986,7 +12916,8 @@ async function startBot() {
11986
12916
  markSessionsRestored();
11987
12917
  // From here the engine can tell a live turn from a dead session.
11988
12918
  requestWakeUpEngine?.start();
11989
- jiraConnector?.start(createJiraSessionDeps(requestLedger, jiraConnector.adapterName));
12919
+ todoReminderEngine?.start();
12920
+ jiraConnector?.start(createJiraSessionDeps(requestLedger, slots, jiraConnector.adapterName));
11990
12921
  },
11991
12922
  healActiveSessions: () => {
11992
12923
  // When this boot ADOPTED an already-running opencode, that server may
@@ -11994,11 +12925,13 @@ async function startBot() {
11994
12925
  // generation pointing at a now-dead port. Reconcile against the live MCP
11995
12926
  // status and force-refresh any directory that is not `connected`.
11996
12927
  // Fire-and-forget: boot must not block on opencode HTTP round-trips
11997
- // before `bot.launch()`. The compaction-plugin step goes FIRST: recreating
11998
- // a directory instance so it loads the plugin drops that directory's MCP
12928
+ // before `bot.launch()`. The skills step (a server started without the
12929
+ // bot's skills folder is replaced) and the compaction-plugin step go FIRST:
12930
+ // a replaced server or a recreated directory instance drops the MCP
11999
12931
  // registration, which this same reconcile then restores.
12000
12932
  const openCodeAdapter = (0, createAdapter_1.getAdapter)('opencode');
12001
12933
  void (async () => {
12934
+ await openCodeAdapter.activateAgentSkillsForServer?.();
12002
12935
  await openCodeAdapter.activateCompactionPluginForActiveSessions?.();
12003
12936
  await openCodeAdapter.reconcileSchedulerMcpForActiveSessions?.();
12004
12937
  })().catch((e) => console.warn('[scheduler] MCP reconcile failed:', e instanceof Error ? e.message : e));
@@ -12127,12 +13060,10 @@ async function startBot() {
12127
13060
  // thread that no longer owns a json-stream session (the normal stop /
12128
13061
  // release paths delete the dir themselves; reattach's adopt/orphan-kill
12129
13062
  // covers live tmux sessions — this covers their dirs).
12130
- void (0, jsonStreamHost_1.sweepOrphanJsonStreamDirs)((0, jsonStreamHost_1.resolveJsonStreamRoot)(getDataDir()), (key) => {
12131
- if ((0, createAdapter_1.getAdapter)(adapterNames_1.claudeJsonStreamAdapterName).checkIsActive(key))
12132
- return true;
12133
- const agent = state.getAgent(key);
12134
- return agent?.name === adapterNames_1.claudeJsonStreamAdapterName && !!agent.claudeSessionId;
12135
- })
13063
+ void (0, jsonStreamHost_1.sweepOrphanJsonStreamDirs)((0, jsonStreamHost_1.resolveJsonStreamRoot)(getDataDir()), (key) => (0, jsonStreamHost_1.checkIsJsonStreamHostOwned)({
13064
+ isSessionActive: [adapterNames_1.claudeJsonStreamAdapterName, adapterNames_1.claudePerTurnAdapterName].some((name) => (0, createAdapter_1.getAdapter)(name).checkIsActive(key)),
13065
+ agent: state.getAgent(key),
13066
+ }))
12136
13067
  .then((removedCount) => {
12137
13068
  if (removedCount > 0)
12138
13069
  console.log(`[fileSweep] removed ${removedCount} orphan jsonstream dirs`);
@@ -12175,7 +13106,7 @@ async function startBot() {
12175
13106
  console.error('[bot.catch] unhandled error:', err, 'update:', ctx.updateType);
12176
13107
  });
12177
13108
  // 7. Shutdown — preserve active agents for restart/reattach. Use /quit
12178
- // (or /quit-all) for an intentional agent stop; process signals only
13109
+ // (or /quitall) for an intentional agent stop; process signals only
12179
13110
  // stop the bot itself. Ordering (bot.stop → state.flush →
12180
13111
  // releaseLock → exit) is enforced by `gracefulShutdown` so the
12181
13112
  // previous race against `lock.ts`'s synchronous `process.exit(0)`
@@ -12206,6 +13137,7 @@ async function startBot() {
12206
13137
  // means the next boot's rearmAll picks them back up (catch-up replay).
12207
13138
  schedulerEngine?.shutdown();
12208
13139
  requestWakeUpEngine?.stop();
13140
+ todoReminderEngine?.stop();
12209
13141
  jiraConnector?.stop();
12210
13142
  if (isSchedulerMcpStarted)
12211
13143
  void schedulerMcpHandle.stop().catch(() => { });