charness 3.6.1 → 3.8.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 (828) hide show
  1. package/CHANGELOG.md +297 -0
  2. package/DEVELOPMENT.md +35 -5
  3. package/MIGRATION_RENAME_TO_CHARNESS.md +3 -2
  4. package/README.md +84 -782
  5. package/SETUP.md +48 -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 +97 -7
  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 +1360 -387
  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 +55 -1
  54. package/dist/cli.js.map +1 -1
  55. package/dist/configDirFile.d.ts +7 -3
  56. package/dist/configDirFile.d.ts.map +1 -1
  57. package/dist/configDirFile.js +12 -5
  58. package/dist/configDirFile.js.map +1 -1
  59. package/dist/connectors/jira/answerSink.d.ts +10 -0
  60. package/dist/connectors/jira/answerSink.d.ts.map +1 -1
  61. package/dist/connectors/jira/answerSink.js +13 -0
  62. package/dist/connectors/jira/answerSink.js.map +1 -1
  63. package/dist/connectors/jira/config.d.ts +2 -0
  64. package/dist/connectors/jira/config.d.ts.map +1 -1
  65. package/dist/connectors/jira/config.js +9 -1
  66. package/dist/connectors/jira/config.js.map +1 -1
  67. package/dist/connectors/jira/connector.d.ts +11 -1
  68. package/dist/connectors/jira/connector.d.ts.map +1 -1
  69. package/dist/connectors/jira/connector.js +7 -0
  70. package/dist/connectors/jira/connector.js.map +1 -1
  71. package/dist/connectors/jira/inbound.d.ts +27 -4
  72. package/dist/connectors/jira/inbound.d.ts.map +1 -1
  73. package/dist/connectors/jira/inbound.js +69 -8
  74. package/dist/connectors/jira/inbound.js.map +1 -1
  75. package/dist/connectors/jira/issueBlocks.d.ts +5 -0
  76. package/dist/connectors/jira/issueBlocks.d.ts.map +1 -1
  77. package/dist/connectors/jira/issueBlocks.js +11 -0
  78. package/dist/connectors/jira/issueBlocks.js.map +1 -1
  79. package/dist/connectors/telegram/bindPicker.d.ts +5 -0
  80. package/dist/connectors/telegram/bindPicker.d.ts.map +1 -1
  81. package/dist/connectors/telegram/bindPicker.js +18 -1
  82. package/dist/connectors/telegram/bindPicker.js.map +1 -1
  83. package/dist/connectors/telegram/bindPickerClaims.d.ts +46 -0
  84. package/dist/connectors/telegram/bindPickerClaims.d.ts.map +1 -0
  85. package/dist/connectors/telegram/bindPickerClaims.js +83 -0
  86. package/dist/connectors/telegram/bindPickerClaims.js.map +1 -0
  87. package/dist/connectors/telegram/bindSweep.d.ts +32 -0
  88. package/dist/connectors/telegram/bindSweep.d.ts.map +1 -0
  89. package/dist/connectors/telegram/bindSweep.js +47 -0
  90. package/dist/connectors/telegram/bindSweep.js.map +1 -0
  91. package/dist/connectors/telegram/bindingWelcome.d.ts +23 -0
  92. package/dist/connectors/telegram/bindingWelcome.d.ts.map +1 -0
  93. package/dist/connectors/telegram/bindingWelcome.js +26 -0
  94. package/dist/connectors/telegram/bindingWelcome.js.map +1 -0
  95. package/dist/connectors/telegram/botApiConnection.d.ts +74 -0
  96. package/dist/connectors/telegram/botApiConnection.d.ts.map +1 -0
  97. package/dist/connectors/telegram/botApiConnection.js +201 -0
  98. package/dist/connectors/telegram/botApiConnection.js.map +1 -0
  99. package/dist/connectors/telegram/commands/botCore.d.ts +13 -3
  100. package/dist/connectors/telegram/commands/botCore.d.ts.map +1 -1
  101. package/dist/connectors/telegram/commands/botSkills.d.ts +21 -0
  102. package/dist/connectors/telegram/commands/botSkills.d.ts.map +1 -0
  103. package/dist/connectors/telegram/commands/botSkills.js +133 -0
  104. package/dist/connectors/telegram/commands/botSkills.js.map +1 -0
  105. package/dist/connectors/telegram/commands/callbackMessage.d.ts +5 -0
  106. package/dist/connectors/telegram/commands/callbackMessage.d.ts.map +1 -1
  107. package/dist/connectors/telegram/commands/callbackMessage.js +9 -0
  108. package/dist/connectors/telegram/commands/callbackMessage.js.map +1 -1
  109. package/dist/connectors/telegram/commands/compaction.d.ts +65 -3
  110. package/dist/connectors/telegram/commands/compaction.d.ts.map +1 -1
  111. package/dist/connectors/telegram/commands/compaction.js +512 -94
  112. package/dist/connectors/telegram/commands/compaction.js.map +1 -1
  113. package/dist/connectors/telegram/commands/displayModes.d.ts +1 -1
  114. package/dist/connectors/telegram/commands/displayModes.d.ts.map +1 -1
  115. package/dist/connectors/telegram/commands/displayModes.js +6 -18
  116. package/dist/connectors/telegram/commands/displayModes.js.map +1 -1
  117. package/dist/connectors/telegram/commands/fallbackChain.js +5 -5
  118. package/dist/connectors/telegram/commands/fallbackChain.js.map +1 -1
  119. package/dist/connectors/telegram/commands/reminders.d.ts +1 -1
  120. package/dist/connectors/telegram/commands/reminders.d.ts.map +1 -1
  121. package/dist/connectors/telegram/commands/reminders.js +4 -15
  122. package/dist/connectors/telegram/commands/reminders.js.map +1 -1
  123. package/dist/connectors/telegram/commands/runners.d.ts +52 -0
  124. package/dist/connectors/telegram/commands/runners.d.ts.map +1 -0
  125. package/dist/connectors/telegram/commands/runners.js +486 -0
  126. package/dist/connectors/telegram/commands/runners.js.map +1 -0
  127. package/dist/connectors/telegram/commands/selfUpdate.d.ts.map +1 -1
  128. package/dist/connectors/telegram/commands/selfUpdate.js +1 -11
  129. package/dist/connectors/telegram/commands/selfUpdate.js.map +1 -1
  130. package/dist/connectors/telegram/commands/taskAgents.d.ts +22 -0
  131. package/dist/connectors/telegram/commands/taskAgents.d.ts.map +1 -0
  132. package/dist/connectors/telegram/commands/taskAgents.js +104 -0
  133. package/dist/connectors/telegram/commands/taskAgents.js.map +1 -0
  134. package/dist/connectors/telegram/commands/todo.d.ts +82 -0
  135. package/dist/connectors/telegram/commands/todo.d.ts.map +1 -0
  136. package/dist/connectors/telegram/commands/todo.js +384 -0
  137. package/dist/connectors/telegram/commands/todo.js.map +1 -0
  138. package/dist/connectors/telegram/commands/topicCommand.d.ts +6 -2
  139. package/dist/connectors/telegram/commands/topicCommand.d.ts.map +1 -1
  140. package/dist/connectors/telegram/commands/topicCommand.js +6 -9
  141. package/dist/connectors/telegram/commands/topicCommand.js.map +1 -1
  142. package/dist/connectors/telegram/openBindMessages.d.ts +45 -0
  143. package/dist/connectors/telegram/openBindMessages.d.ts.map +1 -0
  144. package/dist/connectors/telegram/openBindMessages.js +67 -0
  145. package/dist/connectors/telegram/openBindMessages.js.map +1 -0
  146. package/dist/connectors/telegram/outputFlushPlan.d.ts +49 -0
  147. package/dist/connectors/telegram/outputFlushPlan.d.ts.map +1 -1
  148. package/dist/connectors/telegram/outputFlushPlan.js +46 -0
  149. package/dist/connectors/telegram/outputFlushPlan.js.map +1 -1
  150. package/dist/connectors/telegram/pendingTopicName.d.ts +26 -0
  151. package/dist/connectors/telegram/pendingTopicName.d.ts.map +1 -0
  152. package/dist/connectors/telegram/pendingTopicName.js +27 -0
  153. package/dist/connectors/telegram/pendingTopicName.js.map +1 -0
  154. package/dist/connectors/telegram/runnerPairingMessages.d.ts +27 -0
  155. package/dist/connectors/telegram/runnerPairingMessages.d.ts.map +1 -0
  156. package/dist/connectors/telegram/runnerPairingMessages.js +105 -0
  157. package/dist/connectors/telegram/runnerPairingMessages.js.map +1 -0
  158. package/dist/connectors/test/connector.d.ts.map +1 -1
  159. package/dist/connectors/test/connector.js +6 -1
  160. package/dist/connectors/test/connector.js.map +1 -1
  161. package/dist/i18n/de.d.ts.map +1 -1
  162. package/dist/i18n/de.js +148 -7
  163. package/dist/i18n/de.js.map +1 -1
  164. package/dist/i18n/en.d.ts.map +1 -1
  165. package/dist/i18n/en.js +148 -7
  166. package/dist/i18n/en.js.map +1 -1
  167. package/dist/i18n/es.d.ts.map +1 -1
  168. package/dist/i18n/es.js +148 -7
  169. package/dist/i18n/es.js.map +1 -1
  170. package/dist/i18n/fr.d.ts.map +1 -1
  171. package/dist/i18n/fr.js +148 -7
  172. package/dist/i18n/fr.js.map +1 -1
  173. package/dist/i18n/hi.d.ts.map +1 -1
  174. package/dist/i18n/hi.js +148 -7
  175. package/dist/i18n/hi.js.map +1 -1
  176. package/dist/i18n/ja.d.ts.map +1 -1
  177. package/dist/i18n/ja.js +148 -7
  178. package/dist/i18n/ja.js.map +1 -1
  179. package/dist/i18n/ka.d.ts.map +1 -1
  180. package/dist/i18n/ka.js +148 -7
  181. package/dist/i18n/ka.js.map +1 -1
  182. package/dist/i18n/pt.d.ts.map +1 -1
  183. package/dist/i18n/pt.js +148 -7
  184. package/dist/i18n/pt.js.map +1 -1
  185. package/dist/i18n/ru.d.ts.map +1 -1
  186. package/dist/i18n/ru.js +148 -7
  187. package/dist/i18n/ru.js.map +1 -1
  188. package/dist/i18n/uk.d.ts.map +1 -1
  189. package/dist/i18n/uk.js +148 -7
  190. package/dist/i18n/uk.js.map +1 -1
  191. package/dist/i18n/uz.d.ts.map +1 -1
  192. package/dist/i18n/uz.js +148 -7
  193. package/dist/i18n/uz.js.map +1 -1
  194. package/dist/i18n/zh.d.ts.map +1 -1
  195. package/dist/i18n/zh.js +148 -7
  196. package/dist/i18n/zh.js.map +1 -1
  197. package/dist/i18n.d.ts +1 -1
  198. package/dist/i18n.js +1 -1
  199. package/dist/installManager.d.ts +70 -1
  200. package/dist/installManager.d.ts.map +1 -1
  201. package/dist/installManager.js +125 -13
  202. package/dist/installManager.js.map +1 -1
  203. package/dist/instanceSettings.d.ts +50 -1
  204. package/dist/instanceSettings.d.ts.map +1 -1
  205. package/dist/instanceSettings.js +119 -26
  206. package/dist/instanceSettings.js.map +1 -1
  207. package/dist/openCodeQuestionFlow.d.ts +30 -0
  208. package/dist/openCodeQuestionFlow.d.ts.map +1 -1
  209. package/dist/openCodeQuestionFlow.js +43 -0
  210. package/dist/openCodeQuestionFlow.js.map +1 -1
  211. package/dist/outputTrace.d.ts +6 -0
  212. package/dist/outputTrace.d.ts.map +1 -1
  213. package/dist/outputTrace.js +24 -1
  214. package/dist/outputTrace.js.map +1 -1
  215. package/dist/postToSession.d.ts +20 -5
  216. package/dist/postToSession.d.ts.map +1 -1
  217. package/dist/postToSession.js +38 -7
  218. package/dist/postToSession.js.map +1 -1
  219. package/dist/remoteRun/cliArgs.d.ts +75 -0
  220. package/dist/remoteRun/cliArgs.d.ts.map +1 -0
  221. package/dist/remoteRun/cliArgs.js +206 -0
  222. package/dist/remoteRun/cliArgs.js.map +1 -0
  223. package/dist/remoteRun/commands.d.ts +77 -0
  224. package/dist/remoteRun/commands.d.ts.map +1 -0
  225. package/dist/remoteRun/commands.js +327 -0
  226. package/dist/remoteRun/commands.js.map +1 -0
  227. package/dist/remoteRun/depsPlan.d.ts +16 -0
  228. package/dist/remoteRun/depsPlan.d.ts.map +1 -0
  229. package/dist/remoteRun/depsPlan.js +82 -0
  230. package/dist/remoteRun/depsPlan.js.map +1 -0
  231. package/dist/remoteRun/exitCodes.d.ts +34 -0
  232. package/dist/remoteRun/exitCodes.d.ts.map +1 -0
  233. package/dist/remoteRun/exitCodes.js +38 -0
  234. package/dist/remoteRun/exitCodes.js.map +1 -0
  235. package/dist/remoteRun/foreground.d.ts +29 -0
  236. package/dist/remoteRun/foreground.d.ts.map +1 -0
  237. package/dist/remoteRun/foreground.js +159 -0
  238. package/dist/remoteRun/foreground.js.map +1 -0
  239. package/dist/remoteRun/interrupts.d.ts +27 -0
  240. package/dist/remoteRun/interrupts.d.ts.map +1 -0
  241. package/dist/remoteRun/interrupts.js +78 -0
  242. package/dist/remoteRun/interrupts.js.map +1 -0
  243. package/dist/remoteRun/jobFiles.d.ts +31 -0
  244. package/dist/remoteRun/jobFiles.d.ts.map +1 -0
  245. package/dist/remoteRun/jobFiles.js +84 -0
  246. package/dist/remoteRun/jobFiles.js.map +1 -0
  247. package/dist/remoteRun/jobFormat.d.ts +16 -0
  248. package/dist/remoteRun/jobFormat.d.ts.map +1 -0
  249. package/dist/remoteRun/jobFormat.js +130 -0
  250. package/dist/remoteRun/jobFormat.js.map +1 -0
  251. package/dist/remoteRun/jobId.d.ts +15 -0
  252. package/dist/remoteRun/jobId.d.ts.map +1 -0
  253. package/dist/remoteRun/jobId.js +85 -0
  254. package/dist/remoteRun/jobId.js.map +1 -0
  255. package/dist/remoteRun/jobMeta.d.ts +18 -0
  256. package/dist/remoteRun/jobMeta.d.ts.map +1 -0
  257. package/dist/remoteRun/jobMeta.js +33 -0
  258. package/dist/remoteRun/jobMeta.js.map +1 -0
  259. package/dist/remoteRun/jobQuery.d.ts +12 -0
  260. package/dist/remoteRun/jobQuery.d.ts.map +1 -0
  261. package/dist/remoteRun/jobQuery.js +22 -0
  262. package/dist/remoteRun/jobQuery.js.map +1 -0
  263. package/dist/remoteRun/jobRecords.d.ts +22 -0
  264. package/dist/remoteRun/jobRecords.d.ts.map +1 -0
  265. package/dist/remoteRun/jobRecords.js +145 -0
  266. package/dist/remoteRun/jobRecords.js.map +1 -0
  267. package/dist/remoteRun/jobState.d.ts +62 -0
  268. package/dist/remoteRun/jobState.d.ts.map +1 -0
  269. package/dist/remoteRun/jobState.js +132 -0
  270. package/dist/remoteRun/jobState.js.map +1 -0
  271. package/dist/remoteRun/leaseTiming.d.ts +18 -0
  272. package/dist/remoteRun/leaseTiming.d.ts.map +1 -0
  273. package/dist/remoteRun/leaseTiming.js +43 -0
  274. package/dist/remoteRun/leaseTiming.js.map +1 -0
  275. package/dist/remoteRun/output.d.ts +37 -0
  276. package/dist/remoteRun/output.d.ts.map +1 -0
  277. package/dist/remoteRun/output.js +127 -0
  278. package/dist/remoteRun/output.js.map +1 -0
  279. package/dist/remoteRun/pairedRunners.d.ts +51 -0
  280. package/dist/remoteRun/pairedRunners.d.ts.map +1 -0
  281. package/dist/remoteRun/pairedRunners.js +165 -0
  282. package/dist/remoteRun/pairedRunners.js.map +1 -0
  283. package/dist/remoteRun/pathPatterns.d.ts +33 -0
  284. package/dist/remoteRun/pathPatterns.d.ts.map +1 -0
  285. package/dist/remoteRun/pathPatterns.js +115 -0
  286. package/dist/remoteRun/pathPatterns.js.map +1 -0
  287. package/dist/remoteRun/probe.d.ts +78 -0
  288. package/dist/remoteRun/probe.d.ts.map +1 -0
  289. package/dist/remoteRun/probe.js +157 -0
  290. package/dist/remoteRun/probe.js.map +1 -0
  291. package/dist/remoteRun/project.d.ts +26 -0
  292. package/dist/remoteRun/project.d.ts.map +1 -0
  293. package/dist/remoteRun/project.js +159 -0
  294. package/dist/remoteRun/project.js.map +1 -0
  295. package/dist/remoteRun/projectConfig.d.ts +38 -0
  296. package/dist/remoteRun/projectConfig.d.ts.map +1 -0
  297. package/dist/remoteRun/projectConfig.js +87 -0
  298. package/dist/remoteRun/projectConfig.js.map +1 -0
  299. package/dist/remoteRun/remoteRunCli.d.ts +22 -0
  300. package/dist/remoteRun/remoteRunCli.d.ts.map +1 -0
  301. package/dist/remoteRun/remoteRunCli.js +119 -0
  302. package/dist/remoteRun/remoteRunCli.js.map +1 -0
  303. package/dist/remoteRun/remoteRunError.d.ts +14 -0
  304. package/dist/remoteRun/remoteRunError.d.ts.map +1 -0
  305. package/dist/remoteRun/remoteRunError.js +28 -0
  306. package/dist/remoteRun/remoteRunError.js.map +1 -0
  307. package/dist/remoteRun/rsync.d.ts +17 -0
  308. package/dist/remoteRun/rsync.d.ts.map +1 -0
  309. package/dist/remoteRun/rsync.js +39 -0
  310. package/dist/remoteRun/rsync.js.map +1 -0
  311. package/dist/remoteRun/runnerClient.d.ts +52 -0
  312. package/dist/remoteRun/runnerClient.d.ts.map +1 -0
  313. package/dist/remoteRun/runnerClient.js +143 -0
  314. package/dist/remoteRun/runnerClient.js.map +1 -0
  315. package/dist/remoteRun/runnerSummary.d.ts +24 -0
  316. package/dist/remoteRun/runnerSummary.d.ts.map +1 -0
  317. package/dist/remoteRun/runnerSummary.js +86 -0
  318. package/dist/remoteRun/runnerSummary.js.map +1 -0
  319. package/dist/remoteRun/runnerWait.d.ts +68 -0
  320. package/dist/remoteRun/runnerWait.d.ts.map +1 -0
  321. package/dist/remoteRun/runnerWait.js +137 -0
  322. package/dist/remoteRun/runnerWait.js.map +1 -0
  323. package/dist/remoteRun/runnerWaitTiming.d.ts +16 -0
  324. package/dist/remoteRun/runnerWaitTiming.d.ts.map +1 -0
  325. package/dist/remoteRun/runnerWaitTiming.js +37 -0
  326. package/dist/remoteRun/runnerWaitTiming.js.map +1 -0
  327. package/dist/remoteRun/runners.d.ts +57 -0
  328. package/dist/remoteRun/runners.d.ts.map +1 -0
  329. package/dist/remoteRun/runners.js +209 -0
  330. package/dist/remoteRun/runners.js.map +1 -0
  331. package/dist/remoteRun/runnersCatalog.d.ts +14 -0
  332. package/dist/remoteRun/runnersCatalog.d.ts.map +1 -0
  333. package/dist/remoteRun/runnersCatalog.js +68 -0
  334. package/dist/remoteRun/runnersCatalog.js.map +1 -0
  335. package/dist/remoteRun/runnersConfig.d.ts +53 -0
  336. package/dist/remoteRun/runnersConfig.d.ts.map +1 -0
  337. package/dist/remoteRun/runnersConfig.js +109 -0
  338. package/dist/remoteRun/runnersConfig.js.map +1 -0
  339. package/dist/remoteRun/safeCopy.d.ts +31 -0
  340. package/dist/remoteRun/safeCopy.d.ts.map +1 -0
  341. package/dist/remoteRun/safeCopy.js +137 -0
  342. package/dist/remoteRun/safeCopy.js.map +1 -0
  343. package/dist/remoteRun/sendPlan.d.ts +67 -0
  344. package/dist/remoteRun/sendPlan.d.ts.map +1 -0
  345. package/dist/remoteRun/sendPlan.js +139 -0
  346. package/dist/remoteRun/sendPlan.js.map +1 -0
  347. package/dist/remoteRun/ssh.d.ts +82 -0
  348. package/dist/remoteRun/ssh.d.ts.map +1 -0
  349. package/dist/remoteRun/ssh.js +236 -0
  350. package/dist/remoteRun/ssh.js.map +1 -0
  351. package/dist/remoteRun/submit.d.ts +29 -0
  352. package/dist/remoteRun/submit.d.ts.map +1 -0
  353. package/dist/remoteRun/submit.js +142 -0
  354. package/dist/remoteRun/submit.js.map +1 -0
  355. package/dist/requests/answerRequest.d.ts +5 -0
  356. package/dist/requests/answerRequest.d.ts.map +1 -1
  357. package/dist/requests/answerRequest.js +16 -1
  358. package/dist/requests/answerRequest.js.map +1 -1
  359. package/dist/requests/requestLedger.d.ts +13 -0
  360. package/dist/requests/requestLedger.d.ts.map +1 -1
  361. package/dist/requests/requestLedger.js +9 -0
  362. package/dist/requests/requestLedger.js.map +1 -1
  363. package/dist/requests/topicRequestSettle.d.ts +30 -0
  364. package/dist/requests/topicRequestSettle.d.ts.map +1 -0
  365. package/dist/requests/topicRequestSettle.js +23 -0
  366. package/dist/requests/topicRequestSettle.js.map +1 -0
  367. package/dist/requests/wakeUpEngine.d.ts +4 -0
  368. package/dist/requests/wakeUpEngine.d.ts.map +1 -1
  369. package/dist/requests/wakeUpEngine.js +5 -1
  370. package/dist/requests/wakeUpEngine.js.map +1 -1
  371. package/dist/runnerUtility/adminCheck.d.ts +11 -0
  372. package/dist/runnerUtility/adminCheck.d.ts.map +1 -0
  373. package/dist/runnerUtility/adminCheck.js +68 -0
  374. package/dist/runnerUtility/adminCheck.js.map +1 -0
  375. package/dist/runnerUtility/appInstall.d.ts +25 -0
  376. package/dist/runnerUtility/appInstall.d.ts.map +1 -0
  377. package/dist/runnerUtility/appInstall.js +147 -0
  378. package/dist/runnerUtility/appInstall.js.map +1 -0
  379. package/dist/runnerUtility/appUpdate.d.ts +39 -0
  380. package/dist/runnerUtility/appUpdate.d.ts.map +1 -0
  381. package/dist/runnerUtility/appUpdate.js +90 -0
  382. package/dist/runnerUtility/appUpdate.js.map +1 -0
  383. package/dist/runnerUtility/execEnv.d.ts +21 -0
  384. package/dist/runnerUtility/execEnv.d.ts.map +1 -0
  385. package/dist/runnerUtility/execEnv.js +109 -0
  386. package/dist/runnerUtility/execEnv.js.map +1 -0
  387. package/dist/runnerUtility/execSession.d.ts +13 -0
  388. package/dist/runnerUtility/execSession.d.ts.map +1 -0
  389. package/dist/runnerUtility/execSession.js +58 -0
  390. package/dist/runnerUtility/execSession.js.map +1 -0
  391. package/dist/runnerUtility/hubClient.d.ts +30 -0
  392. package/dist/runnerUtility/hubClient.d.ts.map +1 -0
  393. package/dist/runnerUtility/hubClient.js +127 -0
  394. package/dist/runnerUtility/hubClient.js.map +1 -0
  395. package/dist/runnerUtility/injectStream.d.ts +9 -0
  396. package/dist/runnerUtility/injectStream.d.ts.map +1 -0
  397. package/dist/runnerUtility/injectStream.js +12 -0
  398. package/dist/runnerUtility/injectStream.js.map +1 -0
  399. package/dist/runnerUtility/iosCheck.d.ts +62 -0
  400. package/dist/runnerUtility/iosCheck.d.ts.map +1 -0
  401. package/dist/runnerUtility/iosCheck.js +200 -0
  402. package/dist/runnerUtility/iosCheck.js.map +1 -0
  403. package/dist/runnerUtility/launcher.d.ts +5 -0
  404. package/dist/runnerUtility/launcher.d.ts.map +1 -0
  405. package/dist/runnerUtility/launcher.js +85 -0
  406. package/dist/runnerUtility/launcher.js.map +1 -0
  407. package/dist/runnerUtility/linkLoop.d.ts +47 -0
  408. package/dist/runnerUtility/linkLoop.d.ts.map +1 -0
  409. package/dist/runnerUtility/linkLoop.js +281 -0
  410. package/dist/runnerUtility/linkLoop.js.map +1 -0
  411. package/dist/runnerUtility/localLinkStop.d.ts +23 -0
  412. package/dist/runnerUtility/localLinkStop.d.ts.map +1 -0
  413. package/dist/runnerUtility/localLinkStop.js +79 -0
  414. package/dist/runnerUtility/localLinkStop.js.map +1 -0
  415. package/dist/runnerUtility/machineReport.d.ts +39 -0
  416. package/dist/runnerUtility/machineReport.d.ts.map +1 -0
  417. package/dist/runnerUtility/machineReport.js +161 -0
  418. package/dist/runnerUtility/machineReport.js.map +1 -0
  419. package/dist/runnerUtility/packageId.d.ts +5 -0
  420. package/dist/runnerUtility/packageId.d.ts.map +1 -0
  421. package/dist/runnerUtility/packageId.js +64 -0
  422. package/dist/runnerUtility/packageId.js.map +1 -0
  423. package/dist/runnerUtility/pairingRecovery.d.ts +31 -0
  424. package/dist/runnerUtility/pairingRecovery.d.ts.map +1 -0
  425. package/dist/runnerUtility/pairingRecovery.js +24 -0
  426. package/dist/runnerUtility/pairingRecovery.js.map +1 -0
  427. package/dist/runnerUtility/pueueDaemon.d.ts +36 -0
  428. package/dist/runnerUtility/pueueDaemon.d.ts.map +1 -0
  429. package/dist/runnerUtility/pueueDaemon.js +165 -0
  430. package/dist/runnerUtility/pueueDaemon.js.map +1 -0
  431. package/dist/runnerUtility/runnerCli.d.ts +18 -0
  432. package/dist/runnerUtility/runnerCli.d.ts.map +1 -0
  433. package/dist/runnerUtility/runnerCli.js +184 -0
  434. package/dist/runnerUtility/runnerCli.js.map +1 -0
  435. package/dist/runnerUtility/runnerCommands.d.ts +67 -0
  436. package/dist/runnerUtility/runnerCommands.d.ts.map +1 -0
  437. package/dist/runnerUtility/runnerCommands.js +418 -0
  438. package/dist/runnerUtility/runnerCommands.js.map +1 -0
  439. package/dist/runnerUtility/runnerConfig.d.ts +63 -0
  440. package/dist/runnerUtility/runnerConfig.d.ts.map +1 -0
  441. package/dist/runnerUtility/runnerConfig.js +153 -0
  442. package/dist/runnerUtility/runnerConfig.js.map +1 -0
  443. package/dist/runnerUtility/runnerFlags.d.ts +9 -0
  444. package/dist/runnerUtility/runnerFlags.d.ts.map +1 -0
  445. package/dist/runnerUtility/runnerFlags.js +53 -0
  446. package/dist/runnerUtility/runnerFlags.js.map +1 -0
  447. package/dist/runnerUtility/runnerHome.d.ts +30 -0
  448. package/dist/runnerUtility/runnerHome.d.ts.map +1 -0
  449. package/dist/runnerUtility/runnerHome.js +82 -0
  450. package/dist/runnerUtility/runnerHome.js.map +1 -0
  451. package/dist/runnerUtility/runnerKeys.d.ts +11 -0
  452. package/dist/runnerUtility/runnerKeys.d.ts.map +1 -0
  453. package/dist/runnerUtility/runnerKeys.js +80 -0
  454. package/dist/runnerUtility/runnerKeys.js.map +1 -0
  455. package/dist/runnerUtility/runnerServers.d.ts +40 -0
  456. package/dist/runnerUtility/runnerServers.d.ts.map +1 -0
  457. package/dist/runnerUtility/runnerServers.js +79 -0
  458. package/dist/runnerUtility/runnerServers.js.map +1 -0
  459. package/dist/runnerUtility/serviceManager.d.ts +54 -0
  460. package/dist/runnerUtility/serviceManager.d.ts.map +1 -0
  461. package/dist/runnerUtility/serviceManager.js +413 -0
  462. package/dist/runnerUtility/serviceManager.js.map +1 -0
  463. package/dist/runnerUtility/serviceUnits.d.ts +36 -0
  464. package/dist/runnerUtility/serviceUnits.d.ts.map +1 -0
  465. package/dist/runnerUtility/serviceUnits.js +169 -0
  466. package/dist/runnerUtility/serviceUnits.js.map +1 -0
  467. package/dist/runnerUtility/toolInstall.d.ts +95 -0
  468. package/dist/runnerUtility/toolInstall.d.ts.map +1 -0
  469. package/dist/runnerUtility/toolInstall.js +232 -0
  470. package/dist/runnerUtility/toolInstall.js.map +1 -0
  471. package/dist/runnerUtility/versionPollTiming.d.ts +16 -0
  472. package/dist/runnerUtility/versionPollTiming.d.ts.map +1 -0
  473. package/dist/runnerUtility/versionPollTiming.js +36 -0
  474. package/dist/runnerUtility/versionPollTiming.js.map +1 -0
  475. package/dist/runners/authorizedKeys.d.ts +52 -0
  476. package/dist/runners/authorizedKeys.d.ts.map +1 -0
  477. package/dist/runners/authorizedKeys.js +153 -0
  478. package/dist/runners/authorizedKeys.js.map +1 -0
  479. package/dist/runners/bootstrapLine.d.ts +4 -0
  480. package/dist/runners/bootstrapLine.d.ts.map +1 -0
  481. package/dist/runners/bootstrapLine.js +69 -0
  482. package/dist/runners/bootstrapLine.js.map +1 -0
  483. package/dist/runners/gate.d.ts +41 -0
  484. package/dist/runners/gate.d.ts.map +1 -0
  485. package/dist/runners/gate.js +216 -0
  486. package/dist/runners/gate.js.map +1 -0
  487. package/dist/runners/gateLink.d.ts +26 -0
  488. package/dist/runners/gateLink.d.ts.map +1 -0
  489. package/dist/runners/gateLink.js +240 -0
  490. package/dist/runners/gateLink.js.map +1 -0
  491. package/dist/runners/gateProtocol.d.ts +98 -0
  492. package/dist/runners/gateProtocol.d.ts.map +1 -0
  493. package/dist/runners/gateProtocol.js +149 -0
  494. package/dist/runners/gateProtocol.js.map +1 -0
  495. package/dist/runners/gateWrapper.d.ts +6 -0
  496. package/dist/runners/gateWrapper.d.ts.map +1 -0
  497. package/dist/runners/gateWrapper.js +70 -0
  498. package/dist/runners/gateWrapper.js.map +1 -0
  499. package/dist/runners/hubAddress.d.ts +33 -0
  500. package/dist/runners/hubAddress.d.ts.map +1 -0
  501. package/dist/runners/hubAddress.js +88 -0
  502. package/dist/runners/hubAddress.js.map +1 -0
  503. package/dist/runners/hubAddressCandidates.d.ts +16 -0
  504. package/dist/runners/hubAddressCandidates.d.ts.map +1 -0
  505. package/dist/runners/hubAddressCandidates.js +96 -0
  506. package/dist/runners/hubAddressCandidates.js.map +1 -0
  507. package/dist/runners/hubIds.d.ts +16 -0
  508. package/dist/runners/hubIds.d.ts.map +1 -0
  509. package/dist/runners/hubIds.js +25 -0
  510. package/dist/runners/hubIds.js.map +1 -0
  511. package/dist/runners/hubKey.d.ts +6 -0
  512. package/dist/runners/hubKey.d.ts.map +1 -0
  513. package/dist/runners/hubKey.js +78 -0
  514. package/dist/runners/hubKey.js.map +1 -0
  515. package/dist/runners/hubLock.d.ts +7 -0
  516. package/dist/runners/hubLock.d.ts.map +1 -0
  517. package/dist/runners/hubLock.js +230 -0
  518. package/dist/runners/hubLock.js.map +1 -0
  519. package/dist/runners/hubPackage.d.ts +23 -0
  520. package/dist/runners/hubPackage.d.ts.map +1 -0
  521. package/dist/runners/hubPackage.js +184 -0
  522. package/dist/runners/hubPackage.js.map +1 -0
  523. package/dist/runners/hubPairing.d.ts +63 -0
  524. package/dist/runners/hubPairing.d.ts.map +1 -0
  525. package/dist/runners/hubPairing.js +261 -0
  526. package/dist/runners/hubPairing.js.map +1 -0
  527. package/dist/runners/hubPaths.d.ts +38 -0
  528. package/dist/runners/hubPaths.d.ts.map +1 -0
  529. package/dist/runners/hubPaths.js +94 -0
  530. package/dist/runners/hubPaths.js.map +1 -0
  531. package/dist/runners/hubRegistry.d.ts +112 -0
  532. package/dist/runners/hubRegistry.d.ts.map +1 -0
  533. package/dist/runners/hubRegistry.js +210 -0
  534. package/dist/runners/hubRegistry.js.map +1 -0
  535. package/dist/runners/linkControl.d.ts +27 -0
  536. package/dist/runners/linkControl.d.ts.map +1 -0
  537. package/dist/runners/linkControl.js +124 -0
  538. package/dist/runners/linkControl.js.map +1 -0
  539. package/dist/runners/pairCode.d.ts +30 -0
  540. package/dist/runners/pairCode.d.ts.map +1 -0
  541. package/dist/runners/pairCode.js +46 -0
  542. package/dist/runners/pairCode.js.map +1 -0
  543. package/dist/runners/pairingTiming.d.ts +18 -0
  544. package/dist/runners/pairingTiming.d.ts.map +1 -0
  545. package/dist/runners/pairingTiming.js +42 -0
  546. package/dist/runners/pairingTiming.js.map +1 -0
  547. package/dist/runners/runnerProxy.d.ts +9 -0
  548. package/dist/runners/runnerProxy.d.ts.map +1 -0
  549. package/dist/runners/runnerProxy.js +67 -0
  550. package/dist/runners/runnerProxy.js.map +1 -0
  551. package/dist/runners/runnerRemoval.d.ts +21 -0
  552. package/dist/runners/runnerRemoval.d.ts.map +1 -0
  553. package/dist/runners/runnerRemoval.js +82 -0
  554. package/dist/runners/runnerRemoval.js.map +1 -0
  555. package/dist/runners/runnersCli.d.ts +16 -0
  556. package/dist/runners/runnersCli.d.ts.map +1 -0
  557. package/dist/runners/runnersCli.js +214 -0
  558. package/dist/runners/runnersCli.js.map +1 -0
  559. package/dist/runners/runnersList.d.ts +17 -0
  560. package/dist/runners/runnersList.d.ts.map +1 -0
  561. package/dist/runners/runnersList.js +49 -0
  562. package/dist/runners/runnersList.js.map +1 -0
  563. package/dist/runners/runnersOverview.d.ts +20 -0
  564. package/dist/runners/runnersOverview.d.ts.map +1 -0
  565. package/dist/runners/runnersOverview.js +55 -0
  566. package/dist/runners/runnersOverview.js.map +1 -0
  567. package/dist/runners/sshKeys.d.ts +26 -0
  568. package/dist/runners/sshKeys.d.ts.map +1 -0
  569. package/dist/runners/sshKeys.js +60 -0
  570. package/dist/runners/sshKeys.js.map +1 -0
  571. package/dist/runners/tarArchive.d.ts +20 -0
  572. package/dist/runners/tarArchive.d.ts.map +1 -0
  573. package/dist/runners/tarArchive.js +70 -0
  574. package/dist/runners/tarArchive.js.map +1 -0
  575. package/dist/scheduler/delivery.d.ts +2 -0
  576. package/dist/scheduler/delivery.d.ts.map +1 -1
  577. package/dist/scheduler/delivery.js +8 -4
  578. package/dist/scheduler/delivery.js.map +1 -1
  579. package/dist/scheduler/mcpSurface.d.ts +65 -10
  580. package/dist/scheduler/mcpSurface.d.ts.map +1 -1
  581. package/dist/scheduler/mcpSurface.js +319 -89
  582. package/dist/scheduler/mcpSurface.js.map +1 -1
  583. package/dist/scheduler/recurrence.d.ts +5 -0
  584. package/dist/scheduler/recurrence.d.ts.map +1 -1
  585. package/dist/scheduler/recurrence.js +12 -2
  586. package/dist/scheduler/recurrence.js.map +1 -1
  587. package/dist/scheduler/strictToolInput.d.ts +45 -0
  588. package/dist/scheduler/strictToolInput.d.ts.map +1 -0
  589. package/dist/scheduler/strictToolInput.js +155 -0
  590. package/dist/scheduler/strictToolInput.js.map +1 -0
  591. package/dist/scheduler/types.d.ts +5 -0
  592. package/dist/scheduler/types.d.ts.map +1 -1
  593. package/dist/scheduler/workerWatch.d.ts +23 -0
  594. package/dist/scheduler/workerWatch.d.ts.map +1 -0
  595. package/dist/scheduler/workerWatch.js +26 -0
  596. package/dist/scheduler/workerWatch.js.map +1 -0
  597. package/dist/selfUpdate/emittedSource.d.ts +11 -0
  598. package/dist/selfUpdate/emittedSource.d.ts.map +1 -0
  599. package/dist/selfUpdate/emittedSource.js +24 -0
  600. package/dist/selfUpdate/emittedSource.js.map +1 -0
  601. package/dist/selfUpdate/gitSource.d.ts +0 -6
  602. package/dist/selfUpdate/gitSource.d.ts.map +1 -1
  603. package/dist/selfUpdate/gitSource.js +2 -18
  604. package/dist/selfUpdate/gitSource.js.map +1 -1
  605. package/dist/selfUpdate/selfCheck.js +2 -2
  606. package/dist/selfUpdate/selfCheck.js.map +1 -1
  607. package/dist/selfUpdate/updatePending.d.ts +1 -1
  608. package/dist/sendErrorClassifier.d.ts +10 -0
  609. package/dist/sendErrorClassifier.d.ts.map +1 -1
  610. package/dist/sendErrorClassifier.js +22 -0
  611. package/dist/sendErrorClassifier.js.map +1 -1
  612. package/dist/state.d.ts +78 -7
  613. package/dist/state.d.ts.map +1 -1
  614. package/dist/state.js +125 -8
  615. package/dist/state.js.map +1 -1
  616. package/dist/stateFile.d.ts +9 -0
  617. package/dist/stateFile.d.ts.map +1 -0
  618. package/dist/stateFile.js +57 -0
  619. package/dist/stateFile.js.map +1 -0
  620. package/dist/stt/sttInstall.d.ts +7 -3
  621. package/dist/stt/sttInstall.d.ts.map +1 -1
  622. package/dist/stt/sttInstall.js +8 -4
  623. package/dist/stt/sttInstall.js.map +1 -1
  624. package/dist/stt/sttSettings.d.ts +2 -1
  625. package/dist/stt/sttSettings.d.ts.map +1 -1
  626. package/dist/stt/sttSettings.js +3 -2
  627. package/dist/stt/sttSettings.js.map +1 -1
  628. package/dist/stt/transcribeChain.js +2 -0
  629. package/dist/stt/transcribeChain.js.map +1 -1
  630. package/dist/testTimings.d.ts +16 -3
  631. package/dist/testTimings.d.ts.map +1 -1
  632. package/dist/testTimings.js +35 -2
  633. package/dist/testTimings.js.map +1 -1
  634. package/dist/todos/board.d.ts +83 -0
  635. package/dist/todos/board.d.ts.map +1 -0
  636. package/dist/todos/board.js +227 -0
  637. package/dist/todos/board.js.map +1 -0
  638. package/dist/todos/format.d.ts +33 -0
  639. package/dist/todos/format.d.ts.map +1 -0
  640. package/dist/todos/format.js +90 -0
  641. package/dist/todos/format.js.map +1 -0
  642. package/dist/todos/merge.d.ts +46 -0
  643. package/dist/todos/merge.d.ts.map +1 -0
  644. package/dist/todos/merge.js +137 -0
  645. package/dist/todos/merge.js.map +1 -0
  646. package/dist/todos/midTurn.d.ts +75 -0
  647. package/dist/todos/midTurn.d.ts.map +1 -0
  648. package/dist/todos/midTurn.js +214 -0
  649. package/dist/todos/midTurn.js.map +1 -0
  650. package/dist/todos/nag.d.ts +85 -0
  651. package/dist/todos/nag.d.ts.map +1 -0
  652. package/dist/todos/nag.js +103 -0
  653. package/dist/todos/nag.js.map +1 -0
  654. package/dist/todos/order.d.ts +25 -0
  655. package/dist/todos/order.d.ts.map +1 -0
  656. package/dist/todos/order.js +61 -0
  657. package/dist/todos/order.js.map +1 -0
  658. package/dist/todos/projects.d.ts +102 -0
  659. package/dist/todos/projects.d.ts.map +1 -0
  660. package/dist/todos/projects.js +342 -0
  661. package/dist/todos/projects.js.map +1 -0
  662. package/dist/todos/registry.d.ts +64 -0
  663. package/dist/todos/registry.d.ts.map +1 -0
  664. package/dist/todos/registry.js +116 -0
  665. package/dist/todos/registry.js.map +1 -0
  666. package/dist/todos/reminderEngine.d.ts +78 -0
  667. package/dist/todos/reminderEngine.d.ts.map +1 -0
  668. package/dist/todos/reminderEngine.js +162 -0
  669. package/dist/todos/reminderEngine.js.map +1 -0
  670. package/dist/todos/store.d.ts +34 -0
  671. package/dist/todos/store.d.ts.map +1 -0
  672. package/dist/todos/store.js +208 -0
  673. package/dist/todos/store.js.map +1 -0
  674. package/dist/todos/tools.d.ts +26 -0
  675. package/dist/todos/tools.d.ts.map +1 -0
  676. package/dist/todos/tools.js +25 -0
  677. package/dist/todos/tools.js.map +1 -0
  678. package/dist/todos/types.d.ts +85 -0
  679. package/dist/todos/types.d.ts.map +1 -0
  680. package/dist/todos/types.js +30 -0
  681. package/dist/todos/types.js.map +1 -0
  682. package/dist/trackers/taskAgentLimit.d.ts +58 -0
  683. package/dist/trackers/taskAgentLimit.d.ts.map +1 -0
  684. package/dist/trackers/taskAgentLimit.js +62 -0
  685. package/dist/trackers/taskAgentLimit.js.map +1 -0
  686. package/dist/trackers/taskAgentSlots.d.ts +109 -0
  687. package/dist/trackers/taskAgentSlots.d.ts.map +1 -0
  688. package/dist/trackers/taskAgentSlots.js +227 -0
  689. package/dist/trackers/taskAgentSlots.js.map +1 -0
  690. package/dist/trackers/types.d.ts +14 -0
  691. package/dist/trackers/types.d.ts.map +1 -0
  692. package/dist/trackers/types.js +3 -0
  693. package/dist/trackers/types.js.map +1 -0
  694. package/dist/types.d.ts +80 -4
  695. package/dist/types.d.ts.map +1 -1
  696. package/dist/utils/adoptReplayGate.d.ts +16 -0
  697. package/dist/utils/adoptReplayGate.d.ts.map +1 -0
  698. package/dist/utils/adoptReplayGate.js +35 -0
  699. package/dist/utils/adoptReplayGate.js.map +1 -0
  700. package/dist/utils/agentEnvironment.d.ts +6 -2
  701. package/dist/utils/agentEnvironment.d.ts.map +1 -1
  702. package/dist/utils/agentEnvironment.js +13 -2
  703. package/dist/utils/agentEnvironment.js.map +1 -1
  704. package/dist/utils/agentPlugin.d.ts +113 -0
  705. package/dist/utils/agentPlugin.d.ts.map +1 -0
  706. package/dist/utils/agentPlugin.js +192 -0
  707. package/dist/utils/agentPlugin.js.map +1 -0
  708. package/dist/utils/autoContinueOnLimit.d.ts +7 -4
  709. package/dist/utils/autoContinueOnLimit.d.ts.map +1 -1
  710. package/dist/utils/autoContinueOnLimit.js +8 -5
  711. package/dist/utils/autoContinueOnLimit.js.map +1 -1
  712. package/dist/utils/botSkills.d.ts +62 -0
  713. package/dist/utils/botSkills.d.ts.map +1 -0
  714. package/dist/utils/botSkills.js +53 -0
  715. package/dist/utils/botSkills.js.map +1 -0
  716. package/dist/utils/botSkillsSetting.d.ts +7 -0
  717. package/dist/utils/botSkillsSetting.d.ts.map +1 -0
  718. package/dist/utils/botSkillsSetting.js +9 -0
  719. package/dist/utils/botSkillsSetting.js.map +1 -0
  720. package/dist/utils/claudeCodeVersion.d.ts +7 -0
  721. package/dist/utils/claudeCodeVersion.d.ts.map +1 -1
  722. package/dist/utils/claudeCodeVersion.js +14 -1
  723. package/dist/utils/claudeCodeVersion.js.map +1 -1
  724. package/dist/utils/claudeCompactHook.d.ts +33 -15
  725. package/dist/utils/claudeCompactHook.d.ts.map +1 -1
  726. package/dist/utils/claudeCompactHook.js +36 -17
  727. package/dist/utils/claudeCompactHook.js.map +1 -1
  728. package/dist/utils/claudeCompactSummary.d.ts +15 -0
  729. package/dist/utils/claudeCompactSummary.d.ts.map +1 -0
  730. package/dist/utils/claudeCompactSummary.js +96 -0
  731. package/dist/utils/claudeCompactSummary.js.map +1 -0
  732. package/dist/utils/claudeLimitSwitch.d.ts +11 -9
  733. package/dist/utils/claudeLimitSwitch.d.ts.map +1 -1
  734. package/dist/utils/claudeLimitSwitch.js +18 -16
  735. package/dist/utils/claudeLimitSwitch.js.map +1 -1
  736. package/dist/utils/claudeProfiles.d.ts +5 -0
  737. package/dist/utils/claudeProfiles.d.ts.map +1 -1
  738. package/dist/utils/claudeProfiles.js +14 -0
  739. package/dist/utils/claudeProfiles.js.map +1 -1
  740. package/dist/utils/claudeSessionTitle.d.ts +2 -1
  741. package/dist/utils/claudeSessionTitle.d.ts.map +1 -1
  742. package/dist/utils/claudeSessionTitle.js +14 -14
  743. package/dist/utils/claudeSessionTitle.js.map +1 -1
  744. package/dist/utils/claudeUsage.d.ts +95 -15
  745. package/dist/utils/claudeUsage.d.ts.map +1 -1
  746. package/dist/utils/claudeUsage.js +241 -41
  747. package/dist/utils/claudeUsage.js.map +1 -1
  748. package/dist/utils/claudeUsageRefresher.d.ts +10 -0
  749. package/dist/utils/claudeUsageRefresher.d.ts.map +1 -1
  750. package/dist/utils/claudeUsageRefresher.js +11 -0
  751. package/dist/utils/claudeUsageRefresher.js.map +1 -1
  752. package/dist/utils/claudeUserEntry.d.ts +50 -0
  753. package/dist/utils/claudeUserEntry.d.ts.map +1 -0
  754. package/dist/utils/claudeUserEntry.js +120 -0
  755. package/dist/utils/claudeUserEntry.js.map +1 -0
  756. package/dist/utils/clearThreadOutputQueues.d.ts +4 -3
  757. package/dist/utils/clearThreadOutputQueues.d.ts.map +1 -1
  758. package/dist/utils/clearThreadOutputQueues.js +1 -1
  759. package/dist/utils/clearThreadOutputQueues.js.map +1 -1
  760. package/dist/utils/compactOnIdle.d.ts +33 -13
  761. package/dist/utils/compactOnIdle.d.ts.map +1 -1
  762. package/dist/utils/compactOnIdle.js +35 -15
  763. package/dist/utils/compactOnIdle.js.map +1 -1
  764. package/dist/utils/fallbackBridge.d.ts +5 -0
  765. package/dist/utils/fallbackBridge.d.ts.map +1 -1
  766. package/dist/utils/fallbackBridge.js +19 -3
  767. package/dist/utils/fallbackBridge.js.map +1 -1
  768. package/dist/utils/fallbackChain.d.ts +9 -3
  769. package/dist/utils/fallbackChain.d.ts.map +1 -1
  770. package/dist/utils/fallbackChain.js +21 -3
  771. package/dist/utils/fallbackChain.js.map +1 -1
  772. package/dist/utils/fileSendPlan.d.ts +6 -0
  773. package/dist/utils/fileSendPlan.d.ts.map +1 -1
  774. package/dist/utils/fileSendPlan.js +11 -0
  775. package/dist/utils/fileSendPlan.js.map +1 -1
  776. package/dist/utils/fileSendService.d.ts +7 -1
  777. package/dist/utils/fileSendService.d.ts.map +1 -1
  778. package/dist/utils/fileSendService.js +6 -2
  779. package/dist/utils/fileSendService.js.map +1 -1
  780. package/dist/utils/groupFinalizePlan.d.ts +8 -10
  781. package/dist/utils/groupFinalizePlan.d.ts.map +1 -1
  782. package/dist/utils/groupFinalizePlan.js +8 -12
  783. package/dist/utils/groupFinalizePlan.js.map +1 -1
  784. package/dist/utils/jsonStreamHost.d.ts +13 -0
  785. package/dist/utils/jsonStreamHost.d.ts.map +1 -1
  786. package/dist/utils/jsonStreamHost.js +13 -0
  787. package/dist/utils/jsonStreamHost.js.map +1 -1
  788. package/dist/utils/messageSendService.d.ts +5 -3
  789. package/dist/utils/messageSendService.d.ts.map +1 -1
  790. package/dist/utils/messageSendService.js +12 -5
  791. package/dist/utils/messageSendService.js.map +1 -1
  792. package/dist/utils/openCodeCompactPlugin.d.ts +39 -14
  793. package/dist/utils/openCodeCompactPlugin.d.ts.map +1 -1
  794. package/dist/utils/openCodeCompactPlugin.js +106 -38
  795. package/dist/utils/openCodeCompactPlugin.js.map +1 -1
  796. package/dist/utils/promptDelivery.d.ts +29 -12
  797. package/dist/utils/promptDelivery.d.ts.map +1 -1
  798. package/dist/utils/promptDelivery.js +13 -5
  799. package/dist/utils/promptDelivery.js.map +1 -1
  800. package/dist/utils/runnersScreenPlan.d.ts +130 -0
  801. package/dist/utils/runnersScreenPlan.d.ts.map +1 -0
  802. package/dist/utils/runnersScreenPlan.js +257 -0
  803. package/dist/utils/runnersScreenPlan.js.map +1 -0
  804. package/dist/utils/sentKindsSummary.d.ts +10 -0
  805. package/dist/utils/sentKindsSummary.d.ts.map +1 -0
  806. package/dist/utils/sentKindsSummary.js +18 -0
  807. package/dist/utils/sentKindsSummary.js.map +1 -0
  808. package/dist/utils/telegramError.d.ts +6 -0
  809. package/dist/utils/telegramError.d.ts.map +1 -1
  810. package/dist/utils/telegramError.js +12 -0
  811. package/dist/utils/telegramError.js.map +1 -1
  812. package/dist/utils/topicView.d.ts +1 -1
  813. package/dist/utils/topicView.js +1 -1
  814. package/dist/utils/transcribeAudio.d.ts +10 -0
  815. package/dist/utils/transcribeAudio.d.ts.map +1 -1
  816. package/dist/utils/transcribeAudio.js +22 -3
  817. package/dist/utils/transcribeAudio.js.map +1 -1
  818. package/dist/utils/transcriptErrorTime.d.ts +8 -0
  819. package/dist/utils/transcriptErrorTime.d.ts.map +1 -0
  820. package/dist/utils/transcriptErrorTime.js +56 -0
  821. package/dist/utils/transcriptErrorTime.js.map +1 -0
  822. package/dist/utils/wedgeRecovery.d.ts +2 -0
  823. package/dist/utils/wedgeRecovery.d.ts.map +1 -1
  824. package/dist/utils/wedgeRecovery.js +1 -1
  825. package/dist/utils/wedgeRecovery.js.map +1 -1
  826. package/package.json +15 -4
  827. package/remoteRun/runner.sh +1317 -0
  828. 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,19 @@ 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");
242
+ const runners_1 = require("./connectors/telegram/commands/runners");
243
+ const gateWrapper_1 = require("./runners/gateWrapper");
244
+ const hubAddress_1 = require("./runners/hubAddress");
245
+ const hubAddressCandidates_1 = require("./runners/hubAddressCandidates");
246
+ const hubPaths_1 = require("./runners/hubPaths");
247
+ const hubPairing_1 = require("./runners/hubPairing");
248
+ const runnersOverview_1 = require("./runners/runnersOverview");
249
+ const ssh_1 = require("./remoteRun/ssh");
217
250
  const claudeAccounts_1 = require("./connectors/telegram/commands/claudeAccounts");
218
251
  const claudeProfiles_1 = require("./utils/claudeProfiles");
219
252
  const claudeUsage_1 = require("./utils/claudeUsage");
@@ -229,6 +262,7 @@ const fallbackChain_1 = require("./utils/fallbackChain");
229
262
  const fallbackBridge_1 = require("./utils/fallbackBridge");
230
263
  const fallbackChain_2 = require("./connectors/telegram/commands/fallbackChain");
231
264
  const limitsBoard_1 = require("./connectors/telegram/commands/limitsBoard");
265
+ const todo_1 = require("./connectors/telegram/commands/todo");
232
266
  const backendSwitch_1 = require("./connectors/telegram/commands/backendSwitch");
233
267
  const operatorSwitchPlan_1 = require("./utils/operatorSwitchPlan");
234
268
  const backendSwitchPlan_1 = require("./utils/backendSwitchPlan");
@@ -237,6 +271,7 @@ const stt_1 = require("./connectors/telegram/commands/stt");
237
271
  const claudeApiSpend_1 = require("./utils/claudeApiSpend");
238
272
  const claudeProfileUsageLines_1 = require("./utils/claudeProfileUsageLines");
239
273
  const resolveBinary_1 = require("./utils/resolveBinary");
274
+ const runnerSummary_1 = require("./remoteRun/runnerSummary");
240
275
  // ═══════════════════════════════════════════════════════════════════════════════
241
276
  // ENV parsing & fatal validation
242
277
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -354,6 +389,8 @@ if (ENV.testTimings.telegramSendIntervalMs !== undefined)
354
389
  (0, rateLimiter_1.configureGlobalSendInterval)(ENV.testTimings.telegramSendIntervalMs);
355
390
  if (ENV.testTimings.backgroundHoldMaxMs !== undefined)
356
391
  (0, createAdapter_1.registerPendingWorkHoldMs)(ENV.testTimings.backgroundHoldMaxMs);
392
+ if (ENV.testTimings.scheduleMinFireIntervalMs !== undefined)
393
+ (0, recurrence_1.registerMinFireIntervalMs)(ENV.testTimings.scheduleMinFireIntervalMs);
357
394
  /** The short retry that probes an unconfirmed usage limit (a test instance may shorten it). */
358
395
  const limitProbeDelayMs = ENV.testTimings.limitProbeDelayMs ?? apiErrorRetry_1.unconfirmedLimitProbeDelayMs;
359
396
  // The instance default effort (`/effort` in General) is `settings.json`'s `defaultEffort`, re-read when the file changes.
@@ -446,24 +483,22 @@ function getOutputTransport() {
446
483
  }
447
484
  return outputTransport;
448
485
  }
449
- const telegramAgent = new https.Agent({
450
- keepAlive: true,
451
- keepAliveMsecs: 10000,
452
- family: 4,
453
- });
486
+ const telegramAgent = (0, botApiConnection_1.createBotApiHttpsAgent)();
454
487
  // Without the telegram connector there is no token (an empty one), and the call
455
488
  // guard below refuses every Bot API call before it is sent (Jira plan J3, D9).
456
489
  // `TELEGRAM_API_ROOT` points the client at another Bot API host — the
457
490
  // 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
491
  const telegramApiRoot = process.env.TELEGRAM_API_ROOT?.trim();
492
+ // Telegraf drops the https agent itself for an `http://` root and node-fetch then rides the global agent, whose
493
+ // pooling is not the bot's. That root gets the same pooling over `http`.
494
+ const telegramApiAgent = telegramApiRoot?.startsWith('http://') ? (0, botApiConnection_1.createBotApiHttpAgent)() : telegramAgent;
460
495
  const bot = new telegraf_1.Telegraf(ENV.botToken, {
461
- telegram: { agent: telegramAgent, ...(telegramApiRoot ? { apiRoot: telegramApiRoot } : {}) },
496
+ telegram: { agent: telegramApiAgent, ...(telegramApiRoot ? { apiRoot: telegramApiRoot } : {}) },
462
497
  });
463
498
  // Decouple the long-polling intake/ACK from per-handler latency: wrap
464
499
  // `bot.handleUpdate` so each update is enqueued into its thread's serial queue
465
500
  // 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,
501
+ // batch fires now). The real middleware chain — every `bot.use` middleware,
467
502
  // every command/`on`/action, `bot.catch` — runs unchanged inside the captured
468
503
  // original, just off-loop and per-thread-serial. MUST be installed before
469
504
  // `bot.launch`. The graceful-shutdown path drains it (see `updateDrainTimeoutMs`).
@@ -481,6 +516,10 @@ const updateDrainTimeoutMs = 2000;
481
516
  // window is not lost on shutdown.
482
517
  (0, outputTrace_1.installCallApiTrace)(bot.telegram);
483
518
  process.on('exit', outputTrace_1.flushTraceBufferSyncOnExit);
519
+ // The bot's pooling (the agent Telegraf dropped for an `http://` root) and the retry of a request that met a pooled
520
+ // connection the server had closed. AFTER the trace wrap, so the trace records both real HTTP attempts, and below the
521
+ // send queue, pacer and 429 retry. Telegraf builds a NEW client per update: see the first `bot.use` below.
522
+ (0, botApiConnection_1.wireBotApiClient)(bot.telegram, telegramApiAgent);
484
523
  // Default every text message/edit to NO link preview: a bare URL in agent
485
524
  // output otherwise expanded into a large preview card, one per message, in the
486
525
  // muted topic (operator complaint 2026-09-07). Installed OUTSIDE the trace wrap
@@ -601,7 +640,14 @@ async function checkIsForumAdmin(chatId, userId) {
601
640
  return false;
602
641
  }
603
642
  }
604
- // Output-trace `recv` hook — must be the FIRST middleware so every update is
643
+ // Telegraf builds a NEW `Telegram` client for every update (`ctx.telegram`, behind every `ctx.reply`, `ctx.answerCbQuery`
644
+ // and `ctx.editMessageText`) from `bot.telegram.options`, so the wiring of `bot.telegram` does not reach it and an
645
+ // `http://` root drops the agent again. Wire it before any handler can use it.
646
+ bot.use((ctx, next) => {
647
+ (0, botApiConnection_1.wireBotApiClient)(ctx.telegram, telegramApiAgent);
648
+ return next();
649
+ });
650
+ // Output-trace `recv` hook — must come before every middleware that can gate or drop an update, so every update is
605
651
  // recorded before any gating (access control, group routing) can drop it.
606
652
  // Security: while the thread awaits a `/connect` key paste (same
607
653
  // `pendingProviderConnects` state the text handler consumes later in this
@@ -788,7 +834,7 @@ let schedulerEngine = null;
788
834
  * The request wake-up engine (request/answer core S4), assigned once by
789
835
  * `startBot` after the request ledger loads. Module-level like
790
836
  * {@link schedulerEngine} because the cancel paths (`/esc`, `/c`, `/quit`,
791
- * `/quit-all`, `/new`, `/resume`, leaving the folder, switching the topic to
837
+ * `/quitall`, `/new`, `/resume`, leaving the folder, switching the topic to
792
838
  * another agent or a shell) are module-level command handlers.
793
839
  */
794
840
  let requestWakeUpEngine = null;
@@ -820,15 +866,19 @@ async function openTopicRequest(key, text, intake) {
820
866
  return { header: (0, topicRequest_1.buildTopicRequestHeader)(request.id, intake.source, view, request.supersededRequestIds), requestId: request.id };
821
867
  }
822
868
  /**
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.
869
+ * @description Where a topic request goes once its prompt came back from the prompt path (`requests/topicRequestSettle.ts`): the
870
+ * wake-up engine watches the turn of a prompt that reached a session (or waits behind its start), and a prompt that found no
871
+ * session closes the request it opened. Both ports are read at call time: the engine and the ledger are assigned at boot.
826
872
  */
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
- }
873
+ const { trackTurn: trackTopicRequestTurn, settleAfterDelivery: settleTopicRequestAfterDelivery } = (0, topicRequestSettle_1.createTopicRequestSettler)({
874
+ trackTurn: async (key, requestId) => {
875
+ await requestWakeUpEngine?.trackForwardedTurn(key, requestId, { isRequestPrompt: true });
876
+ },
877
+ cancelRequest: async (requestId) => {
878
+ await topicRequestLedger?.closeRequest(requestId, 'cancelled');
879
+ },
880
+ warn: (message) => console.warn(message),
881
+ });
832
882
  /** What the bot's own limit-wait answer needs (S5); set at boot next to the engine. */
833
883
  let requestLimitWaitAnswerDeps = null;
834
884
  /**
@@ -850,6 +900,212 @@ function checkIsAgentEventShownInTopic(key, event) {
850
900
  function getDataDir() {
851
901
  return path.dirname(state.stateFilePath);
852
902
  }
903
+ /** The shared todo registry (`todos/`), built on first use: its store lives in `DATA_DIR`, known once the state is. */
904
+ let todoRegistry = null;
905
+ function getTodoRegistry() {
906
+ todoRegistry ??= (0, registry_1.createTodoRegistry)({ store: new store_1.TodoStore(getDataDir()), getNowMs: Date.now });
907
+ return todoRegistry;
908
+ }
909
+ /**
910
+ * The «Bot skills» switch (`/bot_skills`, `utils/botSkills.ts`): the topic's own choice, else General's value, else `settings.json`'s
911
+ * `botSkills` (re-read when the file changes), else ON. `state` is read when asked, so the reader is registered once it is loaded.
912
+ */
913
+ const botSkillsSources = {
914
+ getGeneralValue: () => state.getBotSkillsGlobalValue(),
915
+ getTopicOverride: (key) => state.getBotSkillsOverride(key),
916
+ getSettingsValue: (0, instanceSettings_1.createBotSkillsSettingsReader)(() => (0, instanceSettings_1.getInstanceSettingsPath)(), (line) => console.error(line)),
917
+ };
918
+ const botSkillsReader = (0, botSkills_1.createBotSkillsReader)(botSkillsSources);
919
+ /**
920
+ * The cap on tracker task agents (`trackers/taskAgentLimit.ts`): General's value, else `settings.json`'s
921
+ * `taskAgents.maxConcurrent` (re-read when the file changes), else the built-in default. `state` is read when asked.
922
+ */
923
+ const taskAgentLimitSources = {
924
+ getGeneralValue: () => state.getTaskAgentsMaxConcurrentGlobalValue(),
925
+ getSettingsValue: (0, instanceSettings_1.createTaskAgentsMaxConcurrentReader)(() => (0, instanceSettings_1.getInstanceSettingsPath)(), (line) => console.error(line)),
926
+ };
927
+ /** The slots of the tracker task agents and their wait list; built at boot, once the request ledger is loaded. */
928
+ let taskAgentSlots = null;
929
+ /** `settings.json`'s `todo` (on / off, the reminder's interval), re-read when the file changes. */
930
+ const readTodoSettings = (0, instanceSettings_1.createTodoSettingsReader)(() => (0, instanceSettings_1.getInstanceSettingsPath)(), (line) => console.error(line));
931
+ /** The staleness reminder's interval now; `null` = off (the feature or its reminder). */
932
+ function getTodoReminderIntervalMsNow() {
933
+ return (0, nag_1.getTodoReminderIntervalMs)(readTodoSettings(), ENV.testTimings.todoReminderIntervalMs);
934
+ }
935
+ /**
936
+ * The reminder line a prompt forwarded now carries (D11 form a), or `null`; taking it counts as delivered. The lists
937
+ * are a courtesy to the agent: a failure here is logged and the prompt goes on without the line.
938
+ */
939
+ function takeTodoPromptReminder(key) {
940
+ try {
941
+ return getTodoRegistry().takePromptReminder(key, getTodoReminderIntervalMsNow(), Date.now());
942
+ }
943
+ catch (e) {
944
+ console.error(`[todos] ${(0, sessionKey_1.keyToString)(key)}: the reminder line could not be taken:`, e instanceof Error ? e.message : e);
945
+ return null;
946
+ }
947
+ }
948
+ /** The staleness reminder's poll (`todos/reminderEngine.ts`), built at boot and started once the sessions are restored. */
949
+ let todoReminderEngine = null;
950
+ let todoRepositories = null;
951
+ /** Which folders are one git repository (`todos/projects.ts`), cached; the root commits persist in `DATA_DIR/todos`. */
952
+ function getTodoRepositories() {
953
+ todoRepositories ??= (0, projects_1.createRepositoryIdentityReader)({
954
+ runGit: projects_1.runGitInFolder,
955
+ rootCommitStore: (0, projects_1.createRepositoryRootCommitFile)((0, store_1.getTodosDir)(getDataDir())),
956
+ });
957
+ return todoRepositories;
958
+ }
959
+ /** What the boards need of the Jira connector (D55, D57); set at boot when it is loaded. */
960
+ let todoJiraConversations = null;
961
+ /**
962
+ * A bound conversation as the project grouping sees it before git is asked: complete (a Jira issue, a folder that does
963
+ * not resolve), or still needing its folder's repository.
964
+ */
965
+ function describeTodoProjectMember({ key, data }) {
966
+ const conversationKey = (0, sessionKey_1.keyToString)(key);
967
+ if (key.platform === 'jira') {
968
+ return { member: { conversationKey, workDir: null, identity: null, jiraRepositoryNames: todoJiraConversations?.getConversationRepositoryNames(key) ?? null } };
969
+ }
970
+ const decision = (0, validation_1.resolveBoundWorkDir)(ENV.workRoot, data);
971
+ return decision.kind === 'proceed' ? { conversationKey, workDir: decision.workDir } : { member: { conversationKey, workDir: null, identity: null } };
972
+ }
973
+ /** The instance's project boards (D15, D16, D57), waiting for git where a folder's read is due. */
974
+ async function listTodoProjects() {
975
+ const repositories = getTodoRepositories();
976
+ const members = await Promise.all(state.listBindings().map(async (binding) => {
977
+ const described = describeTodoProjectMember(binding);
978
+ if ('member' in described)
979
+ return described.member;
980
+ const identity = await repositories.read(described.workDir);
981
+ return identity === undefined ? undefined : { ...described, identity };
982
+ }));
983
+ return (0, projects_1.groupTodoProjects)(members.filter((member) => member !== undefined), state.getTodoProjectOverrides());
984
+ }
985
+ /**
986
+ * The project boards as known NOW, never waiting for git and never yielding (a prompt, the mid-turn poll): `null` while a
987
+ * bound folder was never read — its read starts now, with every other one due.
988
+ */
989
+ function peekTodoProjects() {
990
+ const repositories = getTodoRepositories();
991
+ const members = state.listBindings().map((binding) => {
992
+ const described = describeTodoProjectMember(binding);
993
+ if ('member' in described)
994
+ return described.member;
995
+ const identity = repositories.peek(described.workDir);
996
+ return identity === undefined ? undefined : { ...described, identity };
997
+ });
998
+ return members.every((member) => member !== undefined) ? (0, projects_1.groupTodoProjects)(members, state.getTodoProjectOverrides()) : null;
999
+ }
1000
+ /** The bound folders' due repository reads start in the background (boot, a bind), so the next prompts find the grouping known. */
1001
+ function warmTodoProjects() {
1002
+ if (!readTodoSettings().isEnabled)
1003
+ return;
1004
+ try {
1005
+ peekTodoProjects();
1006
+ }
1007
+ catch (e) {
1008
+ console.error('[todos] the bound folders\' repositories could not be read:', e instanceof Error ? e.message : e);
1009
+ }
1010
+ }
1011
+ /** The project of `key`'s conversation, waiting for git; `null` = in none. */
1012
+ async function getTodoProjectOf(key) {
1013
+ const conversationKey = (0, sessionKey_1.keyToString)(key);
1014
+ return (await listTodoProjects()).find((project) => project.memberKeys.includes(conversationKey)) ?? null;
1015
+ }
1016
+ /** The project of `key`'s conversation as known now; `null` = in none, `undefined` = not known without waiting. */
1017
+ function peekTodoProjectOf(key) {
1018
+ const conversationKey = (0, sessionKey_1.keyToString)(key);
1019
+ const projects = peekTodoProjects();
1020
+ return projects === null ? undefined : (projects.find((project) => project.memberKeys.includes(conversationKey)) ?? null);
1021
+ }
1022
+ /** How a conversation is named on a board and in the picker: `#topic (folder)`, or the issue `PROJ-123 «summary»` (D55). */
1023
+ function getTodoConversationLabel(conversationKey) {
1024
+ const key = (0, sessionKey_1.tryKeyFromString)(conversationKey);
1025
+ if (key === null)
1026
+ return conversationKey;
1027
+ if (key.platform === 'jira') {
1028
+ const title = todoJiraConversations?.getConversationTitle(key) ?? null;
1029
+ return title === null ? key.thread : `${key.thread} «${title}»`;
1030
+ }
1031
+ const binding = state.getBinding(key);
1032
+ return `#${binding?.topicName ?? `topic ${key.thread}`}${binding ? ` (${binding.subdir})` : ''}`;
1033
+ }
1034
+ function getTodoSessionActivity(conversationKey) {
1035
+ const key = (0, sessionKey_1.tryKeyFromString)(conversationKey);
1036
+ if (key === null)
1037
+ return 'asleep';
1038
+ const adapter = (0, createAdapter_1.getThreadAdapter)(key);
1039
+ if (!adapter.checkIsActive(key))
1040
+ return 'asleep';
1041
+ return adapter.checkIsBusy?.(key) ? 'working' : 'idle';
1042
+ }
1043
+ let todoBoard = null;
1044
+ /** The project boards (`todos/board.ts`), built on first use like the registry. */
1045
+ function getTodoBoard() {
1046
+ todoBoard ??= (0, board_1.createTodoBoard)({
1047
+ store: getTodoRegistry().getStore(),
1048
+ getProjectOf: getTodoProjectOf,
1049
+ peekProjectOf: peekTodoProjectOf,
1050
+ getPeerLabel: getTodoConversationLabel,
1051
+ getSessionActivity: getTodoSessionActivity,
1052
+ getNowMs: Date.now,
1053
+ });
1054
+ return todoBoard;
1055
+ }
1056
+ /** The agent lost its context: its next prompt carries the whole board (D20). Logged, never thrown, on a lifecycle path. */
1057
+ function noteTodoFreshContext(key) {
1058
+ try {
1059
+ getTodoBoard().noteFreshContext(key);
1060
+ }
1061
+ catch (e) {
1062
+ console.error(`[todos] ${(0, sessionKey_1.keyToString)(key)}: the board's seen state could not be reset:`, e instanceof Error ? e.message : e);
1063
+ }
1064
+ }
1065
+ let todoMidTurn = null;
1066
+ /** Mid-turn delivery of the board (`todos/midTurn.ts`): the Claude inbox hook, the note into a running OpenCode turn. */
1067
+ function getTodoMidTurn() {
1068
+ todoMidTurn ??= (0, midTurn_1.createTodoMidTurn)({
1069
+ board: getTodoBoard(),
1070
+ inboxDir: (0, midTurn_1.getTodoInboxDir)(getDataDir()),
1071
+ getClaudeSessionId: (key) => {
1072
+ const adapterName = (0, createAdapter_1.getThreadAdapterNameRaw)(key) ?? state.getAgent(key)?.name;
1073
+ return adapterName !== undefined && (0, createAdapter_1.checkIsClaudeBackend)(adapterName) ? getResumableSessionId(key) : null;
1074
+ },
1075
+ // Into the running turn only (D21): never a new turn, never through the prompt path that resets the turn.
1076
+ postRunningTurnNote: async (key, text) => (0, createAdapter_1.getThreadAdapter)(key).queueNoteIntoRunningTurn?.(key, text) ?? false,
1077
+ });
1078
+ return todoMidTurn;
1079
+ }
1080
+ let todoTools = null;
1081
+ function getTodoTools() {
1082
+ todoTools ??= (0, tools_1.createTodoTools)({ registry: getTodoRegistry(), board: getTodoBoard(), onBoardShown: discardTodoInbox });
1083
+ return todoTools;
1084
+ }
1085
+ /** A turn start the bot makes carries the board itself: a waiting mid-turn inbox is withdrawn (or counted, if taken). */
1086
+ function discardTodoInbox(key) {
1087
+ try {
1088
+ getTodoMidTurn().discardInbox(key);
1089
+ }
1090
+ catch (e) {
1091
+ console.error(`[todos] ${(0, sessionKey_1.keyToString)(key)}: the mid-turn inbox could not be withdrawn:`, e instanceof Error ? e.message : e);
1092
+ }
1093
+ }
1094
+ /**
1095
+ * The board block a prompt forwarded now carries (D20), or `null`; taking it counts as seen. A failure never stops the prompt.
1096
+ * Synchronous: it sits between the forward's decision and the write (see `TodoBoard.takePromptBoard`).
1097
+ */
1098
+ function takeTodoPromptBoard(key) {
1099
+ if (!readTodoSettings().isEnabled)
1100
+ return null;
1101
+ try {
1102
+ return getTodoBoard().takePromptBoard(key);
1103
+ }
1104
+ catch (e) {
1105
+ console.error(`[todos] ${(0, sessionKey_1.keyToString)(key)}: the project board could not be built:`, e instanceof Error ? e.message : e);
1106
+ return null;
1107
+ }
1108
+ }
853
1109
  const threadMessageStates = new Map();
854
1110
  const outputQueues = new Map();
855
1111
  const pendingQuestions = new Map();
@@ -1043,10 +1299,12 @@ function onThreadActivityWhileQuestionPending(key) {
1043
1299
  }
1044
1300
  /**
1045
1301
  * @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
1302
+ * (S3): send the current question again via {@link postPendingQuestionAt} (which
1303
+ * re-renders with S1 descriptions / S2 current index and re-owns `messageId`), then
1304
+ * end the old message. The ORIGINAL message of the question is its record: it is closed as «asked again
1305
+ * below» (text stays, buttons go) and never deleted. A copy that an earlier re-post made is only a duplicate of that record
1306
+ * and is deleted, so a question that waits through a long stream of output leaves two messages, not a pile. No-op if the
1307
+ * question was answered meanwhile, or if it is already the last message (the loop guard re-checked at
1050
1308
  * fire time, since the debounce window may have closed the gap).
1051
1309
  */
1052
1310
  async function repostPendingQuestionToBottom(key) {
@@ -1067,12 +1325,77 @@ async function repostPendingQuestionToBottom(key) {
1067
1325
  if (s.isPostInFlight)
1068
1326
  return;
1069
1327
  const oldMessageId = pending.messageId;
1070
- if (oldMessageId !== null) {
1328
+ const oldQuestion = pending.data.questions[pending.currentIndex];
1329
+ // The first message of the question is its record. A state that predates the mark has none: the message found is the
1330
+ // original then, which is never deleted.
1331
+ if (oldMessageId !== null)
1332
+ pending.originalMessageId ??= oldMessageId;
1333
+ await postPendingQuestionAt(key);
1334
+ // The old message is touched only once there IS a copy below. A post that failed leaves the question where it was (its
1335
+ // message id unchanged) and still answerable there.
1336
+ const newMessageId = pendingQuestions.get(kStr)?.messageId ?? null;
1337
+ if (oldMessageId === null || !oldQuestion || newMessageId === null || newMessageId === oldMessageId)
1338
+ return;
1339
+ if (oldMessageId === pending.originalMessageId) {
1340
+ // The original: it stays as the record of the question, saying where the live copy is.
1341
+ await closeQuestionMessage(key, oldMessageId, oldQuestion, 'askedAgain');
1342
+ }
1343
+ else {
1344
+ // An earlier copy: the record already tells the question, so this one only duplicates it.
1071
1345
  await deleteThreadMessage(key, oldMessageId);
1072
1346
  }
1073
- await postPendingQuestionAt(key);
1074
1347
  });
1075
1348
  }
1349
+ /**
1350
+ * @description The note a question's message ends with, by how it ended (see {@link QuestionClosing}). An answer's
1351
+ * note is built where the chosen label is known (`agent.question_answered_note`).
1352
+ */
1353
+ const questionClosingNoteKeys = {
1354
+ unanswered: 'agent.question_unanswered_note',
1355
+ askedAgain: 'agent.question_asked_again_note',
1356
+ };
1357
+ /**
1358
+ * @description End a question's message in the topic WITHOUT deleting it: the buttons are replaced by `note`, and the
1359
+ * header, the question and its options stay, so what the agent asked and how it ended can still be read. Resolves
1360
+ * whether the text edit landed. Every way a question ends goes through this — a delete would take the record away.
1361
+ *
1362
+ * Telegram that cannot parse the Markdown render gets the plain one (no escape marks shown), and a question too long for
1363
+ * the note gets a shortened plain one. An edit that still fails (the message gone, say) leaves the old text, and the
1364
+ * buttons would stay on it dead — so they are taken off by themselves.
1365
+ */
1366
+ async function editQuestionMessageToClosed(key, messageId, question, note) {
1367
+ const markdownText = (0, openCodeQuestionFlow_1.buildClosedQuestionText)(question, note, escapeMarkdown);
1368
+ const plainText = (0, openCodeQuestionFlow_1.buildClosedQuestionPlainText)(question, note, messageSplit_1.MAX_MESSAGE_LEN);
1369
+ // A question that filled a message when it was asked has no room for the note under it, and Telegram refuses the text:
1370
+ // the note is the result, so the body gives way (descriptions first) and goes out as plain text, which cutting cannot break.
1371
+ const isEdited = markdownText.length <= messageSplit_1.MAX_MESSAGE_LEN
1372
+ ? await editThreadMessage(key, messageId, markdownText, { parse_mode: 'Markdown' }, plainText)
1373
+ : await editThreadMessage(key, messageId, plainText);
1374
+ if (!isEdited)
1375
+ await editThreadKeyboard(key, messageId, null, 'questionClose');
1376
+ return isEdited;
1377
+ }
1378
+ /**
1379
+ * {@link editQuestionMessageToClosed} for a question that ended some way ({@link QuestionEnd}). The note speaks the topic's
1380
+ * language whatever called it — a boot or a session teardown runs in no request, so no locale is set by one.
1381
+ */
1382
+ async function closeQuestionMessage(key, messageId, question, ending) {
1383
+ const note = withThreadLocale(key, () => (typeof ending === 'string' ? (0, i18n_1.t)(questionClosingNoteKeys[ending]) : (0, i18n_1.t)('agent.question_answered_note', { answer: ending.answer })));
1384
+ return editQuestionMessageToClosed(key, messageId, question, note);
1385
+ }
1386
+ /**
1387
+ * @description End a question message the bot holds no record of — a copy a restart left while its re-ask was being sent, an
1388
+ * older copy whose close Telegram refused — when somebody taps its buttons: its own text stays (`messageText`, as the tap
1389
+ * delivered it) and «not answered» takes the place of the buttons, so the question does not end with no result at all. Without
1390
+ * the text, or when the note does not fit under it, only the buttons come off. Resolves whether the message was changed at all
1391
+ * (the text edit or the removal of the buttons landed): when not, the buttons are still there and the next tap tries again.
1392
+ */
1393
+ async function closeOrphanQuestionMessage(key, messageId, messageText) {
1394
+ const note = withThreadLocale(key, () => (0, i18n_1.t)(questionClosingNoteKeys.unanswered));
1395
+ const closedText = messageText === null ? null : `${messageText}\n\n${note}`;
1396
+ const isEdited = closedText !== null && closedText.length <= messageSplit_1.MAX_MESSAGE_LEN && await editThreadMessage(key, messageId, closedText);
1397
+ return isEdited || editThreadKeyboard(key, messageId, null, 'reask');
1398
+ }
1076
1399
  // ── auto-retry after a provider-side API error (plan S4/S5/S6) ──
1077
1400
  /** One minute in ms — used only to render the "in N min" notices. */
1078
1401
  const apiRetryMsPerMinute = 60_000;
@@ -1172,6 +1495,13 @@ const wedgeRecoveriesInFlight = new Set();
1172
1495
  */
1173
1496
  function handleApiError(key, cls, options = {}) {
1174
1497
  const k = (0, sessionKey_1.keyToString)(key);
1498
+ // 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
1499
+ // that very episode (with the error's own time, which the replay cannot know). Handling it again announced a limit for a
1500
+ // window that was over, and resumed the topic a second time.
1501
+ if (cls.isReplayed === true && cls.kind === 'usageLimit' && limitEpisodesOwnedByRecovery.has(k)) {
1502
+ console.log(`[limitRecovery] ${k}: the replayed limit error is the episode the recovery re-armed — not handled twice`);
1503
+ return;
1504
+ }
1175
1505
  // A used-up API credit balance: no wait refills it — the account is marked, and the chain or the operator takes over.
1176
1506
  if (cls.kind === 'usageLimit' && cls.isBalanceExhausted === true) {
1177
1507
  void handleBalanceExhausted(key, cls);
@@ -1927,8 +2257,45 @@ const awaitingSessionSelection = new Set();
1927
2257
  * on an invalid name so the user can retry.
1928
2258
  */
1929
2259
  const awaitingFolderName = new Set();
2260
+ /**
2261
+ * The `/bind` picker message a «create new folder» tap came from, per thread: once the typed name is bound, that picker is
2262
+ * deleted. Written only together with the arming of {@link awaitingFolderName}, and read only by the bind that arming
2263
+ * leads to — so a leftover entry of an abandoned attempt is overwritten by the next tap.
2264
+ */
2265
+ const bindPickerMessageIds = new Map();
2266
+ /** 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. */
2267
+ const bindPickerClaims = new bindPickerClaims_1.BindPickerClaims(ENV.testTimings.bindPickerClaimLimit);
2268
+ /**
2269
+ * The messages open in each topic that offer a bind or ask for a folder's name (every `/bind` picker, the «not bound» replies
2270
+ * that carry one, the «create new folder» prompt): any bind or leave of the topic deletes them all — see
2271
+ * {@link closeOpenBindMessages}. In memory: a picker sent before a restart is not known, and only its own tap removes it.
2272
+ */
2273
+ const openBindMessages = new openBindMessages_1.OpenBindMessages();
2274
+ /**
2275
+ * The welcome each topic last got (per topic, newest last): its ▶️ Claude / ▶️ OpenCode / 📋 Resume buttons act on the topic's
2276
+ * CURRENT folder whatever the message says, so the next bind or a leave strips the buttons of the ones before. In memory, like
2277
+ * {@link openBindMessages}: a welcome sent before a restart keeps its buttons. The same per-topic, bounded, timestamped list of
2278
+ * message ids as {@link openBindMessages}, reused: these messages are never deleted, only stripped.
2279
+ */
2280
+ const openWelcomeMessages = new openBindMessages_1.OpenBindMessages();
1930
2281
  /** Max sessions shown in a `/sessions` list (Telegram-friendly, plan S3). */
1931
2282
  const sessionsDisplayLimit = 10;
2283
+ /**
2284
+ * @description Forum-topic names that arrived via `forum_topic_created`
2285
+ * but couldn't be processed because the creator wasn't an authorised user
2286
+ * (a group admin/creator) (audit S2 / #5). We can't read a thread's title
2287
+ * later via Telegram Bot API in any portable way, so keeping the name here
2288
+ * lets the first message from an authorised user trigger fuzzy auto-bind.
2289
+ *
2290
+ * Also holds the name of a topic an authorised user made (or renamed) that matched
2291
+ * no folder, so the binding made through the picker later can keep it
2292
+ * (`PendingTopicName.isFromAdmin`: only such a name may reach a binding).
2293
+ *
2294
+ * In-memory by design: this is a UX nicety, not a security boundary —
2295
+ * losing the cache on restart just means the user has to bind manually
2296
+ * once. The TTL guards against unbounded growth in a busy group where
2297
+ * non-authorised members keep creating topics.
2298
+ */
1932
2299
  const pendingTopicNames = new Map();
1933
2300
  const PENDING_TOPIC_NAME_TTL_MS = 24 * 60 * 60 * 1000;
1934
2301
  /**
@@ -1958,6 +2325,10 @@ const threadContextMarkers = new Map();
1958
2325
  */
1959
2326
  function clearThreadContextMarker(key) {
1960
2327
  threadContextMarkers.delete((0, sessionKey_1.keyToString)(key));
2328
+ // The same boundary for the project board: the next turn start carries it whole (D20), and an inbox no tool call of
2329
+ // this session will take is withdrawn.
2330
+ noteTodoFreshContext(key);
2331
+ discardTodoInbox(key);
1961
2332
  }
1962
2333
  function getThreadMessageState(key) {
1963
2334
  const k = (0, sessionKey_1.keyToString)(key);
@@ -2004,7 +2375,7 @@ function getOutputQueueState(key) {
2004
2375
  const k = (0, sessionKey_1.keyToString)(key);
2005
2376
  let s = outputQueues.get(k);
2006
2377
  if (!s) {
2007
- s = { pendingOutput: null, pendingIsContinuation: false, isProcessing: false, debounceTimer: null };
2378
+ s = { pendingSegments: [], isProcessing: false, debounceTimer: null };
2008
2379
  outputQueues.set(k, s);
2009
2380
  }
2010
2381
  return s;
@@ -2025,7 +2396,7 @@ function checkIsOutputStreaming(key) {
2025
2396
  const q = outputQueues.get((0, sessionKey_1.keyToString)(key));
2026
2397
  if (!q)
2027
2398
  return false;
2028
- return q.pendingOutput !== null || q.isProcessing || q.debounceTimer !== null;
2399
+ return (0, outputFlushPlan_1.checkHasPendingOutput)(q) || q.isProcessing || q.debounceTimer !== null;
2029
2400
  }
2030
2401
  function getStatusCoalesceState(key) {
2031
2402
  const k = (0, sessionKey_1.keyToString)(key);
@@ -2088,11 +2459,15 @@ function clearThreadQueues(key) {
2088
2459
  // `/quit` release, session `closed`/`stopped`, `/unbind`, and topic-delete —
2089
2460
  // all route through here. CRITICAL ORDER: finalize BEFORE
2090
2461
  // `clearThreadOutputQueues`, because the group finalize drains
2091
- // `q.pendingOutput` and the clear would otherwise null it first (S2). Both
2462
+ // the pending segments and the clear would otherwise drop them first (S2). Both
2092
2463
  // transports capture + reset their in-flight state SYNCHRONOUSLY before any
2093
2464
  // await, so the clear below can't race the drain. Fire-and-forget.
2094
2465
  void getOutboundFor(key).finalize(key);
2095
- (0, clearThreadOutputQueues_1.clearThreadOutputQueues)(outputQueues.get(k), statusCoalescers.get(k));
2466
+ const queue = outputQueues.get(k);
2467
+ // Text that took the new-message mark and is dropped unsent leaves the mark with the thread again.
2468
+ if (queue?.pendingSegments.some((segment) => segment.startsNewMessage))
2469
+ markNeedsNewMessage(key);
2470
+ (0, clearThreadOutputQueues_1.clearThreadOutputQueues)(queue, statusCoalescers.get(k));
2096
2471
  // A new session starts with empty context — forget the last-sent outputs so
2097
2472
  // the identical-output backstop can't suppress a legitimate repeat across a
2098
2473
  // session boundary (the same convergence point that clears the other
@@ -2365,6 +2740,7 @@ function clearInMemoryThreadState(key) {
2365
2740
  disconnectProviderLists.delete(pickerKey);
2366
2741
  }
2367
2742
  awaitingFolderName.delete(k);
2743
+ bindPickerMessageIds.delete(k);
2368
2744
  pinnedStatusTextCache.delete(k);
2369
2745
  statusCoalescers.delete(k);
2370
2746
  // `clearThreadQueues` → `finalizeInFlight` above already finalized any accumulated
@@ -2764,11 +3140,17 @@ async function replyToThread(key, text, extra = {}, options = {}) {
2764
3140
  const send = () => bot.telegram.sendMessage((0, sessionKeyCodec_1.getTelegramChatId)(key), text, sendExtra);
2765
3141
  return options.unpaced ? (0, rateLimiter_1.sendUnpaced)(key, send) : (0, rateLimiter_1.enqueueSend)(key, send);
2766
3142
  };
2767
- try {
2768
- const sent = await sendOnce(buildSendExtra(key, extra));
3143
+ // A sent message is tracked for `/clear_messages`, and a `/bind` picker is remembered to be closed by the next bind or leave.
3144
+ const trackSent = async (sent) => {
2769
3145
  const messageId = sent.message_id;
2770
3146
  await state.pushMessageId(key, messageId);
3147
+ if ((0, bindPicker_1.checkIsBindPickerExtra)(extra))
3148
+ openBindMessages.remember((0, sessionKey_1.keyToString)(key), messageId, Date.now());
2771
3149
  return messageId;
3150
+ };
3151
+ const releaseSecret = options.isSecret ? (0, outputTrace_1.markOutboundTextSecret)(text) : null;
3152
+ try {
3153
+ return await trackSent(await sendOnce(buildSendExtra(key, extra)));
2772
3154
  }
2773
3155
  catch (e) {
2774
3156
  // Markdown parse failure fallback. Several `t(...)` templates interpolate
@@ -2786,10 +3168,7 @@ async function replyToThread(key, text, extra = {}, options = {}) {
2786
3168
  const plainExtra = { ...extra };
2787
3169
  delete plainExtra.parse_mode;
2788
3170
  try {
2789
- const sent = await sendOnce(buildSendExtra(key, plainExtra));
2790
- const messageId = sent.message_id;
2791
- await state.pushMessageId(key, messageId);
2792
- return messageId;
3171
+ return await trackSent(await sendOnce(buildSendExtra(key, plainExtra)));
2793
3172
  }
2794
3173
  catch (e2) {
2795
3174
  await handleSendError(key, e2);
@@ -2804,6 +3183,9 @@ async function replyToThread(key, text, extra = {}, options = {}) {
2804
3183
  await handleSendError(key, e);
2805
3184
  return null;
2806
3185
  }
3186
+ finally {
3187
+ releaseSecret?.();
3188
+ }
2807
3189
  }
2808
3190
  /**
2809
3191
  * @description Edit an existing assistant message in the thread.
@@ -2811,13 +3193,17 @@ async function replyToThread(key, text, extra = {}, options = {}) {
2811
3193
  * Returns `true` on success, `false` if the edit failed for any reason —
2812
3194
  * caller decides whether to fall back to a new `replyToThread`. We don't
2813
3195
  * track edits as new ids (the original was already tracked).
3196
+ *
3197
+ * `plainFallbackText`: the text for the retry without `parse_mode` when Telegram cannot parse the Markdown one — the
3198
+ * same text when absent, which shows a Markdown render's escape marks as they are; a caller that has a real plain
3199
+ * rendering passes it.
2814
3200
  */
2815
- async function editThreadMessage(key, messageId, text, extra = {}) {
3201
+ async function editThreadMessage(key, messageId, text, extra = {}, plainFallbackText = text) {
2816
3202
  if (!checkIsTelegramPrimitiveKey(key, 'editThreadMessage'))
2817
3203
  return false;
2818
- const editOnce = (editExtra) => (0, rateLimiter_1.enqueueSend)(key, () => bot.telegram.editMessageText((0, sessionKeyCodec_1.getTelegramChatId)(key), messageId, undefined, text, editExtra));
3204
+ const editOnce = (editText, editExtra) => (0, rateLimiter_1.enqueueSend)(key, () => bot.telegram.editMessageText((0, sessionKeyCodec_1.getTelegramChatId)(key), messageId, undefined, editText, editExtra));
2819
3205
  try {
2820
- await editOnce(extra);
3206
+ await editOnce(text, extra);
2821
3207
  return true;
2822
3208
  }
2823
3209
  catch (e) {
@@ -2837,7 +3223,7 @@ async function editThreadMessage(key, messageId, text, extra = {}) {
2837
3223
  const plainExtra = { ...extra };
2838
3224
  delete plainExtra.parse_mode;
2839
3225
  try {
2840
- await editOnce(plainExtra);
3226
+ await editOnce(plainFallbackText, plainExtra);
2841
3227
  return true;
2842
3228
  }
2843
3229
  catch (e2) {
@@ -2852,14 +3238,63 @@ async function editThreadMessage(key, messageId, text, extra = {}) {
2852
3238
  return false;
2853
3239
  }
2854
3240
  }
2855
- async function deleteThreadMessage(key, messageId) {
3241
+ /**
3242
+ * @description Replace the inline keyboard of a message of the thread (`null` removes it) through the paced send queue.
3243
+ * Returns `true` when the keyboard is as asked — a no-change edit («message is not modified») counts — and `false`
3244
+ * when the edit failed, which is logged under `logTag` and left to the caller to act on (a re-render may simply skip it).
3245
+ */
3246
+ async function editThreadKeyboard(key, messageId, keyboard, logTag) {
3247
+ if (!checkIsTelegramPrimitiveKey(key, 'editThreadKeyboard'))
3248
+ return false;
3249
+ try {
3250
+ await (0, rateLimiter_1.enqueueSend)(key, () => bot.telegram.editMessageReplyMarkup((0, sessionKeyCodec_1.getTelegramChatId)(key), messageId, undefined, keyboard ?? undefined));
3251
+ return true;
3252
+ }
3253
+ catch (e) {
3254
+ const desc = (0, sendErrorClassifier_1.checkIsApiError)(e) ? (0, sendErrorClassifier_1.getErrorDescription)(e) : '';
3255
+ if (/message is not modified/i.test(desc))
3256
+ return true;
3257
+ console.warn(`[${logTag}] keyboard edit failed:`, desc || e);
3258
+ return false;
3259
+ }
3260
+ }
3261
+ async function deleteThreadMessageDetailed(key, messageId) {
2856
3262
  if (!checkIsTelegramPrimitiveKey(key, 'deleteThreadMessage'))
2857
- return;
3263
+ return 'stayed';
2858
3264
  try {
2859
3265
  await (0, rateLimiter_1.enqueueSend)(key, () => bot.telegram.deleteMessage((0, sessionKeyCodec_1.getTelegramChatId)(key), messageId));
3266
+ return 'deleted';
3267
+ }
3268
+ catch (e) {
3269
+ return (0, telegramError_1.checkIsMessageNotFoundError)(e) ? 'gone' : 'stayed';
3270
+ }
3271
+ }
3272
+ /**
3273
+ * @description Delete a message of the thread. Resolves `false` when it is still there — older than 48 hours, refused, or no
3274
+ * Telegram topic's key — which a caller that needs it gone can act on.
3275
+ */
3276
+ async function deleteThreadMessage(key, messageId) {
3277
+ return (await deleteThreadMessageDetailed(key, messageId)) === 'deleted';
3278
+ }
3279
+ /** The most message ids one `deleteMessages` call takes (the Bot API documents 1-100). */
3280
+ const deleteMessagesPerCallLimit = 100;
3281
+ /**
3282
+ * @description Delete several messages of the thread in ONE paced send (`deleteMessages`: «if some of the specified messages
3283
+ * can't be found, they are skipped»). Resolves `true` when the call went through; `false` when Telegram refused it — the docs do
3284
+ * 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.
3285
+ */
3286
+ async function deleteThreadMessages(key, messageIds) {
3287
+ if (!checkIsTelegramPrimitiveKey(key, 'deleteThreadMessages'))
3288
+ return false;
3289
+ try {
3290
+ for (let start = 0; start < messageIds.length; start += deleteMessagesPerCallLimit) {
3291
+ const chunk = messageIds.slice(start, start + deleteMessagesPerCallLimit);
3292
+ await (0, rateLimiter_1.enqueueSend)(key, () => bot.telegram.deleteMessages((0, sessionKeyCodec_1.getTelegramChatId)(key), chunk));
3293
+ }
3294
+ return true;
2860
3295
  }
2861
3296
  catch {
2862
- /* messages older than 48h or already deleted — silently ignore */
3297
+ return false;
2863
3298
  }
2864
3299
  }
2865
3300
  /**
@@ -2941,7 +3376,7 @@ function startTypingLoader(key) {
2941
3376
  const q = outputQueues.get((0, sessionKey_1.keyToString)(key));
2942
3377
  console.warn(`[typing] ${(0, sessionKey_1.keyToString)(key)} force-stop: kept alive by leaked output-queue state — ` +
2943
3378
  `isOutputStreaming=${checkIsOutputStreaming(key)} isAdapterBusy=${checkIsAdapterBusy(key)} ` +
2944
- `pendingOutput=${q?.pendingOutput !== null && q?.pendingOutput !== undefined} ` +
3379
+ `pendingOutput=${q !== undefined && (0, outputFlushPlan_1.checkHasPendingOutput)(q)} ` +
2945
3380
  `isProcessing=${q?.isProcessing === true} debounceTimer=${q?.debounceTimer !== null && q?.debounceTimer !== undefined}`);
2946
3381
  if (q?.debounceTimer) {
2947
3382
  clearTimeout(q.debounceTimer);
@@ -2968,7 +3403,7 @@ function checkIsTypingLoopStuck(key) {
2968
3403
  isAdapterBusy: checkIsAdapterBusy(key),
2969
3404
  isCompacting: checkIsThreadCompacting(key),
2970
3405
  isTransportStreaming: getOutboundFor(key).checkIsDelivering(key),
2971
- hasPendingOutput: q?.pendingOutput != null,
3406
+ hasPendingOutput: q !== undefined && (0, outputFlushPlan_1.checkHasPendingOutput)(q),
2972
3407
  isProcessing: q?.isProcessing === true,
2973
3408
  hasDebounceTimer: q?.debounceTimer != null,
2974
3409
  });
@@ -3033,7 +3468,7 @@ function escapeMarkdown(text) {
3033
3468
  * @description Queue an output text for a thread with debounce.
3034
3469
  *
3035
3470
  * Multiple `output` events from the adapter are coalesced into one pending
3036
- * buffer (see {@link appendPendingOutput}), not overwritten — every emitted
3471
+ * buffer (see {@link addPendingOutput}), not overwritten — every emitted
3037
3472
  * chunk reaches Telegram. A continuation tail concatenates as-is (it may be
3038
3473
  * cut mid-word); a standalone output joins the buffer with `\n`. The first
3039
3474
  * batch's `isContinuation` is recorded so the flush knows whether the buffer
@@ -3069,11 +3504,9 @@ function getOutputDelay(chatId) {
3069
3504
  */
3070
3505
  function queueOutput(key, output, isContinuation = false, isFinal = false, isComplete = false, startsNewParagraph = false) {
3071
3506
  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);
3507
+ // A batch joins the newest pending segment; one queued after a turn boundary was marked starts a segment of its own, which
3508
+ // opens a new message, and leaves the earlier segment to flush where it was going.
3509
+ (0, outputFlushPlan_1.addPendingOutput)(q, getThreadMessageState(key), { output, isContinuation, startsNewParagraph });
3077
3510
  // `isFinal` / `isComplete` still drive the immediate-flush timing below (a
3078
3511
  // finished turn must not wait out the debounce) — they no longer mark the
3079
3512
  // buffer for redelivery (retired with the global send pacer, S4).
@@ -3098,7 +3531,8 @@ function queueOutput(key, output, isContinuation = false, isFinal = false, isCom
3098
3531
  // S3: scale the in-cooldown debounce to the live remaining cooldown.
3099
3532
  remainingCooldownMs: (0, rateLimiter_1.getRateLimitRemainingMs)((0, sessionKeyCodec_1.getTelegramChatId)(key)),
3100
3533
  });
3101
- if (timing === 'now') {
3534
+ // An earlier segment, sealed by a boundary, has nothing more to wait for: its turn is over.
3535
+ if (timing === 'now' || q.pendingSegments.length > 1) {
3102
3536
  // Final frame: flush immediately. `processOutputQueue` already guards
3103
3537
  // `isProcessing`, so this is idempotent if a flush is mid-flight (the
3104
3538
  // pending frame will be picked up by the re-trigger timer it arms).
@@ -3112,14 +3546,14 @@ function queueOutput(key, output, isContinuation = false, isFinal = false, isCom
3112
3546
  }
3113
3547
  async function processOutputQueue(key) {
3114
3548
  const q = getOutputQueueState(key);
3115
- if (q.isProcessing || !q.pendingOutput)
3549
+ if (q.isProcessing || !(0, outputFlushPlan_1.checkHasPendingOutput)(q))
3116
3550
  return;
3117
3551
  q.isProcessing = true;
3118
3552
  try {
3119
- const out = q.pendingOutput;
3120
- const isContinuation = q.pendingIsContinuation;
3121
- q.pendingOutput = null;
3122
- const { unsentRemainder } = await sendOutputImmediate(key, out, isContinuation);
3553
+ const flush = (0, outputFlushPlan_1.takePendingOutput)(q);
3554
+ if (!flush)
3555
+ return;
3556
+ const { unsentRemainder } = await sendOutputImmediate(key, flush.output, flush.isContinuation, flush.startsNewMessage);
3123
3557
  // No silent drop: a chunk dropped on a 429 after the retry-after wait comes
3124
3558
  // back as `unsentRemainder`. Put it BACK at the FRONT of the buffer (it is
3125
3559
  // older than anything that arrived during the await) so the re-trigger flush
@@ -3127,17 +3561,12 @@ async function processOutputQueue(key) {
3127
3561
  // `getUnsentRemainder`, so this never re-sends what already reached Telegram.
3128
3562
  // The remainder restarts a fresh message (its predecessors landed / edited in
3129
3563
  // 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
- }
3564
+ if (unsentRemainder)
3565
+ (0, outputFlushPlan_1.requeueUnsentRemainder)(q, unsentRemainder);
3137
3566
  }
3138
3567
  finally {
3139
3568
  q.isProcessing = false;
3140
- if (q.pendingOutput) {
3569
+ if ((0, outputFlushPlan_1.checkHasPendingOutput)(q)) {
3141
3570
  // Audit S13 / #30: the re-trigger `setTimeout` used to be a bare
3142
3571
  // call whose handle was never stored. A `queueOutput` call between
3143
3572
  // here and the timer firing would `clearTimeout(null)` and
@@ -3149,7 +3578,7 @@ async function processOutputQueue(key) {
3149
3578
  q.debounceTimer = setTimeout(() => {
3150
3579
  q.debounceTimer = null;
3151
3580
  processOutputQueue(key);
3152
- }, getOutputDelay((0, sessionKeyCodec_1.getTelegramChatId)(key)));
3581
+ }, q.pendingSegments.length > 1 ? 0 : getOutputDelay((0, sessionKeyCodec_1.getTelegramChatId)(key)));
3153
3582
  }
3154
3583
  }
3155
3584
  }
@@ -3158,7 +3587,7 @@ async function processOutputQueue(key) {
3158
3587
  * coalesced-but-unsent output against what actually landed, and force-send the
3159
3588
  * remainder so the agent's FINAL answer is never discarded on teardown (S2).
3160
3589
  *
3161
- * Today the group output queue coalesces into `q.pendingOutput` and the
3590
+ * Today the group output queue coalesces into `q.pendingSegments` and the
3162
3591
  * (possibly 429-stretched) debounce flushes it later; a teardown
3163
3592
  * (`idle`/`isFinal` → `handleAgentStopped`, `/new`, `/quit`, `/unbind`,
3164
3593
  * topic-delete) runs `clearThreadOutputQueues` which DISCARDS that buffer — so
@@ -3180,20 +3609,23 @@ async function finalizeGroupOutput(key) {
3180
3609
  const plan = (0, groupFinalizePlan_1.getGroupFinalizePlan)(q);
3181
3610
  if (plan.action === 'noop')
3182
3611
  return; // fully delivered → no-op, no dupe.
3183
- // Snapshot is captured by the plan; null the buffer SYNCHRONOUSLY before the
3612
+ // Snapshot is captured by the plan; empty the buffer SYNCHRONOUSLY before the
3184
3613
  // await so the immediately-following clear / a concurrent flush can't re-send it.
3185
- q.pendingOutput = null;
3614
+ q.pendingSegments = [];
3186
3615
  if (q.debounceTimer) {
3187
3616
  clearTimeout(q.debounceTimer);
3188
3617
  q.debounceTimer = null;
3189
3618
  }
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`);
3619
+ for (const segment of plan.segments) {
3620
+ const { unsentRemainder } = await sendOutputImmediate(key, segment.output, segment.isContinuation, segment.startsNewMessage);
3621
+ // A remainder that still 429'd at teardown is dropped (the buffer is being
3622
+ // torn down anyway — unchanged from the pre-S2 discard), and so is everything behind it. Do NOT re-queue: the
3623
+ // queue is about to be cleared, and re-queuing would race that clear. At 1/2s
3624
+ // global pacing a 429 here is essentially impossible.
3625
+ if (unsentRemainder) {
3626
+ console.warn(`[send] ${(0, sessionKey_1.keyToString)(key)} finalize remainder 429'd and was dropped at teardown`);
3627
+ return;
3628
+ }
3197
3629
  }
3198
3630
  }
3199
3631
  /**
@@ -3210,12 +3642,17 @@ async function finalizeGroupOutput(key) {
3210
3642
  * previous text on the same message id, so the user could read only the last
3211
3643
  * tail. Editing is now reserved for explicit continuations of the SAME reply.
3212
3644
  *
3645
+ * `startsNewMessage`: the text was queued after a turn boundary, so it opens a
3646
+ * message of its own — decided when the text was queued ({@link addPendingOutput}),
3647
+ * not read from the thread here, where a flush of EARLIER text finishing after the
3648
+ * boundary would have spent the mark.
3649
+ *
3213
3650
  * On Markdown rejection by Telegram we fall back to plain text (`parse_mode`
3214
3651
  * unset) — the message lands either way. If the in-place edit fails (message
3215
3652
  * deleted / API hiccup) we send every chunk fresh so the full combined text
3216
3653
  * still reaches the user.
3217
3654
  */
3218
- async function sendOutputImmediate(key, output, isContinuation = false) {
3655
+ async function sendOutputImmediate(key, output, isContinuation = false, startsNewMessage = false) {
3219
3656
  // The typing loader is stopped upstream in `handleAgentOutput` (the shared
3220
3657
  // onOutput entry for BOTH transports), so it's not repeated here.
3221
3658
  await deleteStatusMessage(key);
@@ -3223,7 +3660,7 @@ async function sendOutputImmediate(key, output, isContinuation = false) {
3223
3660
  const { chunks: planChunks, shouldEditFirstChunk } = (0, outputFlushPlan_1.getOutputFlushPlan)({
3224
3661
  output,
3225
3662
  isContinuation,
3226
- needsNewMessage: msgState.needsNewMessage,
3663
+ needsNewMessage: startsNewMessage,
3227
3664
  lastMessageId: msgState.lastMessageId,
3228
3665
  lastMessageText: msgState.lastMessageText,
3229
3666
  });
@@ -3261,7 +3698,6 @@ async function sendOutputImmediate(key, output, isContinuation = false) {
3261
3698
  break;
3262
3699
  msgState.lastMessageId = id;
3263
3700
  msgState.lastMessageText = chunks[i];
3264
- msgState.needsNewMessage = false;
3265
3701
  sentCount = i + 1;
3266
3702
  }
3267
3703
  // S3: output just landed below any pending question — bring the question back
@@ -3399,7 +3835,7 @@ const shutdownOutputFlushMaxMs = rateLimiter_1.shutdownDrainMaxMs + 1000;
3399
3835
  * already-emitted answer chunks died in the send pipeline with the
3400
3836
  * process and were never re-sent). Two buffers drain, in order:
3401
3837
  *
3402
- * [A] per-thread COALESCE state — the group/baseline `q.pendingOutput`
3838
+ * [A] per-thread COALESCE state — the group/baseline `q.pendingSegments`
3403
3839
  * buffers (enumerated here from {@link outputQueues}) and the DM
3404
3840
  * transport's active drafts (enumerated via `getInFlightThreadKeys`) —
3405
3841
  * each finalized to a permanent message via the transport's
@@ -3429,7 +3865,7 @@ async function finalizePendingOutputOnShutdown() {
3429
3865
  // queues + the connector's own unfinalized content (deduped by serialised key).
3430
3866
  const pendingKeys = new Map();
3431
3867
  for (const [keyStr, q] of outputQueues) {
3432
- if (q.pendingOutput === null)
3868
+ if (!(0, outputFlushPlan_1.checkHasPendingOutput)(q))
3433
3869
  continue;
3434
3870
  try {
3435
3871
  pendingKeys.set(keyStr, (0, sessionKey_1.keyFromString)(keyStr));
@@ -3447,11 +3883,11 @@ async function finalizePendingOutputOnShutdown() {
3447
3883
  // A DM-surface thread on a non-draft adapter (e.g. terminal) coalesces via
3448
3884
  // the group `queueOutput` path, but its transport finalize handled only the
3449
3885
  // draft — drain any such leftover buffer directly. Group keys were already
3450
- // drained above (their finalize nulls `pendingOutput` synchronously), so
3886
+ // drained above (their finalize empties the pending segments synchronously), so
3451
3887
  // this is a no-op for them.
3452
3888
  const leftoverDrains = [];
3453
3889
  for (const [keyStr, q] of outputQueues) {
3454
- if (q.pendingOutput === null)
3890
+ if (!(0, outputFlushPlan_1.checkHasPendingOutput)(q))
3455
3891
  continue;
3456
3892
  try {
3457
3893
  leftoverDrains.push(finalizeGroupOutput((0, sessionKey_1.keyFromString)(keyStr)));
@@ -3663,7 +4099,7 @@ function stopAllAdaptersFor(key, adapterNames) {
3663
4099
  return (0, createAdapter_1.stopAllAdaptersFor)(key, createAdapter_1.getAdapter, adapterNames);
3664
4100
  }
3665
4101
  /**
3666
- * @description Explicit-stop teardown used by `/new`/`/clear_session`:
4102
+ * @description Explicit-stop teardown used by `/new`:
3667
4103
  * sweep every adapter active for the thread, then RELEASE the persisted session
3668
4104
  * ids (even when nothing was running) so a later bot restart won't auto-reattach
3669
4105
  * and any half-dead state from a crash/SSE-giveup is cleared. The session stays
@@ -3687,9 +4123,18 @@ async function releaseThreadSession(key) {
3687
4123
  // Claude / no pending question.
3688
4124
  (0, createAdapter_1.getThreadAdapter)(key).rejectQuestion?.(key);
3689
4125
  const result = stopAllAdaptersFor(key);
3690
- await state.clearAgentSessionIds(key);
4126
+ await releaseConversationIds(key);
3691
4127
  return result;
3692
4128
  }
4129
+ /**
4130
+ * @description Release a conversation for good: its saved session ids go, so nothing resumes it, and the question the idle
4131
+ * compaction re-asked for it is closed (its message stays, without its buttons). A conversation that is asleep has no process
4132
+ * whose stop would say so, so the release itself has to — every release of a conversation that may be asleep goes through here.
4133
+ */
4134
+ async function releaseConversationIds(key) {
4135
+ await state.clearAgentSessionIds(key);
4136
+ clearThreadCompaction(key);
4137
+ }
3693
4138
  /**
3694
4139
  * @description Switch a thread's adapter and clear the previous adapter's
3695
4140
  * persisted session id, so a later restart can't re-attach to the wrong
@@ -3712,8 +4157,11 @@ async function switchThreadAdapter(key, newName) {
3712
4157
  // Another agent or a shell takes the topic over: the open request belonged to
3713
4158
  // the old conversation, so it closes silently instead of a later wake-up
3714
4159
  // reminding (or, in a terminal, TYPING into) a session that never saw it.
3715
- if (prevName && !(0, createAdapter_1.checkIsSameConversationSwitch)(prevName, newName))
4160
+ if (prevName && !(0, createAdapter_1.checkIsSameConversationSwitch)(prevName, newName)) {
3716
4161
  cancelConversationRequest(key);
4162
+ // Nor does the conversation left behind get its re-asked question answered: its buttons would send the label into the new one.
4163
+ closeReAskedQuestion(key);
4164
+ }
3717
4165
  let switchCutOffWork = null;
3718
4166
  if (prevName && prevName !== newName) {
3719
4167
  try {
@@ -3848,8 +4296,8 @@ function getStartReadyMessage(adapter, subdir, args, model, effort) {
3848
4296
  return (0, i18n_1.t)('agent.ready', { ...vars, infoBlock: getStartReadyInfoBlock(model, effort) });
3849
4297
  }
3850
4298
  /**
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).
4299
+ * The bot-MCP tool digest of a conversation (its platform's tools, its «Bot skills» instructions), from the scheduler handle
4300
+ * once it is wired at boot; `null` before that (no digest is persisted, which reads as stale).
3853
4301
  */
3854
4302
  let botMcpToolDigestReader = null;
3855
4303
  /**
@@ -3889,7 +4337,7 @@ function getPerTurnRefusal(key) {
3889
4337
  async function persistSessionStart(key, adapter) {
3890
4338
  await (0, persistAdapterSessionIds_1.persistAdapterSessionIds)(key, adapter, state);
3891
4339
  if ((0, adapterNames_1.checkIsJsonStreamBackend)(adapter.name) && botMcpToolDigestReader) {
3892
- await state.setAgentMcpToolDigest(key, botMcpToolDigestReader(key.platform));
4340
+ await state.setAgentMcpToolDigest(key, botMcpToolDigestReader(key));
3893
4341
  }
3894
4342
  }
3895
4343
  /**
@@ -4231,6 +4679,11 @@ const autoStopUnsupportedLoggedKeys = new Set();
4231
4679
  * and that turn ends here too) or the process still works (a background task,
4232
4680
  * input not yet taken in). The session sleeps; the next trigger resumes it with
4233
4681
  * the prompt cache warm.
4682
+ *
4683
+ * The stop is QUEUED here, in the same tick the adapter marks the turn done (nothing awaits in between): a
4684
+ * `postToSession` that polled the session idle and ensures it again (`postToSession.ts`) therefore always finds
4685
+ * the stop already on the transition queue and runs after it. Keep it that way — an await ahead of the queueing
4686
+ * would let that ensure run beside the stop and forward to the process it ends.
4234
4687
  */
4235
4688
  function handleAgentTurnEnded(key) {
4236
4689
  noteThreadActivity(key);
@@ -4273,7 +4726,7 @@ async function replayBufferedPrompts(key) {
4273
4726
  // like the API-error retry's nudge, hears whether it reached the session).
4274
4727
  await startupPromptBuffer.replayPrompts((0, sessionKey_1.keyToString)(key), {
4275
4728
  isSessionActive,
4276
- forward: (prompt) => forwardPromptToAgent(key, adapter, prompt),
4729
+ forward: async (prompt) => { await forwardPromptToAgent(key, adapter, prompt); },
4277
4730
  });
4278
4731
  if (!isSessionActive)
4279
4732
  return;
@@ -4307,17 +4760,61 @@ async function replayBufferedPrompts(key) {
4307
4760
  * `session.status=retry` will not read queued prompts until the provider retry
4308
4761
  * time, so a new prompt aborts that stale wait before using the current model.
4309
4762
  * An adapter without the method forwards directly.
4763
+ *
4764
+ * Resolves where the prompt went (`LatePromptOutcome`): `forwarded` to the session, `buffered` behind a stop window, `noSession`
4765
+ * (nothing to take it; the person was told), `dropped` (a technical prompt found no live idle session). Only a prompt that waited
4766
+ * for an adopt replay can come out as anything but `forwarded`; most callers have no use for the answer.
4310
4767
  */
4311
4768
  async function forwardPromptToAgent(key, adapter, text, sentAtMs, options = {}) {
4769
+ // An adopted session replays what its process wrote during the downtime first: a prompt written meanwhile would reset
4770
+ // the answer and error state those frames still use, and the replayed text would join the new reply's message (the
4771
+ // «new message» mark would go to the replayed text). Nothing is awaited when no replay is pending: the forward then runs
4772
+ // in the caller's tick, as it always did — an await ahead of it would let a stop queued meanwhile begin first.
4773
+ if (!adapter.checkIsAdoptReplayPending?.(key)) {
4774
+ await forwardPromptToLiveAgent(key, adapter, text, sentAtMs, options);
4775
+ return 'forwarded';
4776
+ }
4777
+ await (0, adoptReplayGate_1.waitForAdoptedReplay)(adapter, key);
4778
+ // The wait let the replay run: the replayed turn's end may have queued the per-turn stop, which holds the window open and
4779
+ // ends the process — a prompt written now would go to a process being killed and be lost. Routed again, as a late prompt is.
4780
+ // A technical prompt (the standalone todo reminder) goes only into a session that is live and idle now, and is otherwise
4781
+ // dropped without a word: never buffered, never a resume, never a "no session" line to the person.
4782
+ const isTechnicalPrompt = options.isTechnicalPrompt === true;
4783
+ const outcome = await (0, promptDelivery_1.deliverLatePrompt)({
4784
+ isTechnical: isTechnicalPrompt,
4785
+ checkIsWindowOpen: () => startupPromptBuffer.checkIsStarting((0, sessionKey_1.keyToString)(key)),
4786
+ checkIsSessionActive: () => adapter.checkIsActive(key) && !(isTechnicalPrompt && (adapter.checkIsBusy?.(key) ?? false)),
4787
+ buffer: async () => {
4788
+ await deliverPromptOrBuffer(key, `${options.requestHeader ?? ''}${getPromptWithThreadReplyQuote(key, text, options.replyContext)}`, true);
4789
+ },
4790
+ wake: () => wakeSleepingSessionForPrompt(key),
4791
+ forward: async () => {
4792
+ await forwardPromptToLiveAgent(key, (0, createAdapter_1.getThreadAdapter)(key), text, sentAtMs, options);
4793
+ return 'forwarded';
4794
+ },
4795
+ reportNoSession: async (isAlreadyTold) => {
4796
+ console.warn(`[adopt] ${(0, sessionKey_1.keyToString)(key)}: no session is left to take the prompt that waited for the replay; it is dropped`);
4797
+ if (!isAlreadyTold)
4798
+ await replyToThread(key, (0, i18n_1.t)('agent.no_session'));
4799
+ },
4800
+ });
4801
+ if (outcome === 'dropped')
4802
+ console.log(`[adopt] ${(0, sessionKey_1.keyToString)(key)}: the technical prompt found no live, idle session after the replay wait; it is dropped`);
4803
+ return outcome;
4804
+ }
4805
+ /** @description The forward itself, for a session that is ready to take the prompt: {@link forwardPromptToAgent} minus the replay wait. */
4806
+ async function forwardPromptToLiveAgent(key, adapter, text, sentAtMs, options = {}) {
4312
4807
  // A forwarded prompt is thread activity — reset the compact-on-idle watchdog (F2).
4313
4808
  noteThreadActivity(key);
4809
+ const isTechnicalPrompt = options.isTechnicalPrompt ?? false;
4314
4810
  // 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).
4811
+ // the once-per-episode guard intact), a request reminder (the request's own
4812
+ // prompt stays the one to replay) and a bot-made technical prompt.
4317
4813
  const isReplayable = (0, wedgeRecovery_1.checkIsReplayablePrompt)({
4318
4814
  isSlashCommand: (0, threadContextPreamble_1.checkShouldSkipPreambleForText)(text),
4319
4815
  isRecoveryReplay: options.isRecoveryReplay ?? false,
4320
4816
  isRequestReminder: options.isRequestReminder ?? false,
4817
+ isTechnicalPrompt,
4321
4818
  });
4322
4819
  // R23: prompts held during a usage-limit wait ride the first such prompt
4323
4820
  // forwarded to a live session once no wait holds them — after it, in arrival
@@ -4366,6 +4863,18 @@ async function forwardPromptToAgent(key, adapter, text, sentAtMs, options = {})
4366
4863
  if (state.checkIsTimestampsEnabled(key) && !(0, threadContextPreamble_1.checkShouldSkipPreambleForText)(text)) {
4367
4864
  promptText = `${(0, isoTimestamp_1.formatIsoLocalOffset)(sentAtMs ?? Date.now())}\n\n${promptText}`;
4368
4865
  }
4866
+ // The project board rides every turn start the bot makes (D20: whole on a fresh context, else only what changed),
4867
+ // then the todo list's staleness reminder rides the next real prompt from any source (D11 form a) — never a slash
4868
+ // command or a shell line; the reminder never rides the standalone reminder itself.
4869
+ const isTodoDecorated = !(0, threadContextPreamble_1.checkShouldSkipPreambleForText)(text) && adapter.name !== 'terminal';
4870
+ if (isTodoDecorated)
4871
+ discardTodoInbox(key);
4872
+ const todoBoardBlock = isTodoDecorated ? takeTodoPromptBoard(key) : null;
4873
+ if (todoBoardBlock !== null)
4874
+ promptText = `${promptText}\n\n${todoBoardBlock}`;
4875
+ const todoReminderLine = isTodoDecorated && !isTechnicalPrompt ? takeTodoPromptReminder(key) : null;
4876
+ if (todoReminderLine !== null)
4877
+ promptText = `${promptText}\n\n${todoReminderLine}`;
4369
4878
  markNeedsNewMessage(key);
4370
4879
  // A new prompt is a new turn: lift the S2 idle-suppress latch and re-base the
4371
4880
  // working-status elapsed so the next busy period shows a fresh 0:00 counter
@@ -4380,7 +4889,8 @@ async function forwardPromptToAgent(key, adapter, text, sentAtMs, options = {})
4380
4889
  // persists while output is streaming OR the agent is busy — self-stopping only
4381
4890
  // when the topic drains + idles.
4382
4891
  getOutboundFor(key).setActivity(key, 'working');
4383
- if (adapter.interruptAndWaitIdle) {
4892
+ // A technical prompt is posted only into an idle session: there is no turn to interrupt or sub-agent to detach.
4893
+ if (adapter.interruptAndWaitIdle && !isTechnicalPrompt) {
4384
4894
  await adapter.interruptAndWaitIdle(key);
4385
4895
  }
4386
4896
  // OpenCode: if a sub-agent is synchronously blocking this session, detach it to
@@ -4388,7 +4898,7 @@ async function forwardPromptToAgent(key, adapter, text, sentAtMs, options = {})
4388
4898
  // the whole (possibly long) sub-agent run — the sub-agent keeps working and its
4389
4899
  // result is injected back when it finishes. No-op for other adapters and when
4390
4900
  // no sub-agent is running. Best-effort; never blocks the forward on failure.
4391
- if (adapter.detachRunningSubagents) {
4901
+ if (adapter.detachRunningSubagents && !isTechnicalPrompt) {
4392
4902
  await adapter.detachRunningSubagents(key);
4393
4903
  }
4394
4904
  adapter.sendInput(key, promptText);
@@ -4567,6 +5077,7 @@ const botCore = {
4567
5077
  getState: () => state,
4568
5078
  replyToThread,
4569
5079
  editThreadMessage,
5080
+ editThreadKeyboard,
4570
5081
  deleteThreadMessage,
4571
5082
  authoriseContext,
4572
5083
  withThreadLocale,
@@ -4575,17 +5086,25 @@ const botCore = {
4575
5086
  cancelConversationRequest,
4576
5087
  wakeSleepingSession,
4577
5088
  };
4578
- const { checkIsThreadCompacting, noteThreadActivity, noteThreadUserActivity, markThreadTurnProducedOutput, clearThreadCompaction, rearmThreadIdleTimer, armDeferredCompaction, checkIsDeferredCompactionArmed, threadsCompacting, reAskedQuestionOptions, registerCompactionCommands, registerCompactionCallbacks, } = (0, compaction_1.createCompaction)({
5089
+ const { checkIsThreadCompacting, noteThreadActivity, noteThreadUserActivity, markThreadTurnProducedOutput, clearThreadCompaction, rearmThreadIdleTimer, armDeferredCompaction, checkIsDeferredCompactionArmed, threadsCompacting, answerReAskedQuestionByNumber, reAskInterruptedQuestion, restoreReAskedQuestion, closeSavedReAskedQuestion, closeReAskedQuestion, registerCompactionCommands, registerCompactionCallbacks, } = (0, compaction_1.createCompaction)({
4579
5090
  ...botCore,
4580
5091
  startTypingLoader,
4581
- forwardPromptToAgent,
5092
+ forwardPromptToAgent: async (key, adapter, text) => { await forwardPromptToAgent(key, adapter, text); },
4582
5093
  pendingQuestions,
4583
5094
  clearPendingQuestion,
5095
+ closeQuestionMessage,
4584
5096
  idleWindowMs: (0, compactOnIdle_1.getAgentIdleMs)(process.env.AGENT_IDLE_MINUTES),
4585
5097
  checkIsLimitWaitArmed: (key) => getArmedApiRetry(key)?.kind === 'usageLimit',
4586
5098
  suspendThreadSession,
4587
5099
  // L-D7: a sleeping per-turn session is woken for its idle compaction — a resume only, never a fresh start.
4588
5100
  resumeSleepingSessionForCompaction: async (key) => (await ensureAgentSession(key, { isResumeOnly: true })).ok && (0, createAdapter_1.getThreadAdapter)(key).checkIsActive(key),
5101
+ getThreadAdapter: createAdapter_1.getThreadAdapter,
5102
+ // An answer to the agent's own question is never a request (S7), even delivered as a prompt.
5103
+ deliverLabel: (key, label) => withThreadLocale(key, () => deliverActivePrompt(key, (0, createAdapter_1.getThreadAdapter)(key), label, null)),
5104
+ resumeSleepingSessionForPrompt: (key) => resumeSleepingSessionForPrompt(key),
5105
+ checkIsStarting: (key) => startupPromptBuffer.checkIsStarting((0, sessionKey_1.keyToString)(key)),
5106
+ closeOrphanQuestionMessage,
5107
+ notePersonActivity,
4589
5108
  });
4590
5109
  /**
4591
5110
  * @description True iff the neutral router owns `name`, i.e. a `/name` message
@@ -4703,6 +5222,11 @@ command('start', async (_ctx, key) => {
4703
5222
  '/quit — quit current agent\n' +
4704
5223
  '/status — show status');
4705
5224
  });
5225
+ /** The `/status` agent line (`Claude Code (claude)`), flagged when the backend is the deprecated tmux one. */
5226
+ function formatStatusAgentLine(label, adapterName) {
5227
+ const agentLine = `${label} (${adapterName})`;
5228
+ return adapterName === adapterNames_1.claudeTmuxAdapterName ? (0, i18n_1.t)('status.thread_agentTmuxDeprecated', { agent: agentLine }) : agentLine;
5229
+ }
4706
5230
  command('status', async (_ctx, key) => {
4707
5231
  // In General the per-thread status is meaningless (no binding ever) — so
4708
5232
  // we promote /status to a workspace-wide overview there. Topical threads
@@ -4725,7 +5249,7 @@ command('status', async (_ctx, key) => {
4725
5249
  const adapter = (0, createAdapter_1.getThreadAdapter)(key);
4726
5250
  const isActive = adapter.checkIsActive(key);
4727
5251
  const adapterName = (0, createAdapter_1.getThreadAdapterNameRaw)(key);
4728
- const agentLine = adapterName ? `${adapter.label} (${adapterName})` : (0, i18n_1.t)('status.thread_no_agent');
5252
+ const agentLine = adapterName ? formatStatusAgentLine(adapter.label, adapterName) : (0, i18n_1.t)('status.thread_no_agent');
4729
5253
  const binding = state.getBinding(key);
4730
5254
  const subdir = binding?.subdir ?? (0, i18n_1.t)('status.thread_no_binding');
4731
5255
  let workDir = null;
@@ -4901,6 +5425,19 @@ command('timezone', async (_ctx, key, parsed) => {
4901
5425
  const view = buildTimezoneRegionView();
4902
5426
  await replyToThread(key, view.text, view.keyboard);
4903
5427
  });
5428
+ /**
5429
+ * @description How the welcome's shared-folder row writes `peerKey`, a topic of the SAME chat as the one being bound: by the
5430
+ * name stored on its binding, else by the link that opens it (a person can follow it; a session key means nothing to them).
5431
+ * 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
5432
+ * neither named nor linked (a thread of a private chat, say) — it is only counted, never shown as an id.
5433
+ */
5434
+ function describeSharedTopic(peerKey) {
5435
+ const name = state.getBinding(peerKey)?.topicName;
5436
+ const link = (0, topicCommand_1.buildTopicLink)({ chatId: (0, sessionKeyCodec_1.getTelegramChatId)(peerKey), username: null }, (0, sessionKeyCodec_1.getTelegramThreadId)(peerKey));
5437
+ if (name === undefined && link === null)
5438
+ return null;
5439
+ return { name, link: link ?? '' };
5440
+ }
4904
5441
  async function applyBinding(key, rawSubdir, options = {}) {
4905
5442
  let subdir;
4906
5443
  try {
@@ -4920,18 +5457,88 @@ async function applyBinding(key, rawSubdir, options = {}) {
4920
5457
  // user should know they're about to join an existing workspace rather
4921
5458
  // than start a fresh one.
4922
5459
  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 });
5460
+ const topicName = options.topicName ?? (0, pendingTopicName_1.getTrustedPendingTopicName)(pendingTopicNames.get((0, sessionKey_1.keyToString)(key)), Date.now(), PENDING_TOPIC_NAME_TTL_MS);
5461
+ await state.setBinding(key, subdir, topicName !== undefined ? { topicName } : {});
5462
+ // Bound: the set-aside name has done its part (an unbound topic is the only reader of it), and a «create new folder» wait
5463
+ // armed on a picker is over — the next plain message is nobody's folder name any more.
5464
+ const boundKey = (0, sessionKey_1.keyToString)(key);
5465
+ pendingTopicNames.delete(boundKey);
5466
+ awaitingFolderName.delete(boundKey);
5467
+ bindPickerMessageIds.delete(boundKey);
5468
+ warmTodoProjects();
5469
+ // Only topics of THIS chat are named: a member of one group is not told what another group (or a private chat) calls its
5470
+ // topics. The rest — and a topic of this chat that can be neither named nor linked — are counted.
5471
+ const sharedWith = [];
5472
+ let sharedMore = 0;
5473
+ for (const peerKey of peers) {
5474
+ const described = peerKey.space === key.space ? describeSharedTopic(peerKey) : null;
5475
+ if (described === null)
5476
+ sharedMore += 1;
5477
+ else
5478
+ sharedWith.push(described);
5479
+ }
4930
5480
  // Rebind resume (S8): a thread that was unbound paused its schedules; binding
4931
5481
  // it again (this thread, possibly a different folder) resumes them. Best
4932
5482
  // effort — a folder pick must succeed even if the resume bookkeeping hiccups.
4933
5483
  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 };
5484
+ return { ok: true, subdir, sharedWith, sharedMore };
5485
+ }
5486
+ /**
5487
+ * @description Answer a bind attempt in the topic: the refusal's message, or — once bound — the rich welcome, the one
5488
+ * message announcing the binding (with the shared-folder row when other topics use the folder, and whatever else the
5489
+ * caller knows of this bind: `notes`). Shared by `/bind <subdir>`, the `bind_<subdir>` picker button and the picker's
5490
+ * «create new folder».
5491
+ */
5492
+ async function replyToBindResult(key, result, notes = {}) {
5493
+ if (!result.ok) {
5494
+ await replyToThread(key, result.message);
5495
+ return;
5496
+ }
5497
+ await sendBindingWelcome(key, result.subdir, { ...notes, sharedWith: result.sharedWith, sharedMore: result.sharedMore });
5498
+ }
5499
+ /**
5500
+ * @description The `/bind` picker is one-shot (§ Inline keyboards): once a folder is bound through it — or the topic left its
5501
+ * folder from it — the message is deleted, since its text («not bound, pick from the list», «currently bound: …») is no
5502
+ * longer true and the welcome replaces it; a stale tap cannot rebind the topic either. A pick that bound nothing never gets
5503
+ * here, and paging or the create-folder prompt leave the picker alone. A message that stayed ({@link deleteThreadMessage}
5504
+ * resolved `false`: older than 48 hours, say) loses at least its buttons.
5505
+ */
5506
+ async function removeBindPicker(key, pickerMessageId) {
5507
+ openBindMessages.forget((0, sessionKey_1.keyToString)(key), pickerMessageId);
5508
+ // A message that is gone has no buttons to strip (and Telegram would only answer «not found»).
5509
+ if (await deleteThreadMessageDetailed(key, pickerMessageId) === 'stayed')
5510
+ await editThreadKeyboard(key, pickerMessageId, null, 'bind');
5511
+ }
5512
+ /**
5513
+ * @description Delete every message still open in the topic that offers a bind or asks for a folder's name — each `/bind`
5514
+ * picker (every plain message in an unbound topic posted another), the «not bound» replies, the «send the folder name»
5515
+ * prompt — once the topic is bound or has left its folder, whichever way that happened (a tap, a typed `/bind <folder>`, the
5516
+ * 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
5517
+ * the bot has no record of (sent before a restart) is not touched.
5518
+ *
5519
+ * Every one of them is held first ({@link BindPickerClaims.retire}, outside the limited claim list), so a tap is refused
5520
+ * 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
5521
+ * (each single delete is a paced send, and there can be dozens). A message too old for Telegram to delete loses its buttons
5522
+ * instead, and so does every one of a bulk call Telegram refused — no delete per picker — but only the newest `keyboardStripsPerSweepLimit` of them; the
5523
+ * older rest keeps its buttons, refused by the hold (`sweepOpenBindMessages`). The hold lives in memory: after a restart such a
5524
+ * picker works again. Never rejects: the callers that do not wait for it ({@link sendBindingWelcome}) have nothing to catch.
5525
+ */
5526
+ async function closeOpenBindMessages(key) {
5527
+ try {
5528
+ await (0, bindSweep_1.sweepOpenBindMessages)(openBindMessages.take((0, sessionKey_1.keyToString)(key)), Date.now(), {
5529
+ retire: (messageId) => bindPickerClaims.retire(key, messageId),
5530
+ unretire: (messageId) => bindPickerClaims.unretire(key, messageId),
5531
+ deleteMessages: (messageIds) => deleteThreadMessages(key, messageIds),
5532
+ removeKeyboard: (messageId) => editThreadKeyboard(key, messageId, null, 'bind'),
5533
+ });
5534
+ }
5535
+ catch (e) {
5536
+ console.warn(`[bind] closing the open pickers of ${(0, sessionKey_1.keyToString)(key)} failed:`, e);
5537
+ }
5538
+ }
5539
+ /** Remove the buttons of the topic's earlier welcomes (the newest few; see `stripStaleKeyboards`). Never rejects. */
5540
+ async function stripWelcomeKeyboards(key, messageIds) {
5541
+ await (0, bindSweep_1.stripStaleKeyboards)(messageIds, (messageId) => editThreadKeyboard(key, messageId, null, 'welcome'));
4935
5542
  }
4936
5543
  /**
4937
5544
  * @description Pause the AGENT-PROMPT schedules owned by `key` because its topic
@@ -5002,16 +5609,18 @@ async function resumeThreadSchedulesOnRebind(key) {
5002
5609
  * armed-mode branches in the text handler never overlap (only one armed
5003
5610
  * mode is active per thread at a time).
5004
5611
  */
5005
- function armFolderCreation(key) {
5612
+ function armFolderCreation(key, pickerMessageId) {
5006
5613
  const kStr = (0, sessionKey_1.keyToString)(key);
5007
5614
  awaitingModelSelection.delete(kStr);
5008
5615
  awaitingSessionSelection.delete(kStr);
5009
5616
  // The `/topic` prompts are other readers of the next plain message.
5010
5617
  cancelTopicInput(key);
5011
5618
  // 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);
5619
+ // folder name as an answer to the old (pre-rebind) agent question. Its message stays, closed as unanswered.
5620
+ retirePendingQuestionInBackground(key);
5014
5621
  awaitingFolderName.add(kStr);
5622
+ if (pickerMessageId !== null)
5623
+ bindPickerMessageIds.set(kStr, pickerMessageId);
5015
5624
  }
5016
5625
  /** Map a `validateNewFolderName` rejection to its localised reply. */
5017
5626
  function mapNewFolderError(reason) {
@@ -5027,10 +5636,10 @@ function mapNewFolderError(reason) {
5027
5636
  /**
5028
5637
  * @description Create a new folder under WORK_ROOT from a user-typed name:
5029
5638
  * 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`).
5639
+ * an already-existing folder is fine — the caller just uses it, and says so in its
5640
+ * own way: `alreadyExisted`). A refusal or a filesystem failure is reported to
5641
+ * `key`'s topic here; the outcome says which, so the caller knows whether a retry
5642
+ * is still wanted (`invalid`) or not (`failed`).
5034
5643
  */
5035
5644
  async function createWorkFolder(key, rawName) {
5036
5645
  const validated = (0, folderName_1.validateNewFolderName)(rawName);
@@ -5052,10 +5661,7 @@ async function createWorkFolder(key, rawName) {
5052
5661
  // Folder freshly on disk → the cached subdir list is stale; drop it so the
5053
5662
  // next picker render includes the new folder.
5054
5663
  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 };
5664
+ return { outcome: 'ready', subdir: validated.name, alreadyExisted };
5059
5665
  }
5060
5666
  /**
5061
5667
  * @description Create a new folder under WORK_ROOT from a user-typed name and
@@ -5068,21 +5674,36 @@ async function createWorkFolder(key, rawName) {
5068
5674
  * filesystem failure disarms it.
5069
5675
  */
5070
5676
  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 };
5677
+ const kStr = (0, sessionKey_1.keyToString)(key);
5678
+ // Read before the bind: a bind drops it (and the wait) itself; deleted below for a refused one, which does not.
5679
+ const pickerMessageId = bindPickerMessageIds.get(kStr) ?? null;
5680
+ let isNameRefused = false;
5681
+ // 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
5682
+ // 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
5683
+ // a name that arrives when a tap already holds the picker makes no (empty) folder — it is told so, not dropped in silence.
5684
+ const run = await bindPickerClaims.runClaimed(key, pickerMessageId, async () => {
5685
+ const folder = await createWorkFolder(key, rawName);
5686
+ if (folder.outcome === 'invalid') {
5687
+ isNameRefused = true;
5688
+ return false;
5689
+ }
5690
+ if (folder.outcome === 'failed')
5691
+ return false;
5692
+ const result = await applyBinding(key, folder.subdir);
5693
+ bindPickerMessageIds.delete(kStr);
5694
+ if (result.ok && pickerMessageId !== null)
5695
+ await removeBindPicker(key, pickerMessageId);
5696
+ await replyToBindResult(key, result, { isExistingFolder: folder.alreadyExisted });
5697
+ return result.ok;
5698
+ });
5699
+ if (run === 'busy')
5700
+ await replyToThread(key, (0, i18n_1.t)('cb.bind_picker_used'));
5701
+ return { ok: !isNameRefused };
5081
5702
  }
5082
5703
  /**
5083
5704
  * @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
5705
+ * 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
5706
+ * 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
5707
  * rides on the binding for the thread-context preamble. Resolves why it could not bind, `null` once it is bound.
5087
5708
  */
5088
5709
  async function bindNewTopic(key, subdir, topicName) {
@@ -5092,6 +5713,22 @@ async function bindNewTopic(key, subdir, topicName) {
5092
5713
  await sendBindingWelcome(key, result.subdir);
5093
5714
  return null;
5094
5715
  }
5716
+ /**
5717
+ * @description Bind a topic the bot matched to the folder `subdirName` by the topic's own name, and welcome it. It goes
5718
+ * through {@link applyBinding} like every other bind (validation, the schedules a rebind resumes, the collision lookup), so
5719
+ * 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
5720
+ * the thread-context preamble. Resolves whether the topic is bound: a refused folder is only logged, and the caller falls
5721
+ * back to the folder picker.
5722
+ */
5723
+ async function autoBindTopicByName(key, subdirName, topicName) {
5724
+ const result = await applyBinding(key, subdirName, topicName ? { topicName } : {});
5725
+ if (!result.ok) {
5726
+ console.warn(`[auto-bind] "${subdirName}" refused for ${(0, sessionKey_1.keyToString)(key)}: ${result.message}`);
5727
+ return false;
5728
+ }
5729
+ await sendBindingWelcome(key, result.subdir, { isAutoBound: true, sharedWith: result.sharedWith, sharedMore: result.sharedMore });
5730
+ return true;
5731
+ }
5095
5732
  command('bind', async (_ctx, key, parsed) => {
5096
5733
  if (checkIsGeneral(key)) {
5097
5734
  await replyToThread(key, (0, i18n_1.t)('bind.in_general'));
@@ -5118,10 +5755,7 @@ command('bind', async (_ctx, key, parsed) => {
5118
5755
  await replyToThread(key, usage, (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!binding));
5119
5756
  return;
5120
5757
  }
5121
- const result = await applyBinding(key, arg);
5122
- await replyToThread(key, result.message);
5123
- if (result.ok)
5124
- await sendBindingWelcome(key, result.subdir);
5758
+ await replyToBindResult(key, await applyBinding(key, arg));
5125
5759
  });
5126
5760
  /**
5127
5761
  * @description Unbind a thread from its folder: stop any live session, drop the
@@ -5142,6 +5776,11 @@ async function unbindThread(key) {
5142
5776
  // Order matters: drop the pin FIRST (while binding.pinnedStatusMessageId
5143
5777
  // is still readable), then stop the session, then wipe the binding.
5144
5778
  await clearPinnedStatus(key);
5779
+ // A question still on screen ends here with its «not answered» note: the stop below is reported only after the process
5780
+ // is gone (json-stream stops in the background), by which time the teardown has cleared the question's state and the
5781
+ // report finds nothing to close — the buttons would stay, dead, with no note.
5782
+ if (pendingQuestions.has(kStr))
5783
+ await retirePendingQuestion(key);
5145
5784
  // Stop any running session before discarding the binding so we don't leave
5146
5785
  // an orphan tmux/SSE stream pointing at a directory we no longer track.
5147
5786
  const adapter = (0, createAdapter_1.getThreadAdapter)(key);
@@ -5160,7 +5799,7 @@ async function unbindThread(key) {
5160
5799
  }
5161
5800
  // Release persisted session ids too — otherwise re-binding this thread
5162
5801
  // later + a bot restart would resurrect the old session the user unbound.
5163
- await state.clearAgentSessionIds(key);
5802
+ await releaseConversationIds(key);
5164
5803
  // Pause this thread's schedules BEFORE dropping the binding: a fire against
5165
5804
  // an unbound topic can't deliver, so the jobs park (paused + disarmed) and
5166
5805
  // the next /bind resumes them. `removeBinding` only touches bindings/agents/
@@ -5247,7 +5886,7 @@ command('list', async (_ctx, key) => {
5247
5886
  await replyToThread(key, `${(0, i18n_1.t)('list.header', { count: rows.length })}\n${body}`, { parse_mode: 'Markdown' });
5248
5887
  });
5249
5888
  /**
5250
- * @description `/new` (alias `/clear_session`) — stop the thread's current agent
5889
+ * @description `/new` — stop the thread's current agent
5251
5890
  * session and immediately start a fresh one in the SAME topic with the SAME
5252
5891
  * adapter. The old session is RELEASED (not deleted): its transcript stays on
5253
5892
  * disk so it's still resumable via `/sessions`, but a bot restart won't
@@ -5257,7 +5896,7 @@ command('list', async (_ctx, key) => {
5257
5896
  * Guards mirror `handleStartCommand`: General has no binding/agent, so it just
5258
5897
  * hints; an unbound topic gets the standard bind-required reply.
5259
5898
  */
5260
- command(['new', 'clear_session'], async (_ctx, key) => {
5899
+ command('new', async (_ctx, key) => {
5261
5900
  if (checkIsGeneral(key)) {
5262
5901
  await replyToThread(key, (0, i18n_1.t)('new.general_hint'));
5263
5902
  return;
@@ -5480,11 +6119,15 @@ function buildStartAgentKeyboard() {
5480
6119
  }
5481
6120
  /**
5482
6121
  * @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) {
6122
+ * plan §20.5: header with the bound subdir, the optional rows (why it was bound
6123
+ * when the bot did it by itself, that the folder was there already, CLAUDE.md /
6124
+ * .mcp.json / git, which other topics use the folder), and an inline keyboard to start an agent. It is the ONE
6125
+ * message every way of binding leaves in the topic. Falls back gracefully when
6126
+ * stats can't be probed.
6127
+ */
6128
+ async function sendBindingWelcome(key, subdir, notes = {}) {
6129
+ // The welcomes this topic got before are outdated the moment this one goes out: their buttons would act on the CURRENT folder.
6130
+ const outdatedWelcomeIds = openWelcomeMessages.take((0, sessionKey_1.keyToString)(key)).map((entry) => entry.messageId);
5488
6131
  // Pin the status banner first so the user sees it land near the top of
5489
6132
  // the thread before the slower stats probe finishes. Fire-and-forget —
5490
6133
  // a failed pin (missing permissions, closed topic) just logs a warning
@@ -5499,6 +6142,10 @@ async function sendBindingWelcome(key, subdir) {
5499
6142
  stats = { claudeMdSize: null, mcpServerCount: null, gitBranch: null, gitClean: null };
5500
6143
  }
5501
6144
  const lines = [(0, i18n_1.t)('binding.welcome.header', { subdir })];
6145
+ if (notes.isAutoBound)
6146
+ lines.push((0, i18n_1.t)('binding.welcome.auto_bound'));
6147
+ if (notes.isExistingFolder)
6148
+ lines.push((0, i18n_1.t)('binding.welcome.existed'));
5502
6149
  if (stats.claudeMdSize)
5503
6150
  lines.push((0, i18n_1.t)('binding.welcome.claude_md', { size: stats.claudeMdSize }));
5504
6151
  if (stats.mcpServerCount !== null) {
@@ -5513,12 +6160,23 @@ async function sendBindingWelcome(key, subdir) {
5513
6160
  else if (fs.existsSync(path.join(workDir, '.git')) === false) {
5514
6161
  lines.push((0, i18n_1.t)('binding.welcome.git_none'));
5515
6162
  }
6163
+ const sharedMore = notes.sharedMore ?? 0;
6164
+ if ((notes.sharedWith && notes.sharedWith.length > 0) || sharedMore > 0) {
6165
+ const moreLabel = sharedMore > 0 ? (0, i18n_1.t)('binding.welcome.sharedMore', { count: sharedMore }) : null;
6166
+ lines.push((0, i18n_1.t)('binding.welcome.shared', { threads: (0, bindingWelcome_1.formatSharedTopicList)(notes.sharedWith ?? [], moreLabel) }));
6167
+ }
5516
6168
  lines.push('');
5517
6169
  lines.push((0, i18n_1.t)('binding.welcome.start_prompt'));
5518
- await replyToThread(key, lines.join('\n'), {
6170
+ const welcomeMessageId = await replyToThread(key, lines.join('\n'), {
5519
6171
  parse_mode: 'Markdown',
5520
6172
  ...buildStartAgentKeyboard(),
5521
6173
  });
6174
+ if (welcomeMessageId !== null)
6175
+ openWelcomeMessages.remember((0, sessionKey_1.keyToString)(key), welcomeMessageId, Date.now());
6176
+ // Every bind ends in this welcome, so every bind sweeps here — after the welcome, and NOT waited for: what follows a bind
6177
+ // (an agent start, when the first message was a start phrase) must not hang on the deletions and edits, which are paced sends.
6178
+ void stripWelcomeKeyboards(key, outdatedWelcomeIds);
6179
+ void closeOpenBindMessages(key);
5522
6180
  }
5523
6181
  // `open_sessions` callback wires the [📋 Resume…] button to the `/sessions`
5524
6182
  // flow — same `handleSessionsList` helper the slash command calls into, so
@@ -5740,10 +6398,9 @@ async function handleAgentStart(key, adapterName, args) {
5740
6398
  await replyToThread(key, (0, i18n_1.t)('thread.no_binding'), extra);
5741
6399
  return;
5742
6400
  }
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);
6401
+ // "Claude Code" is one user-facing choice with several backends (json-stream,
6402
+ // per-turn, the deprecated tmux-scrape); open the thread's picked backend, else the default.
6403
+ adapterName = (0, createAdapter_1.resolveStartAdapterName)(key, adapterName);
5747
6404
  // The other backend, with a conversation to hand over: ask first; the answer makes the switch.
5748
6405
  const targetFamily = (0, backendSwitchPlan_1.getBackendFamily)(adapterName);
5749
6406
  if (targetFamily !== null && await askBeforeCrossBackendSwitch(key, targetFamily, args))
@@ -5785,19 +6442,32 @@ command('claude', (_ctx, key, parsed) => handleStartCommand(parsed, key, 'claude
5785
6442
  command(['opencode', 'oc'], (_ctx, key, parsed) => handleStartCommand(parsed, key, 'opencode'));
5786
6443
  command('terminal', (_ctx, key, parsed) => handleStartCommand(parsed, key, 'terminal'));
5787
6444
  registerProviderCommands();
5788
- /** Human label for a Claude backend name (the two adapters share `label`
6445
+ /** Human label for a Claude backend name (the adapters share `label`
5789
6446
  * "Claude Code", so the picker/notices need a distinguishing name). */
5790
6447
  function getClaudeBackendLabel(name) {
5791
6448
  if (name === adapterNames_1.claudeJsonStreamAdapterName)
5792
6449
  return '⚡ JSON-stream';
5793
6450
  if (name === adapterNames_1.claudePerTurnAdapterName)
5794
6451
  return '🔁 Per-turn';
5795
- return '🖥 Terminal-scrape';
6452
+ return '🖥 tmux';
6453
+ }
6454
+ /** The `/claude_mode` button text of a backend: its label, the deprecated tmux backend marked as such. A notice
6455
+ * keeps the plain label instead — the deprecation hint under it already says it. */
6456
+ function getClaudeBackendButtonLabel(name) {
6457
+ return name === adapterNames_1.claudeTmuxAdapterName ? (0, i18n_1.t)('claudeMode.tmuxButtonLabel') : getClaudeBackendLabel(name);
6458
+ }
6459
+ /** The one-line hint that the tmux backend is deprecated and which backend to use instead. */
6460
+ function getTmuxDeprecatedHint() {
6461
+ return (0, i18n_1.t)('claudeMode.tmuxDeprecatedHint', { recommended: getClaudeBackendLabel((0, createAdapter_1.getDefaultClaudeBackendName)()) });
6462
+ }
6463
+ /** `text` with the tmux deprecation hint under it when `backendName` is the tmux backend; unchanged otherwise. */
6464
+ function appendTmuxDeprecatedHint(backendName, text) {
6465
+ return backendName === adapterNames_1.claudeTmuxAdapterName ? `${text}\n${getTmuxDeprecatedHint()}` : text;
5796
6466
  }
5797
6467
  /** Build the `/claude_mode` picker: one button per Claude backend, `✓` on the
5798
6468
  * current one. Callback data `ccmode_<adapterName>`. */
5799
6469
  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}`));
6470
+ 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
6471
  return telegraf_1.Markup.inlineKeyboard(buttons, { columns: 1 });
5802
6472
  }
5803
6473
  /**
@@ -5864,14 +6534,14 @@ command('claude_mode', async (_ctx, key, parsed) => {
5864
6534
  return;
5865
6535
  }
5866
6536
  if (action.kind === 'already') {
5867
- await replyToThread(key, (0, i18n_1.t)('claudeMode.already', { label: getClaudeBackendLabel(action.backendName) }));
6537
+ await replyToThread(key, appendTmuxDeprecatedHint(action.backendName, (0, i18n_1.t)('claudeMode.already', { label: getClaudeBackendLabel(action.backendName) })));
5868
6538
  return;
5869
6539
  }
5870
6540
  if (action.kind === 'switch') {
5871
- await replyToThread(key, await applyClaudeBackendSwitch(key, action.backendName));
6541
+ await replyToThread(key, appendTmuxDeprecatedHint(action.backendName, await applyClaudeBackendSwitch(key, action.backendName)));
5872
6542
  return;
5873
6543
  }
5874
- await replyToThread(key, (0, i18n_1.t)('claudeMode.pick', { label: getClaudeBackendLabel(action.currentBackendName) }), buildClaudeModeKeyboard(action.currentBackendName));
6544
+ await replyToThread(key, (0, i18n_1.t)('claudeMode.pick', { label: getClaudeBackendLabel(action.currentBackendName), hint: getTmuxDeprecatedHint() }), buildClaudeModeKeyboard(action.currentBackendName));
5875
6545
  });
5876
6546
  registerModelCommands();
5877
6547
  const { registerDisplayModeCommands, registerDisplayModeCallbacks, } = (0, displayModes_1.createDisplayModes)(botCore);
@@ -5909,6 +6579,44 @@ const { applyAutoContinueLimits, consumeAutoContinueLimitsPicker, registerAutoCo
5909
6579
  getRequestWakeUpEngine: () => requestWakeUpEngine,
5910
6580
  });
5911
6581
  registerAutoContinueLimitsCommands();
6582
+ // ── «Bot skills» switch (`/bot_skills`): `connectors/telegram/commands/botSkills.ts`, `utils/botSkills.ts` ──
6583
+ const { registerBotSkillsCommands, registerBotSkillsCallbacks } = (0, botSkills_2.createBotSkills)({
6584
+ ...botCore,
6585
+ readBotSkills: botSkillsReader,
6586
+ readBotSkillsInstanceValue: () => (0, botSkills_1.readBotSkillsInstanceValue)(botSkillsSources),
6587
+ checkIsOpenCodeTopic: (key) => ((0, createAdapter_1.getThreadAdapterNameRaw)(key) ?? state.getAgent(key)?.name) === adapterNames_1.openCodeAdapterName,
6588
+ });
6589
+ registerBotSkillsCommands();
6590
+ // ── The cap on tracker task agents (`/task_agents`): `connectors/telegram/commands/taskAgents.ts`, `trackers/` ──
6591
+ const { registerTaskAgentsCommands, registerTaskAgentsCallbacks } = (0, taskAgents_1.createTaskAgents)({
6592
+ ...botCore,
6593
+ readTaskAgentLimit: () => (0, taskAgentLimit_1.readTaskAgentLimit)(taskAgentLimitSources),
6594
+ // A General value just written may raise the cap: the waiting issues start at once.
6595
+ noteTaskAgentLimitChanged: () => taskAgentSlots?.noteLimitMayHaveChanged(),
6596
+ // The slots decide nothing on an instance that serves no tracker: its screen says nothing of them.
6597
+ readTaskAgentStatus: () => ([...ENV.servedPlatforms].some((platform) => platform !== 'telegram') ? taskAgentSlots?.getStatus() ?? null : null),
6598
+ });
6599
+ registerTaskAgentsCommands();
6600
+ // ── Paired runners (`/runners`): `connectors/telegram/commands/runners.ts`, the hub in `runners/` ──
6601
+ const runnersHub = (0, hubPaths_1.createHubContext)();
6602
+ const runnersCacheDir = (0, ssh_1.getRemoteRunCacheDir)();
6603
+ const { registerRunnersCommands, registerRunnersCallbacks, handleRunnersAddressText, cancelRunnersAddressWait, startRunnersWatcher } = (0, runners_1.createRunners)({
6604
+ ...botCore,
6605
+ hub: runnersHub,
6606
+ // The pairing messages this instance sent: its own record (the hub registry is shared by every instance of the OS user).
6607
+ pairingMessagesPath: path.join((0, state_1.resolveDataDir)(), 'runner-pairing-messages.json'),
6608
+ sweepExpiredCodes: async (nowMs) => { await (0, hubPairing_1.sweepExpiredPairings)(runnersHub, nowMs); },
6609
+ readRunnerHost: (0, instanceSettings_1.createRunnerHostReader)(() => (0, instanceSettings_1.getInstanceSettingsPath)(), (line) => console.error(line)),
6610
+ saveRunnerHost: (address) => (0, instanceSettings_1.saveInstanceSettingsChange)((0, instanceSettings_1.getInstanceSettingsPath)(), { runnerHost: address }),
6611
+ getBotLabel: () => (bot.botInfo?.username === undefined ? os.hostname() : `@${bot.botInfo.username}`),
6612
+ readOverview: (nowMs) => (0, runnersOverview_1.readRunnersOverview)(runnersHub, runnersCacheDir, nowMs),
6613
+ probeOverview: (overview, nowMs) => (0, runnersOverview_1.probeRunnersOverview)(runnersHub, overview, runnersCacheDir, nowMs),
6614
+ listAddressCandidates: () => (0, hubAddressCandidates_1.listPublicAddressCandidates)(),
6615
+ scanHostKey: hubAddress_1.scanHostKey,
6616
+ ensureGateWrapper: () => { (0, gateWrapper_1.ensureGateWrapper)(runnersHub); },
6617
+ now: Date.now,
6618
+ });
6619
+ registerRunnersCommands();
5912
6620
  // ── Claude account profiles (`/account`): `utils/claudeProfiles.ts`, `commands/claudeAccounts.ts` ──
5913
6621
  /**
5914
6622
  * @description The Claude config directory a conversation's NEXT process starts with: its resolved account
@@ -5941,7 +6649,13 @@ async function readClaudeAuthStatusUnder(configDir) {
5941
6649
  }
5942
6650
  catch (e) {
5943
6651
  // 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));
6652
+ const status = (0, claudeProfiles_1.parseClaudeAuthStatus)((0, claudeProfiles_1.readProcessErrorStdout)(e));
6653
+ // No answer at all (a probe killed by its timeout on a loaded host, a CLI that did not start): one line, so a
6654
+ // missing status can be told from a signed-out one.
6655
+ if (status === null) {
6656
+ console.log(`[accounts] the auth status probe under ${configDir ?? 'the default directory'} gave no status: ${(0, claudeProfiles_1.getProcessFailureReason)(e, claudeAuthStatusProbeTimeoutMs)}`);
6657
+ }
6658
+ return status;
5945
6659
  }
5946
6660
  }
5947
6661
  const claudeAuthStatusProbeTimeoutMs = 5000;
@@ -6153,12 +6867,39 @@ function getTopicFallbackPosition(key) {
6153
6867
  const parsed = label ? (0, fallbackChain_1.parseOpenCodeModelLabel)(label) : null;
6154
6868
  return parsed ? { backend: 'opencode', ...parsed } : null;
6155
6869
  }
6870
+ /**
6871
+ * @description Whether the account or model `key` runs on is marked limited now, and whether it is billed to API
6872
+ * credits — what the todo list's standalone reminder never fires into (D12).
6873
+ */
6874
+ function getConversationAccountState(key) {
6875
+ const position = getTopicFallbackPosition(key);
6876
+ if (position === null)
6877
+ return { isLimited: false, isApiBilled: false };
6878
+ const nowMs = Date.now();
6879
+ const checkIsFuture = (instant) => instant !== undefined && Date.parse(instant) > nowMs;
6880
+ if (position.backend === 'opencode')
6881
+ return { isLimited: checkIsFuture(state.getOpenCodeModelLimitedUntil((0, fallbackChain_1.buildOpenCodeModelLabel)(position))), isApiBilled: false };
6882
+ const info = state.getClaudeProfileInfo(position.profile);
6883
+ return { isLimited: checkIsFuture(info?.limitedUntil) || info?.creditExhaustedAt !== undefined, isApiBilled: info?.billing === 'api' };
6884
+ }
6885
+ /**
6886
+ * @description A person wrote in the conversation (a message, voice, file, album; an issue's request): the
6887
+ * compact-on-idle latch clears (D2), the todo reminder's silence after ignored reminders ends (D13), and a question the
6888
+ * idle compaction re-asked is closed as unanswered — they wrote something else instead of tapping it, like a live
6889
+ * question a message cancels, and its buttons must not be tapped later onto whatever the agent asks next. A tap on the
6890
+ * re-asked question takes its record before it gets here, so it is not closed by its own answer.
6891
+ */
6892
+ function notePersonActivity(key) {
6893
+ noteThreadUserActivity(key);
6894
+ getTodoRegistry().notePersonActivity(key);
6895
+ closeReAskedQuestion(key);
6896
+ }
6156
6897
  /**
6157
6898
  * @description What a usage limit on `key` leads to (`utils/fallbackChain.ts`): the rung the topic is on is
6158
6899
  * marked limited until the reset the error named (a Claude profile: else its session window, else the retry's
6159
6900
  * blind delay; an OpenCode model: else the blind delay), the usage snapshots are brought up to date, and the
6160
6901
  * 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
6902
+ * the within-Claude ranking by the soonest weekly reset (`utils/claudeLimitSwitch.ts`) — the behaviour before the chain existed. An
6162
6903
  * offer is remembered per thread under its episode so only ITS buttons can act on it. `none` for a non-Telegram
6163
6904
  * conversation, a topic with no account to switch from, or when no rung can take over.
6164
6905
  */
@@ -6261,35 +7002,58 @@ function getBalanceSpentLine(from) {
6261
7002
  function readRunningModelBeforeMove(key, from, to) {
6262
7003
  return from?.backend === 'claude' && to.backend === 'claude' ? getThreadClaudeModel(key) : null;
6263
7004
  }
7005
+ /** 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). */
7006
+ function getConversationSource(key) {
7007
+ const adapter = (0, createAdapter_1.getThreadAdapter)(key);
7008
+ const decision = getWorkDirStartDecision(key);
7009
+ const sessionId = (0, ensureSessionPlan_1.getPersistedSessionIdForAdapter)(state.getAgent(key) ?? null, adapter.name, (0, createAdapter_1.checkIsClaudeBackend)(adapter.name));
7010
+ return { adapter, workDir: decision.ok ? decision.workDir : null, sessionId };
7011
+ }
6264
7012
  /**
6265
7013
  * @description The last `limit` turns (oldest first) of the conversation the topic's agent holds, off its own
6266
7014
  * transcript. `[]` when it has none yet (no session id persisted); `null` when they cannot be read (no bound
6267
7015
  * folder, an adapter without a transcript reader, a read that failed).
6268
7016
  */
6269
7017
  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));
7018
+ const { adapter, workDir, sessionId } = getConversationSource(key);
6273
7019
  if (!sessionId)
6274
7020
  return [];
6275
- if (!decision.ok || !adapter.getRecentTurns)
7021
+ if (workDir === null || !adapter.getRecentTurns)
6276
7022
  return null;
6277
7023
  try {
6278
- return await adapter.getRecentTurns(key, decision.workDir, sessionId, limit);
7024
+ return await adapter.getRecentTurns(key, workDir, sessionId, limit);
6279
7025
  }
6280
7026
  catch (e) {
6281
7027
  console.warn(`[fallback] ${(0, sessionKey_1.keyToString)(key)}: the previous agent's turns are unreadable:`, e instanceof Error ? e.message : e);
6282
7028
  return null;
6283
7029
  }
6284
7030
  }
7031
+ /**
7032
+ * @description The summary the topic's agent's own compaction left in its conversation (`AgentAdapter.getSessionCompactionSummary`),
7033
+ * for the context bridge to hand on beside the turns; `null` when it has none, the backend keeps none, or it cannot be read.
7034
+ */
7035
+ async function readConversationCompactionSummary(key) {
7036
+ const { adapter, workDir, sessionId } = getConversationSource(key);
7037
+ if (!sessionId || workDir === null || !adapter.getSessionCompactionSummary)
7038
+ return null;
7039
+ try {
7040
+ return await adapter.getSessionCompactionSummary(key, workDir, sessionId);
7041
+ }
7042
+ catch (e) {
7043
+ console.warn(`[fallback] ${(0, sessionKey_1.keyToString)(key)}: the previous agent's compaction summary is unreadable:`, e instanceof Error ? e.message : e);
7044
+ return null;
7045
+ }
7046
+ }
6285
7047
  /**
6286
7048
  * @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).
7049
+ * adapter is stopped: its last turns off its own transcript (an unreadable one yields a bridge without turns) and the
7050
+ * summary of its own earlier compaction, if any.
6288
7051
  * `reason` is why the agent changes — a usage limit or the operator's own switch.
6289
7052
  */
6290
7053
  async function buildFallbackBridge(key, input) {
6291
7054
  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)()) });
7055
+ const compactionSummary = await readConversationCompactionSummary(key);
7056
+ return (0, fallbackBridge_1.buildFallbackBridgePrompt)({ ...input, turns, compactionSummary, replyLanguage: (0, i18n_1.getLocaleEndonym)((0, i18n_1.getActiveLang)()) });
6293
7057
  }
6294
7058
  /** The backend the topic's agent runs on, by name (`Claude` / `OpenCode`), for a label whose rung is unknown. */
6295
7059
  function formatThreadBackend(key) {
@@ -6372,19 +7136,31 @@ async function switchTopicToFallbackEntry(key, from, to, options) {
6372
7136
  }
6373
7137
  firstPrompt = { text: bridge, isRequestPrompt: false };
6374
7138
  }
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 };
7139
+ // From the end of the wait until the continue prompt is forwarded, the idle the limit left behind is not a turn end (the
7140
+ // same hold a retry kick has): the model pin below re-spawns the session for seconds, and a wake-up engine that polled
7141
+ // 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.
7142
+ const isHoldingTurnEnd = !apiRetryKicksInFlight.has(k);
7143
+ if (isHoldingTurnEnd)
7144
+ apiRetryKicksInFlight.add(k);
7145
+ try {
7146
+ cancelApiRetry(key); // retires the offer too: the switch consumed it
7147
+ const notice = `${getFallbackSwitchedNotice(from, to, runningModel)}${isStoppingBackground ? `\n${(0, i18n_1.t)('fallback.backgroundStopped')}` : ''}`;
7148
+ console.log(`[fallback] ${k}: switched from ${fromLabel} to ${toLabel} after a usage limit${firstPrompt ? ' (cross-backend, bridged)' : ''}; resuming`);
7149
+ await say(notice);
7150
+ await resumeTopicOnNewSession(key, {
7151
+ modelToPin: (0, fallbackChain_1.getFallbackModelToPin)(to, runningModel),
7152
+ // Read after the session is up: the open requests may have changed while it started.
7153
+ getFirstPrompt: () => firstPrompt ?? (0, limitHeldPrompts_1.getLimitResumeMessage)({
7154
+ continueNudge: (0, i18n_1.t)('apiRetry.continueNudge'),
7155
+ untakenRequestPrompt: (0, requestHeader_1.joinPromptsNotTakenIn)(requestLimitWaitAnswerDeps?.ledger.listOpenRequestsOf(key) ?? []),
7156
+ }),
7157
+ });
7158
+ return { isSwitched: true, notice };
7159
+ }
7160
+ finally {
7161
+ if (isHoldingTurnEnd)
7162
+ apiRetryKicksInFlight.delete(k);
7163
+ }
6388
7164
  }
6389
7165
  /**
6390
7166
  * @description The move ACROSS backends both the usage-limit switch and the operator's manual switch make, first
@@ -6611,10 +7387,14 @@ async function takeFallbackSwitchOffer(key, episodeId, index) {
6611
7387
  const { notice } = await switchTopicToFallbackEntry(key, offer.from, decision.to, { isAnnounced: false });
6612
7388
  return notice;
6613
7389
  }
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());
7390
+ /**
7391
+ * The `claude_profiles` MCP tool's answer: snapshots refreshed on demand (a minute's cache; the account a resumed session
7392
+ * 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.
7393
+ * The accounts the operator switched off in the `/limits` list are never picked for a new agent.
7394
+ */
7395
+ async function listClaudeProfilesForAgent(query) {
7396
+ await (0, claudeUsageRefresher_1.refreshClaudeUsageForLaunch)(claudeUsageRefresher, { currentProfile: query.currentProfile, isStoppedByLimit: query.isStoppedByLimit, maxAgeMs: claudeUsageRefresher_1.claudeUsageOnDemandMaxAgeMs });
7397
+ 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
7398
  }
6619
7399
  const { registerFallbackChainCommands, registerFallbackChainCallbacks, openAutoSwitchView, runAutoSwitchCommand, applyAutoSwitch } = (0, fallbackChain_2.createFallbackChain)({
6620
7400
  ...botCore,
@@ -6626,6 +7406,15 @@ const { registerFallbackChainCommands, registerFallbackChainCallbacks, openAutoS
6626
7406
  getTopicPosition: getTopicFallbackPosition,
6627
7407
  takeSwitchOffer: takeFallbackSwitchOffer,
6628
7408
  });
7409
+ const { registerTodoCommands, registerTodoCallbacks } = (0, todo_1.createTodo)({
7410
+ ...botCore,
7411
+ getTodoRegistry,
7412
+ readTodoSettings,
7413
+ getSettingsPath: () => (0, instanceSettings_1.getInstanceSettingsPath)(),
7414
+ listTodoProjects,
7415
+ getConversationLabel: getTodoConversationLabel,
7416
+ checkIsConversationAsleep: (conversationKey) => getTodoSessionActivity(conversationKey) === 'asleep',
7417
+ });
6629
7418
  const { registerLimitsBoardCommands, registerLimitsBoardCallbacks } = (0, limitsBoard_1.createLimitsBoard)({
6630
7419
  ...botCore,
6631
7420
  getInstanceTimezone: getCurrentTimezone,
@@ -6685,6 +7474,7 @@ registerClaudeAccountCommands();
6685
7474
  registerTopicCommand();
6686
7475
  registerFallbackChainCommands();
6687
7476
  registerLimitsBoardCommands();
7477
+ registerTodoCommands();
6688
7478
  registerSttCommands();
6689
7479
  const { registerSelfUpdateCommands, registerSelfUpdateCallbacks } = (0, selfUpdate_1.createSelfUpdate)({
6690
7480
  ...botCore,
@@ -6801,8 +7591,10 @@ async function resumeSessionByIndex(key, idx, onExpired) {
6801
7591
  // The ONLY resume path that posts the "last N messages" context block —
6802
7592
  // silent re-attach (bot restart) and crash recovery must stay quiet.
6803
7593
  await adapter.resumeSession(key, workDir, sessionId, { isWithRecentContext: true });
6804
- // Another session took over this topic; it does not know the open request.
7594
+ // Another session took over this topic; it does not know the open request, and the question the idle compaction re-asked
7595
+ // for the conversation it replaces would send its label into the picked one.
6805
7596
  cancelConversationRequest(key);
7597
+ closeReAskedQuestion(key);
6806
7598
  // Persist the PICKED id — without this the next restart re-attaches to
6807
7599
  // whatever id the last fresh start wrote, silently dropping the user's
6808
7600
  // pick (live incident 2026-06-10).
@@ -6819,7 +7611,7 @@ async function resumeSessionByIndex(key, idx, onExpired) {
6819
7611
  }
6820
7612
  command(['sessions', 'resume'], (_ctx, key) => handleSessionsList(key));
6821
7613
  /**
6822
- * @description `/quit-all` — kill every active agent across every thread.
7614
+ * @description `/quitall` — kill every active agent across every thread.
6823
7615
  *
6824
7616
  * General-only because it's a workspace-wide operation that's easy to
6825
7617
  * fire by mistake; restricting to General + needing an explicit command
@@ -6832,7 +7624,7 @@ command(['sessions', 'resume'], (_ctx, key) => handleSessionsList(key));
6832
7624
  * adapter that thread is configured for and stop only if it's active —
6833
7625
  * idempotent, so re-running is safe.
6834
7626
  */
6835
- command(['quit-all', 'quitall'], async (_ctx, key) => {
7627
+ command('quitall', async (_ctx, key) => {
6836
7628
  if (!checkIsGeneral(key)) {
6837
7629
  await replyToThread(key, (0, i18n_1.t)('quit_all.general_only'));
6838
7630
  return;
@@ -6852,7 +7644,7 @@ command(['quit-all', 'quitall'], async (_ctx, key) => {
6852
7644
  active += result.attempted;
6853
7645
  stopped += result.stopped.length;
6854
7646
  // Same release-for-good semantics as `/quit`, per swept thread.
6855
- await state.clearAgentSessionIds(bKey);
7647
+ await releaseConversationIds(bKey);
6856
7648
  }
6857
7649
  if (active === 0) {
6858
7650
  await replyToThread(key, (0, i18n_1.t)('quit_all.none_active'));
@@ -6907,7 +7699,7 @@ command(['quit', 'q'], async (_ctx, key) => {
6907
7699
  if (!primaryActive) {
6908
7700
  // A sleeping conversation is released for good: the next message starts a fresh one.
6909
7701
  if (getResumableSessionId(key) !== null) {
6910
- await state.clearAgentSessionIds(key);
7702
+ await releaseConversationIds(key);
6911
7703
  await replyToThread(key, (0, i18n_1.t)('agent.sleeping_released'));
6912
7704
  return;
6913
7705
  }
@@ -6928,8 +7720,14 @@ command(['quit', 'q'], async (_ctx, key) => {
6928
7720
  await replyToThread(key, (0, i18n_1.t)('agent.exit_signal_sent', { label: adapter.label }));
6929
7721
  return;
6930
7722
  }
6931
- // OpenCode, terminal, any future adapter: tear down via stopSession.
6932
- adapter.stopSession(key);
7723
+ // OpenCode, terminal, any future adapter: tear down via stopSession. «Stopped» is said once it is: a `/claude` sent on the
7724
+ // reply must find the session gone, not «already running» — as one transition, so a start or resume waits behind it.
7725
+ await sessionTransitions.run((0, sessionKey_1.keyToString)(key), async () => {
7726
+ if (adapter.stopSessionAndWait)
7727
+ await adapter.stopSessionAndWait(key);
7728
+ else
7729
+ adapter.stopSession(key);
7730
+ });
6933
7731
  // Explicit quit releases the session for good — no auto-reattach later.
6934
7732
  await state.clearAgentSessionIds(key);
6935
7733
  await replyToThread(key, (0, i18n_1.t)('agent.stopped', { label: adapter.label }));
@@ -7198,8 +7996,8 @@ command('schedule', async (ctx, key, parsed) => {
7198
7996
  await replyToThread(key, result.message);
7199
7997
  const isStarting = startupPromptBuffer.checkIsStarting((0, sessionKey_1.keyToString)(key));
7200
7998
  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);
7999
+ const delivery = await deliverPromptOrBuffer(key, `${opening.header}${wrappedPrompt}`, isStarting);
8000
+ await settleTopicRequestAfterDelivery(key, opening, delivery);
7203
8001
  });
7204
8002
  const reminderWizards = new Map();
7205
8003
  const { cancelReminderWizard, claimReminderTextCapture, expireReminderWizard, finishReminderWizard, registerReminderCommands, registerReminderCallbacks, } = (0, reminders_1.createReminders)({
@@ -7278,16 +8076,18 @@ command('clear_messages', async (ctx, key) => {
7278
8076
  // ═══════════════════════════════════════════════════════════════════════════════
7279
8077
  // Slash tokens the text handler must NOT forward to the agent. Covers every
7280
8078
  // 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.
8079
+ // PLUS the retired commands (agent/cancel/unbind/where/stop/stopall/stop-all)
8080
+ // and the spelling `quit-all`: their handlers were removed (`quit-all` never
8081
+ // worked — Telegram ends a command at the hyphen, so a typed `/quit-all` is
8082
+ // routed as `/quit`), but keeping the tokens here makes a stray `/where` (or
8083
+ // `/stop`, or an entity-less `/quit-all`) inert instead of typing a meaningless
8084
+ // prompt into the agent.
7285
8085
  const botCommands = new Set([
7286
8086
  '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',
7288
- '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',
8087
+ 'disconnect', 'stop', 'stopall', 'stop-all', 'quit-all', 'status', 'c', 'y', 'n', 'enter', 'up', 'down', 'tab', 'esc', 'escape', 'interrupt', 'output', 'clear_messages',
8088
+ 'bind', 'topic', 'unbind', 'where', 'ls', 'list', 'new', 'whoami', 'version', 'help', 'language', 'lang',
8089
+ 'doctor', 'mcp', 'rename_session', 'compact', 'compact_on_idle', 'compact_summary', 'bot_skills', 'task_agents', 'runners', 'auto_continue_limits', 'limits', 'limits_autoswitch', 'trace', 'timestamps', 'timezone', 'schedule', 'reminders', 'todo', 'thinking', 'tool_results',
8090
+ 'subagent', 'claude_mode', 'effort', 'verbosity', 'quit', 'q', 'quitall', 'pair', 'account', 'accounts', 'fallback', 'stt', 'voice', 'speech', 'update',
7291
8091
  ]);
7292
8092
  /**
7293
8093
  * @description The bare `/clear` is no longer bot-owned (it was renamed to
@@ -7323,6 +8123,8 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
7323
8123
  cancelTopicInput(key);
7324
8124
  // ... and an armed `/stt` address or key step.
7325
8125
  await cancelSttInput(key);
8126
+ // ... and the `/runners` address step.
8127
+ cancelRunnersAddressWait(key);
7326
8128
  if (botCommands.has(cmd))
7327
8129
  return;
7328
8130
  // `/login` on a json-stream Claude thread (idle or per-turn lifecycle) has no
@@ -7426,12 +8228,18 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
7426
8228
  return;
7427
8229
  }
7428
8230
  }
8231
+ // `/runners` → «Enter another address»: this General message is the address runners dial. A slash command wins over the wait.
8232
+ if (await handleRunnersAddressText(key, text))
8233
+ return;
7429
8234
  // Session is mid-startup → buffer the prompt and replay it once the agent is
7430
8235
  // ready, instead of dropping it into the "no agent running" guidance below.
7431
8236
  if (startupPromptBuffer.checkIsStarting(kStr)) {
7432
8237
  await bufferPromptDuringStartup(key, text, { source: 'text', requesterId: (0, topicRequest_1.getTopicRequesterId)(ctx.from) }, getReplyQuoteBlock(key, ctx.message));
7433
8238
  return;
7434
8239
  }
8240
+ // Set when this very message bound the topic: its welcome is already the answer, so the idle-thread hint at the end
8241
+ // stays out — but the message itself is still read as what it is (a start phrase starts the agent).
8242
+ let isJustAutoBound = false;
7435
8243
  // Audit S2 / #5: if `forum_topic_created` was emitted by a non-allowed
7436
8244
  // admin, we stashed the topic name in `pendingTopicNames` without
7437
8245
  // binding. Now that an allowed user is engaging this thread, retry the
@@ -7441,29 +8249,28 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
7441
8249
  // explicitly chose to create a new folder, so this message is the name —
7442
8250
  // don't let a stale topic-name cache hijack it into a different bind.
7443
8251
  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) {
8252
+ const pendingEntry = pendingTopicNames.get(kStr);
8253
+ const pending = (0, pendingTopicName_1.getFreshPendingTopicName)(pendingEntry, Date.now(), PENDING_TOPIC_NAME_TTL_MS);
8254
+ if (pending) {
7446
8255
  const match = (0, validation_1.findAutobindSubdir)(pending.name, listAvailableSubdirs(ENV.workRoot));
7447
8256
  if (match) {
7448
8257
  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
8258
  pendingTopicNames.delete(kStr);
7454
- await replyToThread(key, (0, i18n_1.t)('thread.welcome_bound', { subdir }));
7455
- await sendBindingWelcome(key, subdir);
8259
+ // The name rides on the binding for the thread-context preamble (S1): an authorised user's as given, a member's
8260
+ // replaced by the folder's own name — the match is all the bot vets of it.
8261
+ isJustAutoBound = await autoBindTopicByName(key, match, (0, pendingTopicName_1.getAutoBindTopicName)(pending, match));
7456
8262
  }
7457
8263
  catch (e) {
7458
8264
  console.warn(`[deferred-autobind] failed for "${match}":`, e);
7459
- pendingTopicNames.delete(kStr);
7460
8265
  }
7461
8266
  }
7462
- else {
8267
+ else if (!pending.isFromAdmin) {
8268
+ // No folder matches: a name only a non-admin gave has nothing else to do. An authorised user's stays for the
8269
+ // bind the picker leads to, which keeps it (until it is bound or the TTL runs out).
7463
8270
  pendingTopicNames.delete(kStr);
7464
8271
  }
7465
8272
  }
7466
- else if (pending) {
8273
+ else if (pendingEntry) {
7467
8274
  // TTL expired — clean the entry.
7468
8275
  pendingTopicNames.delete(kStr);
7469
8276
  }
@@ -7533,12 +8340,13 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
7533
8340
  if (requestedFamily !== null && state.getBinding(key) && await askBeforeCrossBackendSwitch(key, requestedFamily, startMatch.args))
7534
8341
  return;
7535
8342
  // 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).
8343
+ // The explicit matched adapter wins (`preferredAdapterName`), named the way
8344
+ // `/claude` names it: `claude` is the topic's Claude backend, never the bare
8345
+ // tmux one. The phrase's trailing args ride along. On `unbound` we keep this
8346
+ // path's own reply: the folder picker (plan §11 Этап 4 — starting without a
8347
+ // binding would silently launch against WORK_ROOT, which is almost never wanted).
7540
8348
  const result = await ensureAgentSession(key, {
7541
- preferredAdapterName: startMatch.adapterName,
8349
+ preferredAdapterName: (0, createAdapter_1.resolveStartAdapterName)(key, startMatch.adapterName),
7542
8350
  args: startMatch.args,
7543
8351
  });
7544
8352
  if (!result.ok && result.reason === 'unbound') {
@@ -7628,6 +8436,8 @@ bot.on((0, filters_1.message)('text'), async (ctx) => {
7628
8436
  await replyToThread(key, (0, i18n_1.t)('thread.no_binding'), extra);
7629
8437
  return;
7630
8438
  }
8439
+ if (isJustAutoBound)
8440
+ return;
7631
8441
  await replyToThread(key, (0, i18n_1.t)('thread.no_agent_with_binding', { subdir: binding.subdir }));
7632
8442
  });
7633
8443
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -7750,7 +8560,7 @@ async function processVoiceJob(key, fileId, durationSeconds, requesterId, sentAt
7750
8560
  // Same choke point as the text trigger (L-D4): a sleeping conversation is
7751
8561
  // woken, never replaced by a fresh start; `unbound` keeps the folder picker.
7752
8562
  const result = await ensureAgentSession(key, {
7753
- preferredAdapterName: startMatch.adapterName,
8563
+ preferredAdapterName: (0, createAdapter_1.resolveStartAdapterName)(key, startMatch.adapterName),
7754
8564
  args: startMatch.args,
7755
8565
  });
7756
8566
  if (!result.ok && result.reason === 'unbound') {
@@ -7946,12 +8756,12 @@ const albumCollector = (0, mediaGroupCollector_1.createMediaGroupCollector)({
7946
8756
  // cancel it silently, like the text/voice handlers.
7947
8757
  cancelApiRetry(key);
7948
8758
  // A user album is genuine user activity → clear the compact-on-idle latch (D2).
7949
- noteThreadUserActivity(key);
8759
+ notePersonActivity(key);
7950
8760
  try {
7951
8761
  const opening = await openTopicRequest(key, promptText, { source: 'album', requesterId: orderedItems[0].requesterId });
7952
8762
  const quotedPrompt = getPromptWithThreadReplyQuote(key, promptText, replyContext);
7953
- await deliverPromptOrBuffer(key, `${opening.header}${quotedPrompt}`, isStarting);
7954
- trackTopicRequestTurn(key, opening);
8763
+ const delivery = await deliverPromptOrBuffer(key, `${opening.header}${quotedPrompt}`, isStarting);
8764
+ await settleTopicRequestAfterDelivery(key, opening, delivery);
7955
8765
  }
7956
8766
  catch (err) {
7957
8767
  console.error('[Bot] album flush failed:', err);
@@ -8030,12 +8840,12 @@ async function handleIncomingFile(ctx, key) {
8030
8840
  // cancel it silently, like the text/voice handlers.
8031
8841
  cancelApiRetry(key);
8032
8842
  // A user file is genuine user activity → clear the compact-on-idle latch (D2).
8033
- noteThreadUserActivity(key);
8843
+ notePersonActivity(key);
8034
8844
  const opening = await openTopicRequest(key, promptText, { source: 'file', requesterId: (0, topicRequest_1.getTopicRequesterId)(ctx.from) });
8035
8845
  // A file sent as a REPLY carries the message it answers, as a typed or spoken reply does.
8036
8846
  const quotedPrompt = getPromptWithThreadReplyQuote(key, promptText, getReplyQuoteBlock(key, ctx.message));
8037
- await deliverPromptOrBuffer(key, `${opening.header}${quotedPrompt}`, isStarting);
8038
- trackTopicRequestTurn(key, opening);
8847
+ const delivery = await deliverPromptOrBuffer(key, `${opening.header}${quotedPrompt}`, isStarting);
8848
+ await settleTopicRequestAfterDelivery(key, opening, delivery);
8039
8849
  }
8040
8850
  bot.on(
8041
8851
  // anyOf-composed per-kind filter — `message('photo', 'document', …)` would
@@ -8139,7 +8949,7 @@ bot.on((0, filters_1.message)('forum_topic_created'), async (ctx) => {
8139
8949
  const existing = state.getBinding(key);
8140
8950
  // Already bound — service event arrived after the user's first message
8141
8951
  // (or on restart). Don't overwrite; quietly skip the welcome too, the
8142
- // user has already seen the binding ack.
8952
+ // user has already seen the welcome.
8143
8953
  if (existing)
8144
8954
  return;
8145
8955
  // Audit S2 / #5: topic creation may be open to all members (group setting),
@@ -8156,7 +8966,7 @@ bot.on((0, filters_1.message)('forum_topic_created'), async (ctx) => {
8156
8966
  return;
8157
8967
  if (action === 'cacheNameOnly') {
8158
8968
  if (topicName) {
8159
- pendingTopicNames.set((0, sessionKey_1.keyToString)(key), { name: topicName, ts: Date.now() });
8969
+ pendingTopicNames.set((0, sessionKey_1.keyToString)(key), { name: topicName, ts: Date.now(), isFromAdmin: false });
8160
8970
  }
8161
8971
  console.warn(`[security] forum_topic_created in chat ${ctx.chat.id} by user ${userId ?? '?'} (not a group admin) — name cached, no auto-bind`);
8162
8972
  return;
@@ -8169,19 +8979,20 @@ bot.on((0, filters_1.message)('forum_topic_created'), async (ctx) => {
8169
8979
  const match = (0, validation_1.findAutobindSubdir)(topicName, subdirs);
8170
8980
  if (match) {
8171
8981
  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;
8982
+ // The creation event carries the topic name — it rides on the binding for the thread-context preamble (S1).
8983
+ if (await autoBindTopicByName(key, match, topicName))
8984
+ return;
8179
8985
  }
8180
8986
  catch (e) {
8181
8987
  console.warn(`[forum_topic_created] auto-bind failed for "${match}":`, e);
8182
8988
  // Fall through to picker.
8183
8989
  }
8184
8990
  }
8991
+ // 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
8992
+ // that bind keeps it (the shared-folder row of a later topic names this one by it).
8993
+ if (topicName && !state.getBinding(key)) {
8994
+ pendingTopicNames.set((0, sessionKey_1.keyToString)(key), { name: topicName, ts: Date.now(), isFromAdmin: true });
8995
+ }
8185
8996
  const extra = (0, bindPicker_1.buildBindKeyboard)(subdirs, 0, bindPicker_1.BIND_PAGE_SIZE, !!state.getBinding(key));
8186
8997
  await replyToThread(key, (0, i18n_1.t)('thread.welcome_pick'), extra);
8187
8998
  });
@@ -8213,7 +9024,7 @@ bot.on((0, filters_1.message)('forum_topic_edited'), async (ctx) => {
8213
9024
  }
8214
9025
  else {
8215
9026
  // 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() });
9027
+ pendingTopicNames.set((0, sessionKey_1.keyToString)(key), { name: newName, ts: Date.now(), isFromAdmin: true });
8217
9028
  }
8218
9029
  });
8219
9030
  // Audit S2 / #5: closed/reopened events can come from a member who isn't an
@@ -8297,16 +9108,11 @@ bot.action(/^bind_page_(\d+)$/, async (ctx) => {
8297
9108
  const page = parseInt(ctx.match[1], 10);
8298
9109
  const subdirs = listAvailableSubdirs(ENV.workRoot);
8299
9110
  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
- }
9111
+ // Answered BEFORE the edit: the edit waits in the paced send queue, which can outlive the callback.
8309
9112
  await ctx.answerCbQuery();
9113
+ const pickerMessageId = (0, callbackMessage_1.getCallbackMessageId)(ctx);
9114
+ if (pickerMessageId !== null)
9115
+ await editThreadKeyboard(key, pickerMessageId, keyboard.reply_markup, 'bind_page');
8310
9116
  });
8311
9117
  // Middle "N/M" pill in the nav row — pure UI, no state change.
8312
9118
  bot.action(bindPicker_1.bindPageNoopCallback, async (ctx) => {
@@ -8326,9 +9132,18 @@ bot.action(bindPicker_1.bindCreateFolderCallback, async (ctx) => {
8326
9132
  await ctx.answerCbQuery((0, i18n_1.t)('cb.bind_only_topical'));
8327
9133
  return;
8328
9134
  }
9135
+ const pickerMessageId = (0, callbackMessage_1.getCallbackMessageId)(ctx);
9136
+ // A picker a bind (or a leave) holds is spent: a name asked for now would bind a second time.
9137
+ if (pickerMessageId !== null && bindPickerClaims.has(key, pickerMessageId)) {
9138
+ await (0, callbackToast_1.answerCallbackQuerySafely)(ctx, (0, i18n_1.t)('cb.bind_picker_used'));
9139
+ return;
9140
+ }
8329
9141
  await ctx.answerCbQuery((0, i18n_1.t)('bind.create_cb'));
8330
- armFolderCreation(key);
8331
- await replyToThread(key, (0, i18n_1.t)('bind.create_prompt'));
9142
+ armFolderCreation(key, pickerMessageId);
9143
+ // Remembered like a picker: the bind this question leads to deletes it (a refused name keeps it, the question stays open).
9144
+ const promptMessageId = await replyToThread(key, (0, i18n_1.t)('bind.create_prompt'));
9145
+ if (promptMessageId !== null)
9146
+ openBindMessages.remember((0, sessionKey_1.keyToString)(key), promptMessageId, Date.now());
8332
9147
  });
8333
9148
  // «Leave current dir» — the folded-in `/unbind`, shown only in a bound topic.
8334
9149
  // Callback id deliberately avoids the `bind_` prefix so it can't be matched by
@@ -8343,8 +9158,20 @@ bot.action(bindPicker_1.bindLeaveCurrentCallback, async (ctx) => {
8343
9158
  await ctx.answerCbQuery((0, i18n_1.t)('cb.bind_only_topical'));
8344
9159
  return;
8345
9160
  }
8346
- await ctx.answerCbQuery();
8347
- await unbindThread(key);
9161
+ // 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.
9162
+ const pickerMessageId = (0, callbackMessage_1.getCallbackMessageId)(ctx);
9163
+ const run = await bindPickerClaims.runClaimed(key, pickerMessageId, async () => {
9164
+ await (0, callbackToast_1.answerCallbackQuerySafely)(ctx);
9165
+ await unbindThread(key);
9166
+ if (pickerMessageId !== null)
9167
+ await removeBindPicker(key, pickerMessageId);
9168
+ await closeOpenBindMessages(key);
9169
+ // The welcome's buttons have no folder to act on now.
9170
+ await stripWelcomeKeyboards(key, openWelcomeMessages.take((0, sessionKey_1.keyToString)(key)).map((entry) => entry.messageId));
9171
+ return true;
9172
+ });
9173
+ if (run === 'busy')
9174
+ await (0, callbackToast_1.answerCallbackQuerySafely)(ctx, (0, i18n_1.t)('cb.bind_picker_used'));
8348
9175
  });
8349
9176
  // `/topic`'s buttons: patterns of their own (`topic_…`), disjoint from the `bind_<subdir>` regex below.
8350
9177
  registerTopicCallbacks();
@@ -8358,12 +9185,26 @@ bot.action(/^bind_(.+)$/, async (ctx) => {
8358
9185
  await ctx.answerCbQuery((0, i18n_1.t)('cb.bind_only_topical'));
8359
9186
  return;
8360
9187
  }
9188
+ // The picker is claimed before any await: a bind takes seconds (the folder's facts are probed, the sends are paced), and a
9189
+ // second tap landing meanwhile must not bind — or welcome — a second time. A bind that does not happen (a refused folder,
9190
+ // a failure on the way) gives the picker back.
8361
9191
  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);
9192
+ const pickerMessageId = (0, callbackMessage_1.getCallbackMessageId)(ctx);
9193
+ const run = await bindPickerClaims.runClaimed(key, pickerMessageId, async () => {
9194
+ // A toast that comes too late (the query expired) changes nothing about the bind the person asked for.
9195
+ await (0, callbackToast_1.answerCallbackQuerySafely)(ctx, (0, i18n_1.t)('cb.binding_to', { subdir }));
9196
+ const result = await applyBinding(key, subdir);
9197
+ if (!result.ok) {
9198
+ await replyToBindResult(key, result);
9199
+ return false;
9200
+ }
9201
+ if (pickerMessageId !== null)
9202
+ await removeBindPicker(key, pickerMessageId);
9203
+ await replyToBindResult(key, result);
9204
+ return true;
9205
+ });
9206
+ if (run === 'busy')
9207
+ await (0, callbackToast_1.answerCallbackQuerySafely)(ctx, (0, i18n_1.t)('cb.bind_picker_used'));
8367
9208
  });
8368
9209
  // ─── /language inline picker callbacks ───────────────────────────────────────
8369
9210
  //
@@ -8501,10 +9342,14 @@ bot.action(timezonePicker_1.zonePickCallbackRe, async (ctx) => {
8501
9342
  registerModelCallbacks();
8502
9343
  registerCompactionCallbacks();
8503
9344
  registerAutoContinueLimitsCallbacks();
9345
+ registerBotSkillsCallbacks();
9346
+ registerTaskAgentsCallbacks();
9347
+ registerRunnersCallbacks();
8504
9348
  registerBackendSwitchCallbacks();
8505
9349
  registerClaudeAccountCallbacks();
8506
9350
  registerFallbackChainCallbacks();
8507
9351
  registerLimitsBoardCallbacks();
9352
+ registerTodoCallbacks();
8508
9353
  registerSttCallbacks();
8509
9354
  registerSelfUpdateCallbacks();
8510
9355
  // «⏭ Skip once» on the `/limits` board and the `/limits_autoswitch` screen: drop THIS armed resume and
@@ -8535,43 +9380,6 @@ bot.action(new RegExp(`^${autoContinueOnLimit_1.skipArmedRetryCallbackPrefix}(\\
8535
9380
  await consumeAutoContinueLimitsPicker(key, ctx.callbackQuery?.message, (0, i18n_1.t)('autoContinueLimits.skippedNotice'));
8536
9381
  });
8537
9382
  });
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
9383
  registerReminderCallbacks();
8576
9384
  bot.action(/^ccmode_(.+)$/, async (ctx) => {
8577
9385
  const key = await authoriseContext(ctx);
@@ -8593,18 +9401,11 @@ bot.action(/^ccmode_(.+)$/, async (ctx) => {
8593
9401
  return;
8594
9402
  }
8595
9403
  await ctx.answerCbQuery((0, i18n_1.t)('cb.claudeMode_switching'));
8596
- await replyToThread(key, await applyClaudeBackendSwitch(key, target));
9404
+ await replyToThread(key, appendTmuxDeprecatedHint(target, await applyClaudeBackendSwitch(key, target)));
8597
9405
  // 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
- }
9406
+ const pickerMessageId = (0, callbackMessage_1.getCallbackMessageId)(ctx);
9407
+ if (pickerMessageId !== null) {
9408
+ await editThreadKeyboard(key, pickerMessageId, buildClaudeModeKeyboard((0, createAdapter_1.resolveClaudeBackendName)(key)).reply_markup, 'ccmode_cb');
8608
9409
  }
8609
9410
  });
8610
9411
  registerDisplayModeCallbacks();
@@ -8632,18 +9433,9 @@ bot.action(/^agent_(.+)$/, async (ctx) => {
8632
9433
  // instead of staying stuck on the previous one (B16, mirrors B12). The
8633
9434
  // start keeps the picker message visible (it is neither edited nor deleted),
8634
9435
  // 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
- }
9436
+ const pickerMessageId = (0, callbackMessage_1.getCallbackMessageId)(ctx);
9437
+ if (pickerMessageId !== null) {
9438
+ await editThreadKeyboard(key, pickerMessageId, buildAgentKeyboard((0, createAdapter_1.getAvailableAdapters)(), adapterName).reply_markup, 'agent_cb');
8647
9439
  }
8648
9440
  }
8649
9441
  catch {
@@ -8716,14 +9508,14 @@ bot.action(/^qa_(\d+)_(\d+)$/, async (ctx) => {
8716
9508
  const selectedLabel = question.options[optIdx].label;
8717
9509
  const isAnswerShown = await applyQuestionAnswer(key, [selectedLabel]);
8718
9510
  if (isAnswerShown) {
8719
- // The question message was just edited to "✅ <header>: <answer>", which carries the whole answer: the toast only
9511
+ // The question message was just edited to carry the whole answer under the question: the toast only
8720
9512
  // 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);
9513
+ await (0, callbackToast_2.answerCallbackWithFittedText)(ctx, selectedLabel);
8722
9514
  }
8723
9515
  else {
8724
9516
  // The message could not be edited (it is gone, or the edit failed): nothing in the topic shows the answer, so a
8725
9517
  // label too long for the toast is posted in full.
8726
- await (0, callbackToast_1.answerCallbackWithWholeText)(ctx, selectedLabel, (wholeLabel) => replyToThread(key, wholeLabel));
9518
+ await (0, callbackToast_2.answerCallbackWithWholeText)(ctx, selectedLabel, (wholeLabel) => replyToThread(key, wholeLabel));
8727
9519
  }
8728
9520
  });
8729
9521
  /**
@@ -8735,6 +9527,13 @@ bot.action(/^qa_(\d+)_(\d+)$/, async (ctx) => {
8735
9527
  * the agent without the message it points at.
8736
9528
  */
8737
9529
  async function bufferPromptDuringStartup(key, text, intake, replyContext) {
9530
+ // The same for a bare option number typed in that window: it answers the question asked again.
9531
+ if (await answerReAskedQuestionByNumber(key, text))
9532
+ return;
9533
+ // A person wrote, in a window where the session is starting or being stopped: it counts like a message that reaches
9534
+ // `deliverActivePrompt` — the idle latch clears, and a question the idle compaction re-asked is closed (the idle fire
9535
+ // posts it just before the stop, so this window is where a message instead of a tap most often lands).
9536
+ notePersonActivity(key);
8738
9537
  const opening = await openTopicRequest(key, text, intake);
8739
9538
  const quotedText = getPromptWithThreadReplyQuote(key, text, replyContext);
8740
9539
  await deliverPromptOrBuffer(key, `${opening.header}${quotedText}`, true);
@@ -8753,15 +9552,18 @@ async function bufferPromptDuringStartup(key, text, intake, replyContext) {
8753
9552
  * user before forwarding so the prompt doesn't queue behind it forever.
8754
9553
  * 3. Forward the prompt (a bare `/clear` first purges thread context + files).
8755
9554
  *
8756
- * Claude/terminal never reach step 1 (their routing returns earlier in the text
8757
- * handler, and `pendingQuestions`/`answerQuestion` are OpenCode-only). For them
9555
+ * Step 1 is the OpenCode AND the Claude stream-json backends (both raise the `question` event and implement
9556
+ * `answerQuestion`). The tmux-scrape Claude and the terminal never reach it (no `pendingQuestions` entry): for them
8758
9557
  * `forwardPromptToAgent` interrupts the TUI selector itself.
8759
9558
  */
8760
9559
  async function deliverActivePrompt(key, adapter, text, intake, sentAtMs, replyContext) {
9560
+ // A bare option number answers the question the idle compaction asked again (a tap's own label comes in with no intake).
9561
+ if (intake !== null && await answerReAskedQuestionByNumber(key, text))
9562
+ return null;
8761
9563
  // A genuine USER message (text or voice) — clear the compact-on-idle latch so
8762
9564
  // the next idle period can compact again (D2), whether it answers a pending
8763
9565
  // question or is a fresh prompt.
8764
- noteThreadUserActivity(key);
9566
+ notePersonActivity(key);
8765
9567
  const pending = pendingQuestions.get((0, sessionKey_1.keyToString)(key));
8766
9568
  if (pending && adapter.answerQuestion) {
8767
9569
  const currentQuestion = pending.data.questions[pending.currentIndex];
@@ -8775,8 +9577,8 @@ async function deliverActivePrompt(key, adapter, text, intake, sentAtMs, replyCo
8775
9577
  }
8776
9578
  // Free-form text cancels the question and is a fresh prompt — a request (S7).
8777
9579
  const opening = await openTopicRequest(key, text, intake);
8778
- await cancelPendingQuestionAndForward(key, adapter, text, sentAtMs, replyContext, opening.header);
8779
- trackTopicRequestTurn(key, opening);
9580
+ const delivery = await cancelPendingQuestionAndForward(key, adapter, text, sentAtMs, replyContext, opening.header);
9581
+ await settleTopicRequestAfterDelivery(key, opening, delivery);
8780
9582
  return null;
8781
9583
  }
8782
9584
  // A bare `/clear` forwarded to the agent wipes its context (Claude TUI), so
@@ -8805,6 +9607,8 @@ async function deliverActivePrompt(key, adapter, text, intake, sentAtMs, replyCo
8805
9607
  // durable by the time the agent reads it.
8806
9608
  const opening = await openTopicRequest(key, text, intake);
8807
9609
  // Routed now, not when the message arrived: the idle stop may have begun or ended during the awaits since.
9610
+ // The forward can itself end in no session — a prompt that waited for an adopt replay found the session gone and nothing to wake;
9611
+ // it has told the person already (`forwardPromptToAgent`), so what is left here is the request this prompt opened.
8808
9612
  const outcome = await (0, promptDelivery_1.deliverLatePrompt)({
8809
9613
  checkIsWindowOpen: () => startupPromptBuffer.checkIsStarting((0, sessionKey_1.keyToString)(key)),
8810
9614
  checkIsSessionActive: () => adapter.checkIsActive(key),
@@ -8814,36 +9618,44 @@ async function deliverActivePrompt(key, adapter, text, intake, sentAtMs, replyCo
8814
9618
  wake: () => wakeSleepingSessionForPrompt(key),
8815
9619
  forward: () => forwardPromptToAgent(key, (0, createAdapter_1.getThreadAdapter)(key), text, sentAtMs, { replyContext, requestHeader: opening.header }),
8816
9620
  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
9621
  if (!isAlreadyTold)
8821
9622
  await replyToThread(key, (0, i18n_1.t)('agent.no_session'));
8822
9623
  },
8823
9624
  });
8824
- if (outcome !== 'noSession')
8825
- trackTopicRequestTurn(key, opening);
9625
+ await settleTopicRequestAfterDelivery(key, opening, outcome);
8826
9626
  return outcome;
8827
9627
  }
8828
9628
  /**
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.
9629
+ * @description Close the on-screen state of a pending question that ends without an answer: clear the bot's
9630
+ * pending-question state (which unpins it) and edit the question's message so its buttons are gone and a note says that
9631
+ * nobody answered it. The message is never deleted — the question and its options
9632
+ * stay readable. Closing the question on the agent is the caller's, before it stops or aborts the turn.
9633
+ * Returns whether the message itself was edited — it IS the notice then.
8833
9634
  *
8834
9635
  * 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.
9636
+ * re-post-to-bottom. The `messageId` + question are captured BEFORE clearing, since `clearPendingQuestion` drops the state.
8836
9637
  */
8837
9638
  async function retirePendingQuestion(key) {
8838
9639
  const pending = pendingQuestions.get((0, sessionKey_1.keyToString)(key));
8839
- const cancelledMessageId = pending?.messageId ?? null;
8840
- const cancelledQuestion = pending?.data.questions[pending.currentIndex];
8841
9640
  clearPendingQuestion(key);
8842
- if (cancelledMessageId === null || !cancelledQuestion)
9641
+ return closeCapturedQuestionMessage(key, pending?.messageId ?? null, pending?.data.questions[pending.currentIndex]);
9642
+ }
9643
+ /**
9644
+ * @description The edit half of {@link retirePendingQuestion}, for a question whose id and text were captured before its
9645
+ * state was cleared (also: read from the saved state at boot, where no memory entry ever existed). `false` when there is no
9646
+ * message to edit, else whether the edit landed.
9647
+ */
9648
+ async function closeCapturedQuestionMessage(key, messageId, question) {
9649
+ if (messageId === null || !question)
8843
9650
  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;
9651
+ return runQuestionLifecycleOp(key, () => closeQuestionMessage(key, messageId, question, 'unanswered'));
9652
+ }
9653
+ /**
9654
+ * @description {@link retirePendingQuestion} for the teardown and boot paths that do not wait for the edit (it goes through
9655
+ * the send pacer): a failure is logged, never an unhandled rejection.
9656
+ */
9657
+ function retirePendingQuestionInBackground(key) {
9658
+ retirePendingQuestion(key).catch((e) => console.error('[pendingQuestion] retire failed:', e));
8847
9659
  }
8848
9660
  /**
8849
9661
  * @description Cancel a pending OpenCode question because the user sent free-form
@@ -8853,7 +9665,7 @@ async function retirePendingQuestion(key) {
8853
9665
  * abort`), post the "previous question cancelled" notice, then forward.
8854
9666
  */
8855
9667
  async function cancelPendingQuestionAndForward(key, adapter, text, sentAtMs, replyContext, requestHeader) {
8856
- // When the question bubble itself was relabelled to "❌ … cancelled", that IS the
9668
+ // When the question bubble itself was closed with its «not answered» note, that IS the
8857
9669
  // cancellation notice — a second standalone "previous question cancelled" line is
8858
9670
  // pure noise (the reported triple message). The standalone notice is kept only
8859
9671
  // for the no-bubble case.
@@ -8871,7 +9683,7 @@ async function cancelPendingQuestionAndForward(key, adapter, text, sentAtMs, rep
8871
9683
  if (!didLabelQuestionMessage) {
8872
9684
  await replyToThread(key, (0, i18n_1.t)('agent.question_cancelled_for_prompt'));
8873
9685
  }
8874
- await forwardPromptToAgent(key, adapter, text, sentAtMs, { replyContext, requestHeader });
9686
+ return forwardPromptToAgent(key, adapter, text, sentAtMs, { replyContext, requestHeader });
8875
9687
  }
8876
9688
  /**
8877
9689
  * @description Record one answer for the CURRENTLY shown OpenCode question and
@@ -8895,8 +9707,9 @@ async function applyQuestionAnswerInner(key, answerForCurrent) {
8895
9707
  return false;
8896
9708
  const answeredQuestion = pending.data.questions[pending.currentIndex];
8897
9709
  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(', ')}`);
9710
+ // Mark the answered question's message as done: the question stays as asked, the chosen answer takes the place of
9711
+ // its buttons.
9712
+ 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
9713
  if (action.kind === 'submit') {
8901
9714
  const adapter = (0, createAdapter_1.getThreadAdapter)(key);
8902
9715
  clearPendingQuestion(key);
@@ -8910,7 +9723,8 @@ async function applyQuestionAnswerInner(key, answerForCurrent) {
8910
9723
  }
8911
9724
  // More questions remain: persist the advanced state, then post the next one
8912
9725
  // (which re-owns `messageId`). DO NOT reply to the agent yet.
8913
- setPendingQuestion(key, nextState);
9726
+ // 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.
9727
+ setPendingQuestion(key, { ...nextState, originalMessageId: null });
8914
9728
  await postPendingQuestionAt(key);
8915
9729
  return isAnswerShown;
8916
9730
  }
@@ -9922,6 +10736,9 @@ function handleAgentQuestion(key, questionData) {
9922
10736
  if (persistedQuestion && persistedQuestion.data.requestId === questionData.requestId) {
9923
10737
  return;
9924
10738
  }
10739
+ // The agent asks anew: a question the idle compaction re-asked earlier is superseded, and its buttons would now answer
10740
+ // the wrong question — it is closed as unanswered.
10741
+ closeReAskedQuestion(key);
9925
10742
  // A pending status frame is now stale — the question UI replaces it. Stop the
9926
10743
  // liveness loop first so it can't recreate a frame under the question prompt
9927
10744
  // (Claude-only; a no-op for OpenCode threads, which never arm it).
@@ -10037,6 +10854,7 @@ async function postPendingQuestionAt(key) {
10037
10854
  const existing = pendingQuestions.get(kStr);
10038
10855
  if (existing && existing.data === pending.data && existing.currentIndex === qIdx) {
10039
10856
  existing.messageId = messageId;
10857
+ existing.originalMessageId ??= messageId;
10040
10858
  setPendingQuestion(key, existing);
10041
10859
  markQuestionMessageSent(key, messageId);
10042
10860
  // Pin the question so the muted topic fires a notification (S2). The
@@ -10048,11 +10866,12 @@ async function postPendingQuestionAt(key) {
10048
10866
  void pinThreadQuestion(key, messageId);
10049
10867
  }
10050
10868
  else {
10051
- // The question advanced / was answered while our send sat in the
10869
+ // The question advanced / was answered / was closed while our send sat in the
10052
10870
  // queue (a fast digit reply can beat the post — seen live 2026-06-10:
10053
10871
  // 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);
10872
+ // just sent describes a question that no longer exists: it is closed as
10873
+ // unanswered (its buttons would be dead), never deleted.
10874
+ await closeQuestionMessage(key, messageId, question, 'unanswered');
10056
10875
  }
10057
10876
  }
10058
10877
  }
@@ -10090,7 +10909,7 @@ function handleAgentClosed(key) {
10090
10909
  clearThinkingMessage(key);
10091
10910
  // A closed session has no running delegation — remove the sub-agent status.
10092
10911
  clearSubagentStatus(key);
10093
- clearPendingQuestion(key);
10912
+ retirePendingQuestionInBackground(key);
10094
10913
  // A closed session has nothing to resume — drop any armed retry silently.
10095
10914
  cancelApiRetry(key);
10096
10915
  clearAuthNotice(key); // session closed → retire any pinned logged-out notice
@@ -10197,7 +11016,7 @@ function handleAgentError(key, error) {
10197
11016
  deleteStatusMessage(key).catch(() => { });
10198
11017
  clearThinkingMessage(key);
10199
11018
  clearSubagentStatus(key);
10200
- clearPendingQuestion(key);
11019
+ retirePendingQuestionInBackground(key);
10201
11020
  replyToThread(key, `Error: ${error.message}`).catch(() => { });
10202
11021
  }
10203
11022
  /**
@@ -10245,8 +11064,8 @@ function handleAgentStarted(key) {
10245
11064
  * the delete); the "thinking …" frame and the sub-agent status for the same reason;
10246
11065
  * and the pending question (F1): no stop path fires the adapter's `questionGone`
10247
11066
  * (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`.
11067
+ * leak forever — retiring it chains `unpinThreadQuestion` and closes its message as unanswered (the question stays
11068
+ * in the topic, its buttons would be dead), matching `handleAgentClosed` / `handleAgentError`.
10250
11069
  */
10251
11070
  function clearGoneProcessFrames(key) {
10252
11071
  clearThreadQueues(key);
@@ -10254,11 +11073,11 @@ function clearGoneProcessFrames(key) {
10254
11073
  deleteStatusMessage(key).catch(() => { });
10255
11074
  clearThinkingMessage(key);
10256
11075
  clearSubagentStatus(key);
10257
- clearPendingQuestion(key);
11076
+ retirePendingQuestionInBackground(key);
10258
11077
  }
10259
11078
  function handleAgentStopped(key) {
10260
11079
  // Single convergence point for every `stopSession`-driven stop path —
10261
- // `/quit` (OpenCode/terminal), `/quit-all`, `/new`, `/unbind`, and adapter
11080
+ // `/quit` (OpenCode/terminal), `/quitall`, `/new`, `/unbind`, and adapter
10262
11081
  // switch all emit `stopped`.
10263
11082
  clearGoneProcessFrames(key);
10264
11083
  // Session ended — a future session starts with empty context, so forget the
@@ -10321,9 +11140,8 @@ exports.COMMANDS_MENU = [
10321
11140
  { command: 'claude', description: '▶️ Start Claude Code' },
10322
11141
  { command: 'opencode', description: '▶️ Start OpenCode' },
10323
11142
  { command: 'terminal', description: '🖥 Open a raw shell in the bound folder' },
10324
- { command: 'claude_mode', description: '🔀 Claude backend: json-stream ⇄ per-turn ⇄ tmux-scrape' },
10325
- { command: 'new', description: '🆕 Restart session (alias /clear_session)' },
10326
- { command: 'clear_session', description: '🆕 Restart session (alias /new)' },
11143
+ { command: 'claude_mode', description: '🔀 Claude backend: json-stream ⇄ per-turn ⇄ tmux (deprecated)' },
11144
+ { command: 'new', description: '🆕 Restart session' },
10327
11145
  { command: 'model', description: '🧠 Switch model' },
10328
11146
  { command: 'effort', description: '⚙️ Reasoning effort' },
10329
11147
  { command: 'verbosity', description: '🔊 Output verbosity (thinking+tools+sub-agents)' },
@@ -10338,6 +11156,9 @@ exports.COMMANDS_MENU = [
10338
11156
  { command: 'compact', description: '🧹 Compact agent context' },
10339
11157
  { command: 'compact_on_idle', description: '🧹 Auto-compact after idle (toggle)' },
10340
11158
  { command: 'compact_summary', description: '📄 Post the compaction summary (toggle)' },
11159
+ { command: 'bot_skills', description: '🧰 The bot\'s own skills for agents (on/off)' },
11160
+ { command: 'task_agents', description: '🧵 How many tracker task agents work at once' },
11161
+ { command: 'runners', description: '🖥 Runners — machines for the agents\' tests and builds' },
10341
11162
  { command: 'accounts', description: '👤 Claude accounts and OpenCode providers (switch, add, re-login, remove, connect, disconnect)' },
10342
11163
  { command: 'limits', description: '🚧 Usage limits — switch this topic' },
10343
11164
  { command: 'limits_autoswitch', description: '🔁 Automatic switching on a usage limit' },
@@ -10345,6 +11166,7 @@ exports.COMMANDS_MENU = [
10345
11166
  { command: 'update', description: '🔄 Bot updates: automatic, notify only, off' },
10346
11167
  { command: 'schedule', description: '⏰ Schedule a prompt (agent does the work)' },
10347
11168
  { command: 'reminders', description: '🔔 Reminders — add, list, delete (no agent)' },
11169
+ { command: 'todo', description: '📋 Agents\' published todo lists' },
10348
11170
  { command: 'status', description: '📊 Show status' },
10349
11171
  { command: 'output', description: '📜 Last 500 lines' },
10350
11172
  { command: 'trace', description: '🛰 Output-trace recorder on/off' },
@@ -10650,7 +11472,7 @@ async function reattachExistingSessions(opts = { quietReattach: false }) {
10650
11472
  // A per-turn process that outlived the bot is stopped as soon as its turn (if any) ends (L5).
10651
11473
  stopAdoptedPerTurnSessionWhenIdle(key, ownerAdapter);
10652
11474
  }
10653
- if ((0, adoptedToolList_1.checkIsAdoptedToolListStale)({ persistedDigest: agent.mcpToolDigest, currentDigest: botMcpToolDigestReader?.(key.platform) ?? '' })) {
11475
+ if ((0, adoptedToolList_1.checkIsAdoptedToolListStale)({ persistedDigest: agent.mcpToolDigest, currentDigest: botMcpToolDigestReader?.(key) ?? '' })) {
10654
11476
  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
11477
  }
10656
11478
  // The surviving process keeps the account it was spawned with. A switch the user picked while it worked is
@@ -10797,7 +11619,8 @@ async function reattachExistingSessions(opts = { quietReattach: false }) {
10797
11619
  * A persisted question is restored ONLY when its thread's session reattached
10798
11620
  * and is currently active (`adapter.checkIsActive`). Otherwise the question is
10799
11621
  * 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
11622
+ * store, preventing a stale entry from lingering across boots; its message is
11623
+ * kept, closed as unanswered, and its pin released. The persisted
10801
11624
  * `messageId` is preserved, so the OLD buttons resolve correctly; the question
10802
11625
  * is never re-posted.
10803
11626
  *
@@ -10830,12 +11653,62 @@ function restorePendingQuestions() {
10830
11653
  restored += 1;
10831
11654
  }
10832
11655
  else {
11656
+ // The agent that asked it is gone, so nobody can answer it: it is closed like any question that ends unanswered —
11657
+ // its message stays with the «not answered» note in place of the buttons and its pin is released. The pin map is
11658
+ // lost with the restart, so it is seeded from the saved message id for the clear to unpin that message.
11659
+ const orphaned = (0, openCodeQuestionFlow_1.migratePendingQuestionState)(value);
11660
+ if (orphaned.messageId !== null)
11661
+ questionPinnedMessageId.set(keyStr, orphaned.messageId);
10833
11662
  clearPendingQuestion(key);
11663
+ closeCapturedQuestionMessage(key, orphaned.messageId, orphaned.data.questions[orphaned.currentIndex])
11664
+ .catch((e) => console.error('[pendingQuestion] closing the dropped question failed:', e));
10834
11665
  dropped += 1;
10835
11666
  }
10836
11667
  }
10837
11668
  console.log(`[reattach] pending questions: restored ${restored}, dropped ${dropped}`);
10838
11669
  }
11670
+ /**
11671
+ * @description Bring back the questions the idle compaction re-asked, at boot. One whose conversation can still be
11672
+ * resumed (a live adopted process, or a sleeping conversation whose id is kept) gets its buttons working again — a tap
11673
+ * wakes it, as before the restart. One whose conversation is gone (released while the bot was down), or beside which a newer
11674
+ * question waits already, cannot be answered / must not be: its message is closed as unanswered, never left with buttons that
11675
+ * do nothing or that would answer the wrong question, and the saved record is dropped.
11676
+ * MUST run AFTER {@link reattachExistingSessions} so session activity is known. A platform this instance does not serve
11677
+ * is left alone, like {@link restorePendingQuestions}. A record saved BEFORE its re-ask was posted (`isPosted: false`: the bot
11678
+ * stopped during the idle compaction) names the message the question waited in: when the conversation can be resumed the
11679
+ * question is asked again now (nobody has had the chance to answer it) and that message says so; otherwise it is closed as
11680
+ * unanswered like any other.
11681
+ */
11682
+ function restoreReAskedQuestions() {
11683
+ for (const [keyStr, saved] of Object.entries(state.getReAskedQuestions())) {
11684
+ let key;
11685
+ try {
11686
+ key = (0, sessionKey_1.keyFromString)(keyStr);
11687
+ }
11688
+ catch {
11689
+ continue;
11690
+ }
11691
+ if (!(0, connectorSet_1.checkIsServedConversation)(key, ENV.servedPlatforms))
11692
+ continue;
11693
+ // A question waits already (restored a moment ago, or asked anew by the agent that reattached): the copy's buttons would
11694
+ // deliver their label into IT, and a question asked again now would sit beside it. Nor is there anything to deliver a label
11695
+ // to once the conversation is gone.
11696
+ const isConversationResumable = !pendingQuestions.has(keyStr) && ((0, createAdapter_1.getThreadAdapter)(key).checkIsActive(key) || getResumableSessionId(key) !== null);
11697
+ if (isConversationResumable) {
11698
+ if (saved.isPosted === false) {
11699
+ // The bot stopped between the start of an idle compaction and its re-ask: the question was rejected on the agent's side
11700
+ // and nobody has answered it — it is asked again now, and the message it waited in (what is saved) says so.
11701
+ reAskInterruptedQuestion(key, saved).catch((e) => console.error('[reAskedQuestion] asking the interrupted question again failed:', e));
11702
+ }
11703
+ else {
11704
+ restoreReAskedQuestion(key, saved);
11705
+ }
11706
+ continue;
11707
+ }
11708
+ // The copy is closed, never brought back; an interrupted question's message ends as unanswered, the one thing known of it.
11709
+ closeSavedReAskedQuestion(key, saved);
11710
+ }
11711
+ }
10839
11712
  /**
10840
11713
  * @description Re-arm persisted API-error retries (S6) so a pending kick —
10841
11714
  * especially a multi-hour usage-limit wait — survives a bot restart. MUST run
@@ -10905,23 +11778,20 @@ function markLimitEpisodeSettled(key) {
10905
11778
  }
10906
11779
  /**
10907
11780
  * @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
11781
+ * API-error entry ({@link readTranscriptErrorWrittenAtMs}); `null` when the transcript tail cannot be read or holds no such
10909
11782
  * entry — the recovery then goes by the stdout log's mtime.
10910
11783
  */
10911
11784
  function readParkedErrorWrittenAtMs(key, claudeSessionId, errorText) {
10912
11785
  const workDirDecision = getWorkDirStartDecision(key);
10913
11786
  if (!workDirDecision.ok)
10914
11787
  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
- }
11788
+ return (0, transcriptErrorTime_1.readTranscriptErrorWrittenAtMs)((0, claudeCliAdapter_1.getClaudeTranscriptPath)(workDirDecision.workDir, claudeSessionId), errorText);
10924
11789
  }
11790
+ /**
11791
+ * @description The threads whose limit episode the boot recovery armed and whose adopt is still replaying the downtime tail
11792
+ * ({@link handleApiError} leaves the replay's copy of that error alone). An entry lives until the replay is over.
11793
+ */
11794
+ const limitEpisodesOwnedByRecovery = new Set();
10925
11795
  /**
10926
11796
  * @description Recover a usage/session-limit episode that ENDED BEFORE this boot
10927
11797
  * and left no armed retry behind — the case {@link restoreApiRetries} cannot help
@@ -11005,6 +11875,10 @@ function recoverLimitEpisodesFromDisk() {
11005
11875
  void state
11006
11876
  .setLimitEpisodeRecovered(key, log)
11007
11877
  .catch(e => console.error('[limitRecovery] marker persist failed:', e));
11878
+ // This episode is the recovery's alone: the adopt replays the same error a moment later (the tail was read from the
11879
+ // saved offset), and `handleApiError` leaves that copy out until the replay is over.
11880
+ limitEpisodesOwnedByRecovery.add(keyStr);
11881
+ void ((0, createAdapter_1.getAdapter)(agent.name).whenAdoptReplayed?.(key) ?? Promise.resolve()).then(() => limitEpisodesOwnedByRecovery.delete(keyStr));
11008
11882
  void withThreadLocale(key, () => handleApiError(key, decision.cls, { isRecoveredEpisode: true }));
11009
11883
  recovered += 1;
11010
11884
  }
@@ -11204,6 +12078,8 @@ let connectorConversationHooks = new Map();
11204
12078
  * {@link clearThreadContextMarker}, which a stop clears too.
11205
12079
  */
11206
12080
  function noteConversationContextReset(key, reason) {
12081
+ // A completed compaction or a fresh session: the agent forgot the board too (D20).
12082
+ noteTodoFreshContext(key);
11207
12083
  connectorConversationHooks.get(key.platform)?.onContextReset(key, reason);
11208
12084
  }
11209
12085
  /**
@@ -11267,6 +12143,36 @@ async function remindAnswerTail(key, text) {
11267
12143
  await forwardPromptToAgent(key, (0, createAdapter_1.getThreadAdapter)(key), text, undefined, { isRequestReminder: true });
11268
12144
  return true;
11269
12145
  }
12146
+ /**
12147
+ * @description How a conversation's session stands right now (`requests/sessionTurnProbe.ts`) — the one probe the
12148
+ * wake-up engine and the todo reminder both poll.
12149
+ */
12150
+ const probeThreadTurn = (0, sessionTurnProbe_1.createSessionTurnProbe)({
12151
+ getAdapter: createAdapter_1.getThreadAdapter,
12152
+ checkHasPendingQuestion: (keyString) => pendingQuestions.has(keyString),
12153
+ checkHasQuestionPin: (keyString) => questionPinnedMessageId.has(keyString),
12154
+ checkIsCompacting: (keyString) => threadsCompacting.has(keyString),
12155
+ getApiRetryTimer: (keyString) => apiRetryTimers.get(keyString)?.timer,
12156
+ checkIsWedgeRecoveryInFlight: (keyString) => wedgeRecoveriesInFlight.has(keyString),
12157
+ checkIsRetryKickInFlight: (keyString) => apiRetryKicksInFlight.has(keyString),
12158
+ checkIsSessionStarting: (keyString) => startupPromptBuffer.checkIsStarting(keyString),
12159
+ serializeKey: sessionKey_1.keyToString,
12160
+ });
12161
+ /**
12162
+ * @description The todo list's standalone reminder (D11 form b): a technical prompt, posted only into a live session
12163
+ * that is idle right now — never a resume, never an interrupt (`postToIdleSession`). It opens no request and is never
12164
+ * replayed; in a Jira conversation it is no hand-over and nothing of its turn reaches the issue (D58).
12165
+ */
12166
+ async function postTodoReminder(key, text) {
12167
+ const adapter = (0, createAdapter_1.getThreadAdapter)(key);
12168
+ // A reminder dropped after an adopt replay (the session was no longer live and idle) was not posted.
12169
+ let isDropped = false;
12170
+ const isPosted = await (0, postToSession_1.postToIdleSession)({
12171
+ checkIsIdle: () => adapter.checkIsActive(key) && !(adapter.checkIsBusy?.(key) ?? false),
12172
+ forward: async () => { isDropped = (await forwardPromptToAgent(key, adapter, text, undefined, { isTechnicalPrompt: true })) === 'dropped'; },
12173
+ });
12174
+ return isPosted && !isDropped;
12175
+ }
11270
12176
  /**
11271
12177
  * @description The person took over the conversation (interrupt, quit, restart,
11272
12178
  * switch or resume of the session, a switch to another agent or a shell, leaving
@@ -11302,8 +12208,8 @@ function createSessionPostDeps() {
11302
12208
  // A scheduled run in a topic with requests on is a request (S7); a tracker's
11303
12209
  // post arrives with its request already open and its header in the text.
11304
12210
  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);
12211
+ const delivery = await deliverPromptOrBuffer(key, `${opening.header}${text}`, startupPromptBuffer.checkIsStarting(conversationKey));
12212
+ await settleTopicRequestAfterDelivery(key, opening, delivery);
11307
12213
  },
11308
12214
  now: () => Date.now(),
11309
12215
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
@@ -11342,6 +12248,15 @@ function wireScheduler(wiring) {
11342
12248
  await state.upsertSchedule({ ...record, isCheckFailing: isFailing, updatedAt: new Date().toISOString() });
11343
12249
  await state.flush();
11344
12250
  },
12251
+ recordAlertPin: async (jobId, messageId) => {
12252
+ const record = state.getSchedules()[jobId];
12253
+ if (!record)
12254
+ return;
12255
+ const alertPinMessageIds = [...(record.alertPinMessageIds ?? []), messageId];
12256
+ await state.upsertSchedule({ ...record, alertPinMessageIds, updatedAt: new Date().toISOString() });
12257
+ // Saved at once, like the failing flag: an id lost in a crash would leave its alert pinned for good.
12258
+ await state.flush();
12259
+ },
11345
12260
  });
11346
12261
  /**
11347
12262
  * The engine's deliver callback: the S4 pipeline plus the S8 unbound-pause
@@ -11377,6 +12292,11 @@ function wireScheduler(wiring) {
11377
12292
  const key = (0, sessionKey_1.keyFromString)(threadKeyStr);
11378
12293
  return (0, createAdapter_1.getThreadAdapterNameRaw)(key) ?? state.getAgent(key)?.name;
11379
12294
  },
12295
+ unpinMessages: async (threadKeyStr, messageIds) => {
12296
+ const key = (0, sessionKey_1.keyFromString)(threadKeyStr);
12297
+ for (const messageId of messageIds)
12298
+ await unpinMessageQuiet(key, messageId);
12299
+ },
11380
12300
  // Routed through the outbound seam: the agent-facing file tool is a
11381
12301
  // connector delivery, not a direct call into the Telegram send pipeline.
11382
12302
  // The MCP surface is string-keyed (its `threadKey` arrives as text), so the
@@ -11391,13 +12311,26 @@ function wireScheduler(wiring) {
11391
12311
  sendMessagesToThread,
11392
12312
  compactConversation: (threadKeyStr) => armDeferredCompaction((0, sessionKey_1.keyFromString)(threadKeyStr)),
11393
12313
  listClaudeProfiles: listClaudeProfilesForAgent,
12314
+ getRemoteRunRunnerLines: () => (0, runnerSummary_1.getRunnerSummaryLines)(),
12315
+ // A refused tool call leaves one line in the bounded diagnostic log (agent-diag.log): the SDK refuses before the bot sees it.
12316
+ logDiagnostic: diagLog_1.appendDiagLog,
11394
12317
  ...(jiraConnector
11395
12318
  ? { fetchJiraAttachment: (threadKeyStr, attachmentId) => jiraConnector.fetchAttachment((0, sessionKey_1.keyFromString)(threadKeyStr), attachmentId) }
11396
12319
  : {}),
12320
+ // Read per request: General can turn the shared lists off, and the tools go with them.
12321
+ get todos() {
12322
+ return readTodoSettings().isEnabled
12323
+ ? {
12324
+ update: (threadKeyStr, args) => getTodoTools().update((0, sessionKey_1.keyFromString)(threadKeyStr), args),
12325
+ read: (threadKeyStr, args) => getTodoTools().read((0, sessionKey_1.keyFromString)(threadKeyStr), args),
12326
+ }
12327
+ : undefined;
12328
+ },
11397
12329
  answerRequest: (args) => (0, answerRequest_1.answerRequest)({
11398
12330
  ledger: wiring.requestLedger,
11399
12331
  answerSinks: wiring.answerSinks,
11400
12332
  onAnswerDelivered: (key, requestId, kind) => requestWakeUpEngine?.noteAnswerDelivered(key, requestId, kind),
12333
+ getFinalAnswerNote: (key, requestCreatedAt) => (readTodoSettings().isEnabled ? getTodoRegistry().getFinalAnswerNote(key, requestCreatedAt) : null),
11401
12334
  }, args),
11402
12335
  whenSessionsRestored: wiring.whenSessionsRestored,
11403
12336
  getSecret: () => state.getSchedulerMcpSecret(),
@@ -11616,10 +12549,11 @@ async function prepareJiraConnectorOrExit() {
11616
12549
  * J5): the issue's binding, the request ledger, and the shared session post —
11617
12550
  * after which the wake-up engine watches the turn the request started.
11618
12551
  */
11619
- function createJiraSessionDeps(requestLedger, adapterName) {
12552
+ function createJiraSessionDeps(requestLedger, slots, adapterName) {
11620
12553
  // D5: one conversation per issue — a session that sleeps between requests is resumed by the ensure (L-D4).
11621
12554
  const sessionPostDeps = createSessionPostDeps();
11622
12555
  return {
12556
+ slots,
11623
12557
  bindConversation: async (key, folder) => {
11624
12558
  if (state.getBinding(key)?.subdir !== folder)
11625
12559
  await state.setBinding(key, folder);
@@ -11629,7 +12563,7 @@ function createJiraSessionDeps(requestLedger, adapterName) {
11629
12563
  postRequest: async (key, requestId, prompt) => {
11630
12564
  // A request is the issue's "user message" (L-D5): it clears the spent idle-compaction
11631
12565
  // latch like a topic's message does, or the issue would never compact again.
11632
- noteThreadUserActivity(key);
12566
+ notePersonActivity(key);
11633
12567
  const posted = await (0, postToSession_1.postToSession)(sessionPostDeps, (0, sessionKey_1.keyToString)(key), prompt, adapterName);
11634
12568
  if (!posted.ok) {
11635
12569
  // R28: retried at about 1, 5 and 15 min, outside the wake-up cap.
@@ -11652,7 +12586,7 @@ async function startBot() {
11652
12586
  console.log(`Default Claude backend: ${(0, createAdapter_1.getDefaultClaudeBackendName)()}`);
11653
12587
  console.log(`Available agents: ${(0, createAdapter_1.getAvailableAdapters)().map(a => a.name).join(', ')}`);
11654
12588
  // 1. State store. The `[startup] DATA_DIR=` shape is load-bearing: the
11655
- // README's two-instances troubleshooting tells the operator to compare this
12589
+ // docs/troubleshooting.md's two-instances section tells the operator to compare this
11656
12590
  // exact line across instances.
11657
12591
  state = await (0, state_1.getStateStore)({ saveDebounceMs: ENV.testTimings.stateSaveDebounceMs });
11658
12592
  console.log(`[startup] DATA_DIR=${path.dirname(state.stateFilePath)}`);
@@ -11682,6 +12616,8 @@ async function startBot() {
11682
12616
  // It starts polling once the sessions are restored.
11683
12617
  // Every Claude spawn from here on reads the conversation's account profile (`/account`).
11684
12618
  (0, createAdapter_1.registerClaudeConfigDirReader)(resolveThreadClaudeConfigDir);
12619
+ // The «Bot skills» switch (`/bot_skills`): every agent start, the OpenCode server's start and the bot MCP's instructions read it.
12620
+ (0, botSkills_1.setBotSkillsReader)(botSkillsReader);
11685
12621
  (0, createAdapter_1.registerClaudeRateLimitSink)(onClaudeRateLimitEvent);
11686
12622
  (0, createAdapter_1.registerClaudeTurnCostSink)(onClaudeTurnCost);
11687
12623
  if (ENV.testTimings.openCodeServerUpBudgetMs !== undefined)
@@ -11690,6 +12626,7 @@ async function startBot() {
11690
12626
  claudeUsageRefresher.start();
11691
12627
  void claudeUsageRefresher.refreshAll();
11692
12628
  const jiraConnector = ENV.servedPlatforms.has('jira') ? await prepareJiraConnectorOrExit() : null;
12629
+ todoJiraConversations = jiraConnector;
11693
12630
  if (jiraConnector) {
11694
12631
  const { launchDefaults } = jiraConnector;
11695
12632
  (0, createAdapter_1.registerSessionLaunchDefaultsReader)((key) => (key.platform === 'jira' ? launchDefaults : null));
@@ -11709,24 +12646,26 @@ async function startBot() {
11709
12646
  onRequestCreated: (key) => announceLimitWaitToNewRequest(key),
11710
12647
  // C4: a connector counts what a prompt carried as sent only once its agent took it in.
11711
12648
  onPromptSettled: (key, requestId, outcome) => connectorConversationHooks.get(key.platform)?.onPromptSettled(key, requestId, outcome),
12649
+ // D10: a closed hand-over may free a task-agent slot for a waiting issue.
12650
+ onRequestClosed: (record) => taskAgentSlots?.noteRequestClosed(record),
12651
+ // D10: a request the wake-up engine gave up on no longer holds a slot either.
12652
+ onRequestWakeStopped: (_key, request) => taskAgentSlots?.noteRequestWakeStopped(request),
11712
12653
  });
11713
12654
  await requestLedger.load();
12655
+ const slots = new taskAgentSlots_1.TaskAgentSlots({
12656
+ getMaxConcurrent: () => (0, taskAgentLimit_1.readTaskAgentLimit)(taskAgentLimitSources).maxConcurrent,
12657
+ ledger: requestLedger,
12658
+ store: state,
12659
+ });
12660
+ // A place held for a connector this process no longer serves could never start, and would hold up the others.
12661
+ slots.dropUnserved((key) => (0, connectorSet_1.checkIsServedConversation)(key, ENV.servedPlatforms));
12662
+ taskAgentSlots = slots;
11714
12663
  topicRequestLedger = requestLedger;
11715
12664
  // The wake-up engine (S4) watches the turns requests start. It is created now
11716
12665
  // but started only once the sessions are restored (its sweep reads them).
11717
12666
  requestWakeUpEngine = new wakeUpEngine_1.RequestWakeUpEngine({
11718
12667
  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
- }),
12668
+ probeTurn: probeThreadTurn,
11730
12669
  prepareWakeUpSession: (key, message) => prepareRequestWakeUpSession(key, message, key.platform === 'jira' ? jiraConnector?.adapterName : undefined),
11731
12670
  forwardWakeUp: forwardRequestWakeUp,
11732
12671
  deliverAlert: async (key, request, reason) => {
@@ -11739,6 +12678,7 @@ async function startBot() {
11739
12678
  return result.ok ? (result.alertRef ?? null) : null;
11740
12679
  },
11741
12680
  backstopMs: (0, wakeUpRules_1.getRequestBackstopMs)(process.env.REQUEST_BACKSTOP_MINUTES),
12681
+ pollIntervalMs: ENV.testTimings.requestTurnPollMs,
11742
12682
  sweepIntervalMs: ENV.testTimings.requestSweepIntervalMs,
11743
12683
  firstPostRetryDelayMs: ENV.testTimings.requestFirstPostRetryDelayMs,
11744
12684
  // Only a Telegram topic in the answers-only view hides the stream from someone who expects to see the work.
@@ -11746,6 +12686,25 @@ async function startBot() {
11746
12686
  remindAnswerTail,
11747
12687
  });
11748
12688
  requestLimitWaitAnswerDeps = { ledger: requestLedger, engine: requestWakeUpEngine, answerSinks };
12689
+ // The todo list's staleness reminder (D10–D12) polls the same turns; started once the sessions are restored.
12690
+ todoReminderEngine = new reminderEngine_1.TodoReminderEngine({
12691
+ registry: getTodoRegistry(),
12692
+ listConversations: () => state.listBindings().map(({ key }) => key),
12693
+ probeTurn: probeThreadTurn,
12694
+ getGate: (key) => ({
12695
+ hasOpenRequest: requestLedger.listOpenRequestsOf(key).length > 0,
12696
+ isStoppingAfterTurn: (0, createAdapter_1.getThreadAdapter)(key).name === adapterNames_1.claudePerTurnAdapterName,
12697
+ isAnswerTailFollowed: requestWakeUpEngine?.checkIsAnswerTailFollowed(key) ?? false,
12698
+ ...getConversationAccountState(key),
12699
+ }),
12700
+ getIntervalMs: getTodoReminderIntervalMsNow,
12701
+ checkIsEnabled: () => readTodoSettings().isEnabled,
12702
+ postReminder: postTodoReminder,
12703
+ midTurn: getTodoMidTurn(),
12704
+ pollIntervalMs: ENV.testTimings.todoReminderPollMs,
12705
+ });
12706
+ getTodoMidTurn().clearInboxes();
12707
+ warmTodoProjects();
11749
12708
  // Snapshot the persisted transient status-frame ids (S2) NOW, before reattach
11750
12709
  // can run any frame-id setter. A reattached session's first frame lifecycle
11751
12710
  // call persists the FRESH (all-null) in-memory state and would otherwise
@@ -11801,8 +12760,9 @@ async function startBot() {
11801
12760
  // that gives a conversation of another platform only its request-side handling
11802
12761
  // (Jira connector plan J2b, R2 — the routing table is unit-tested per event).
11803
12762
  const noteTurnOutput = (key) => {
11804
- // Output means a turn is under way — the wake-up engine's busy-onset signal.
12763
+ // Output means a turn is under way — the wake-up engine's busy-onset signal, and the todo reminder's.
11805
12764
  requestWakeUpEngine?.noteAgentOutput(key);
12765
+ todoReminderEngine?.noteAgentOutput(key);
11806
12766
  };
11807
12767
  (0, createAdapter_1.registerAdapterEventHandlers)({
11808
12768
  onOutput: (key, output, meta) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'output', () => {
@@ -11841,7 +12801,12 @@ async function startBot() {
11841
12801
  }),
11842
12802
  onStopped: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'stopped', () => withThreadLocale(key, () => handleAgentStopped(key)), () => clearForeignSessionState(key)),
11843
12803
  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)),
12804
+ onTurnEnded: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'turnEnded', () => {
12805
+ // Every platform's (the route is `all`): the todo reminder's turn end, which the per-turn stop below would
12806
+ // otherwise hide from its poll.
12807
+ 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));
12808
+ handleAgentTurnEnded(key);
12809
+ }),
11845
12810
  onContextCompacted: (key, trigger) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'contextCompacted', () => noteConversationContextReset(key, `compaction (${trigger ?? 'bot'})`)),
11846
12811
  onConversationLost: (key) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'conversationLost', () => withThreadLocale(key, () => handleConversationLost(key)), () => { dropLostConversationWatermark(key); noteConversationContextReset(key, 'its transcript was lost'); }),
11847
12812
  onError: (key, error) => (0, adapterEventRouting_1.dispatchAdapterEvent)(key, 'error', () => withThreadLocale(key, () => handleAgentError(key, error)), () => {
@@ -11943,7 +12908,7 @@ async function startBot() {
11943
12908
  });
11944
12909
  // Known before any session starts or is adopted: every json-stream start persists the digest of the
11945
12910
  // tools it connected to, and the reattach compares an adopted process's against it (L4).
11946
- botMcpToolDigestReader = (platform) => schedulerMcpHandle.getToolDigest(platform);
12911
+ botMcpToolDigestReader = (key) => schedulerMcpHandle.getToolDigest(key.platform, (0, botSkills_1.checkIsBotSkillsEnabled)(key));
11947
12912
  const isSchedulerMcpStarted = await (0, mcpBoot_1.runSessionBootPhase)({
11948
12913
  startBotMcp: () => (0, mcpBoot_1.startSchedulerMcpForBoot)({
11949
12914
  handle: schedulerMcpHandle,
@@ -11964,6 +12929,9 @@ async function startBot() {
11964
12929
  // existing option buttons keep working. A question is restored only for a
11965
12930
  // thread whose session came back; otherwise it is unreachable, dropped.
11966
12931
  restorePendingQuestions();
12932
+ // The questions the idle compaction re-asked: their buttons come back while the conversation can be resumed,
12933
+ // else the message is closed.
12934
+ restoreReAskedQuestions();
11967
12935
  // Re-arm persisted API-error retries (S6) so a pending kick survives the
11968
12936
  // restart. AFTER restorePendingQuestions so the kick lands in a live
11969
12937
  // session; a past fireAt fires one delayed catch-up.
@@ -11986,7 +12954,8 @@ async function startBot() {
11986
12954
  markSessionsRestored();
11987
12955
  // From here the engine can tell a live turn from a dead session.
11988
12956
  requestWakeUpEngine?.start();
11989
- jiraConnector?.start(createJiraSessionDeps(requestLedger, jiraConnector.adapterName));
12957
+ todoReminderEngine?.start();
12958
+ jiraConnector?.start(createJiraSessionDeps(requestLedger, slots, jiraConnector.adapterName));
11990
12959
  },
11991
12960
  healActiveSessions: () => {
11992
12961
  // When this boot ADOPTED an already-running opencode, that server may
@@ -11994,11 +12963,13 @@ async function startBot() {
11994
12963
  // generation pointing at a now-dead port. Reconcile against the live MCP
11995
12964
  // status and force-refresh any directory that is not `connected`.
11996
12965
  // 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
12966
+ // before `bot.launch()`. The skills step (a server started without the
12967
+ // bot's skills folder is replaced) and the compaction-plugin step go FIRST:
12968
+ // a replaced server or a recreated directory instance drops the MCP
11999
12969
  // registration, which this same reconcile then restores.
12000
12970
  const openCodeAdapter = (0, createAdapter_1.getAdapter)('opencode');
12001
12971
  void (async () => {
12972
+ await openCodeAdapter.activateAgentSkillsForServer?.();
12002
12973
  await openCodeAdapter.activateCompactionPluginForActiveSessions?.();
12003
12974
  await openCodeAdapter.reconcileSchedulerMcpForActiveSessions?.();
12004
12975
  })().catch((e) => console.warn('[scheduler] MCP reconcile failed:', e instanceof Error ? e.message : e));
@@ -12127,12 +13098,10 @@ async function startBot() {
12127
13098
  // thread that no longer owns a json-stream session (the normal stop /
12128
13099
  // release paths delete the dir themselves; reattach's adopt/orphan-kill
12129
13100
  // 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
- })
13101
+ void (0, jsonStreamHost_1.sweepOrphanJsonStreamDirs)((0, jsonStreamHost_1.resolveJsonStreamRoot)(getDataDir()), (key) => (0, jsonStreamHost_1.checkIsJsonStreamHostOwned)({
13102
+ isSessionActive: [adapterNames_1.claudeJsonStreamAdapterName, adapterNames_1.claudePerTurnAdapterName].some((name) => (0, createAdapter_1.getAdapter)(name).checkIsActive(key)),
13103
+ agent: state.getAgent(key),
13104
+ }))
12136
13105
  .then((removedCount) => {
12137
13106
  if (removedCount > 0)
12138
13107
  console.log(`[fileSweep] removed ${removedCount} orphan jsonstream dirs`);
@@ -12175,7 +13144,7 @@ async function startBot() {
12175
13144
  console.error('[bot.catch] unhandled error:', err, 'update:', ctx.updateType);
12176
13145
  });
12177
13146
  // 7. Shutdown — preserve active agents for restart/reattach. Use /quit
12178
- // (or /quit-all) for an intentional agent stop; process signals only
13147
+ // (or /quitall) for an intentional agent stop; process signals only
12179
13148
  // stop the bot itself. Ordering (bot.stop → state.flush →
12180
13149
  // releaseLock → exit) is enforced by `gracefulShutdown` so the
12181
13150
  // previous race against `lock.ts`'s synchronous `process.exit(0)`
@@ -12206,6 +13175,7 @@ async function startBot() {
12206
13175
  // means the next boot's rearmAll picks them back up (catch-up replay).
12207
13176
  schedulerEngine?.shutdown();
12208
13177
  requestWakeUpEngine?.stop();
13178
+ todoReminderEngine?.stop();
12209
13179
  jiraConnector?.stop();
12210
13180
  if (isSchedulerMcpStarted)
12211
13181
  void schedulerMcpHandle.stop().catch(() => { });
@@ -12291,6 +13261,9 @@ async function startBot() {
12291
13261
  console.warn('[platform] native Windows notice failed:', e instanceof Error ? e.message : e);
12292
13262
  }
12293
13263
  await startUpdates();
13264
+ // Pairing messages of `/runners` turn into «paired» / «expired» (and survive this restart through the registry's notes).
13265
+ if (ENV.servedPlatforms.has('telegram'))
13266
+ startRunnersWatcher();
12294
13267
  // The local speech engine: clean up after an earlier run, re-attach to / start its install. Starts no process.
12295
13268
  void bootSttLocalEngine().catch((e) => console.warn('[STT] boot step failed:', e instanceof Error ? e.message : e));
12296
13269
  console.log(`Launching Telegraf bot (long polling, dropPendingUpdates=${bootMode.dropPendingUpdates})...`);