charness 0.0.0-stage → 3.0.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 (988) hide show
  1. package/.env.example +88 -0
  2. package/DEVELOPMENT.md +110 -0
  3. package/LICENSE +21 -0
  4. package/MIGRATION_1_TO_2.md +24 -0
  5. package/README.md +1024 -2
  6. package/dist/accessControl.d.ts +71 -0
  7. package/dist/accessControl.d.ts.map +1 -0
  8. package/dist/accessControl.js +108 -0
  9. package/dist/accessControl.js.map +1 -0
  10. package/dist/adapters/adapterEventRouting.d.ts +51 -0
  11. package/dist/adapters/adapterEventRouting.d.ts.map +1 -0
  12. package/dist/adapters/adapterEventRouting.js +74 -0
  13. package/dist/adapters/adapterEventRouting.js.map +1 -0
  14. package/dist/adapters/adapterNames.d.ts +19 -0
  15. package/dist/adapters/adapterNames.d.ts.map +1 -0
  16. package/dist/adapters/adapterNames.js +25 -0
  17. package/dist/adapters/adapterNames.js.map +1 -0
  18. package/dist/adapters/claudeCliAdapter.d.ts +1336 -0
  19. package/dist/adapters/claudeCliAdapter.d.ts.map +1 -0
  20. package/dist/adapters/claudeCliAdapter.js +4204 -0
  21. package/dist/adapters/claudeCliAdapter.js.map +1 -0
  22. package/dist/adapters/claudeJsonStreamAdapter.d.ts +548 -0
  23. package/dist/adapters/claudeJsonStreamAdapter.d.ts.map +1 -0
  24. package/dist/adapters/claudeJsonStreamAdapter.js +1810 -0
  25. package/dist/adapters/claudeJsonStreamAdapter.js.map +1 -0
  26. package/dist/adapters/claudePlatformFlags.d.ts +35 -0
  27. package/dist/adapters/claudePlatformFlags.d.ts.map +1 -0
  28. package/dist/adapters/claudePlatformFlags.js +46 -0
  29. package/dist/adapters/claudePlatformFlags.js.map +1 -0
  30. package/dist/adapters/createAdapter.d.ts +252 -0
  31. package/dist/adapters/createAdapter.d.ts.map +1 -0
  32. package/dist/adapters/createAdapter.js +475 -0
  33. package/dist/adapters/createAdapter.js.map +1 -0
  34. package/dist/adapters/openCodeAdapter.d.ts +1446 -0
  35. package/dist/adapters/openCodeAdapter.d.ts.map +1 -0
  36. package/dist/adapters/openCodeAdapter.js +4540 -0
  37. package/dist/adapters/openCodeAdapter.js.map +1 -0
  38. package/dist/adapters/sessionLaunchDefaults.d.ts +28 -0
  39. package/dist/adapters/sessionLaunchDefaults.d.ts.map +1 -0
  40. package/dist/adapters/sessionLaunchDefaults.js +15 -0
  41. package/dist/adapters/sessionLaunchDefaults.js.map +1 -0
  42. package/dist/adapters/terminalAdapter.d.ts +68 -0
  43. package/dist/adapters/terminalAdapter.d.ts.map +1 -0
  44. package/dist/adapters/terminalAdapter.js +371 -0
  45. package/dist/adapters/terminalAdapter.js.map +1 -0
  46. package/dist/agentLogin/agentLogin.d.ts +31 -0
  47. package/dist/agentLogin/agentLogin.d.ts.map +1 -0
  48. package/dist/agentLogin/agentLogin.js +493 -0
  49. package/dist/agentLogin/agentLogin.js.map +1 -0
  50. package/dist/agentTrigger.d.ts +28 -0
  51. package/dist/agentTrigger.d.ts.map +1 -0
  52. package/dist/agentTrigger.js +53 -0
  53. package/dist/agentTrigger.js.map +1 -0
  54. package/dist/apiErrorRetry.d.ts +209 -0
  55. package/dist/apiErrorRetry.d.ts.map +1 -0
  56. package/dist/apiErrorRetry.js +351 -0
  57. package/dist/apiErrorRetry.js.map +1 -0
  58. package/dist/apiRetryKick.d.ts +148 -0
  59. package/dist/apiRetryKick.d.ts.map +1 -0
  60. package/dist/apiRetryKick.js +158 -0
  61. package/dist/apiRetryKick.js.map +1 -0
  62. package/dist/bootClassifier.d.ts +46 -0
  63. package/dist/bootClassifier.d.ts.map +1 -0
  64. package/dist/bootClassifier.js +43 -0
  65. package/dist/bootClassifier.js.map +1 -0
  66. package/dist/bot.d.ts +207 -0
  67. package/dist/bot.d.ts.map +1 -0
  68. package/dist/bot.js +10611 -0
  69. package/dist/bot.js.map +1 -0
  70. package/dist/botFileStorage.d.ts +60 -0
  71. package/dist/botFileStorage.d.ts.map +1 -0
  72. package/dist/botFileStorage.js +205 -0
  73. package/dist/botFileStorage.js.map +1 -0
  74. package/dist/cli/applyDnsFix.d.ts +7 -0
  75. package/dist/cli/applyDnsFix.d.ts.map +1 -0
  76. package/dist/cli/applyDnsFix.js +46 -0
  77. package/dist/cli/applyDnsFix.js.map +1 -0
  78. package/dist/cli/bot.d.ts +27 -0
  79. package/dist/cli/bot.d.ts.map +1 -0
  80. package/dist/cli/bot.js +98 -0
  81. package/dist/cli/bot.js.map +1 -0
  82. package/dist/cli/botEntry.d.ts +3 -0
  83. package/dist/cli/botEntry.d.ts.map +1 -0
  84. package/dist/cli/botEntry.js +11 -0
  85. package/dist/cli/botEntry.js.map +1 -0
  86. package/dist/cli/connectorGuards.d.ts +31 -0
  87. package/dist/cli/connectorGuards.d.ts.map +1 -0
  88. package/dist/cli/connectorGuards.js +159 -0
  89. package/dist/cli/connectorGuards.js.map +1 -0
  90. package/dist/cli/envLoader.d.ts +34 -0
  91. package/dist/cli/envLoader.d.ts.map +1 -0
  92. package/dist/cli/envLoader.js +132 -0
  93. package/dist/cli/envLoader.js.map +1 -0
  94. package/dist/cli/hot.d.ts +82 -0
  95. package/dist/cli/hot.d.ts.map +1 -0
  96. package/dist/cli/hot.js +323 -0
  97. package/dist/cli/hot.js.map +1 -0
  98. package/dist/cli.d.ts +3 -0
  99. package/dist/cli.d.ts.map +1 -0
  100. package/dist/cli.js +130 -0
  101. package/dist/cli.js.map +1 -0
  102. package/dist/connectors/jira/adf.d.ts +72 -0
  103. package/dist/connectors/jira/adf.d.ts.map +1 -0
  104. package/dist/connectors/jira/adf.js +407 -0
  105. package/dist/connectors/jira/adf.js.map +1 -0
  106. package/dist/connectors/jira/agentBinaries.d.ts +14 -0
  107. package/dist/connectors/jira/agentBinaries.d.ts.map +1 -0
  108. package/dist/connectors/jira/agentBinaries.js +75 -0
  109. package/dist/connectors/jira/agentBinaries.js.map +1 -0
  110. package/dist/connectors/jira/answerSink.d.ts +52 -0
  111. package/dist/connectors/jira/answerSink.d.ts.map +1 -0
  112. package/dist/connectors/jira/answerSink.js +220 -0
  113. package/dist/connectors/jira/answerSink.js.map +1 -0
  114. package/dist/connectors/jira/attachmentDownload.d.ts +41 -0
  115. package/dist/connectors/jira/attachmentDownload.d.ts.map +1 -0
  116. package/dist/connectors/jira/attachmentDownload.js +92 -0
  117. package/dist/connectors/jira/attachmentDownload.js.map +1 -0
  118. package/dist/connectors/jira/attachmentTool.d.ts +43 -0
  119. package/dist/connectors/jira/attachmentTool.d.ts.map +1 -0
  120. package/dist/connectors/jira/attachmentTool.js +150 -0
  121. package/dist/connectors/jira/attachmentTool.js.map +1 -0
  122. package/dist/connectors/jira/client.d.ts +490 -0
  123. package/dist/connectors/jira/client.d.ts.map +1 -0
  124. package/dist/connectors/jira/client.js +453 -0
  125. package/dist/connectors/jira/client.js.map +1 -0
  126. package/dist/connectors/jira/config.d.ts +117 -0
  127. package/dist/connectors/jira/config.d.ts.map +1 -0
  128. package/dist/connectors/jira/config.js +350 -0
  129. package/dist/connectors/jira/config.js.map +1 -0
  130. package/dist/connectors/jira/configFile.d.ts +10 -0
  131. package/dist/connectors/jira/configFile.d.ts.map +1 -0
  132. package/dist/connectors/jira/configFile.js +50 -0
  133. package/dist/connectors/jira/configFile.js.map +1 -0
  134. package/dist/connectors/jira/connector.d.ts +68 -0
  135. package/dist/connectors/jira/connector.d.ts.map +1 -0
  136. package/dist/connectors/jira/connector.js +257 -0
  137. package/dist/connectors/jira/connector.js.map +1 -0
  138. package/dist/connectors/jira/contextLedger.d.ts +59 -0
  139. package/dist/connectors/jira/contextLedger.d.ts.map +1 -0
  140. package/dist/connectors/jira/contextLedger.js +194 -0
  141. package/dist/connectors/jira/contextLedger.js.map +1 -0
  142. package/dist/connectors/jira/contextPreamble.d.ts +18 -0
  143. package/dist/connectors/jira/contextPreamble.d.ts.map +1 -0
  144. package/dist/connectors/jira/contextPreamble.js +31 -0
  145. package/dist/connectors/jira/contextPreamble.js.map +1 -0
  146. package/dist/connectors/jira/inbound.d.ts +132 -0
  147. package/dist/connectors/jira/inbound.d.ts.map +1 -0
  148. package/dist/connectors/jira/inbound.js +316 -0
  149. package/dist/connectors/jira/inbound.js.map +1 -0
  150. package/dist/connectors/jira/issueBlocks.d.ts +51 -0
  151. package/dist/connectors/jira/issueBlocks.d.ts.map +1 -0
  152. package/dist/connectors/jira/issueBlocks.js +182 -0
  153. package/dist/connectors/jira/issueBlocks.js.map +1 -0
  154. package/dist/connectors/jira/issueContext.d.ts +25 -0
  155. package/dist/connectors/jira/issueContext.d.ts.map +1 -0
  156. package/dist/connectors/jira/issueContext.js +53 -0
  157. package/dist/connectors/jira/issueContext.js.map +1 -0
  158. package/dist/connectors/jira/issueDelta.d.ts +35 -0
  159. package/dist/connectors/jira/issueDelta.d.ts.map +1 -0
  160. package/dist/connectors/jira/issueDelta.js +50 -0
  161. package/dist/connectors/jira/issueDelta.js.map +1 -0
  162. package/dist/connectors/jira/mediaPlaceholders.d.ts +20 -0
  163. package/dist/connectors/jira/mediaPlaceholders.d.ts.map +1 -0
  164. package/dist/connectors/jira/mediaPlaceholders.js +92 -0
  165. package/dist/connectors/jira/mediaPlaceholders.js.map +1 -0
  166. package/dist/connectors/jira/outbound.d.ts +6 -0
  167. package/dist/connectors/jira/outbound.d.ts.map +1 -0
  168. package/dist/connectors/jira/outbound.js +38 -0
  169. package/dist/connectors/jira/outbound.js.map +1 -0
  170. package/dist/connectors/jira/prompt.d.ts +37 -0
  171. package/dist/connectors/jira/prompt.d.ts.map +1 -0
  172. package/dist/connectors/jira/prompt.js +99 -0
  173. package/dist/connectors/jira/prompt.js.map +1 -0
  174. package/dist/connectors/jira/promptSpill.d.ts +50 -0
  175. package/dist/connectors/jira/promptSpill.d.ts.map +1 -0
  176. package/dist/connectors/jira/promptSpill.js +157 -0
  177. package/dist/connectors/jira/promptSpill.js.map +1 -0
  178. package/dist/connectors/jira/promptText.d.ts +7 -0
  179. package/dist/connectors/jira/promptText.d.ts.map +1 -0
  180. package/dist/connectors/jira/promptText.js +26 -0
  181. package/dist/connectors/jira/promptText.js.map +1 -0
  182. package/dist/connectors/jira/sessionKeyCodec.d.ts +16 -0
  183. package/dist/connectors/jira/sessionKeyCodec.d.ts.map +1 -0
  184. package/dist/connectors/jira/sessionKeyCodec.js +101 -0
  185. package/dist/connectors/jira/sessionKeyCodec.js.map +1 -0
  186. package/dist/connectors/jira/trigger.d.ts +50 -0
  187. package/dist/connectors/jira/trigger.d.ts.map +1 -0
  188. package/dist/connectors/jira/trigger.js +93 -0
  189. package/dist/connectors/jira/trigger.js.map +1 -0
  190. package/dist/connectors/jira/triggerLog.d.ts +51 -0
  191. package/dist/connectors/jira/triggerLog.d.ts.map +1 -0
  192. package/dist/connectors/jira/triggerLog.js +78 -0
  193. package/dist/connectors/jira/triggerLog.js.map +1 -0
  194. package/dist/connectors/jira/unconfirmedPosts.d.ts +58 -0
  195. package/dist/connectors/jira/unconfirmedPosts.d.ts.map +1 -0
  196. package/dist/connectors/jira/unconfirmedPosts.js +105 -0
  197. package/dist/connectors/jira/unconfirmedPosts.js.map +1 -0
  198. package/dist/connectors/telegram/answerSink.d.ts +46 -0
  199. package/dist/connectors/telegram/answerSink.d.ts.map +1 -0
  200. package/dist/connectors/telegram/answerSink.js +71 -0
  201. package/dist/connectors/telegram/answerSink.js.map +1 -0
  202. package/dist/connectors/telegram/commands/autoContinueLimits.d.ts +31 -0
  203. package/dist/connectors/telegram/commands/autoContinueLimits.d.ts.map +1 -0
  204. package/dist/connectors/telegram/commands/autoContinueLimits.js +142 -0
  205. package/dist/connectors/telegram/commands/autoContinueLimits.js.map +1 -0
  206. package/dist/connectors/telegram/commands/botCore.d.ts +46 -0
  207. package/dist/connectors/telegram/commands/botCore.d.ts.map +1 -0
  208. package/dist/connectors/telegram/commands/botCore.js +3 -0
  209. package/dist/connectors/telegram/commands/botCore.js.map +1 -0
  210. package/dist/connectors/telegram/commands/callbackMessage.d.ts +12 -0
  211. package/dist/connectors/telegram/commands/callbackMessage.d.ts.map +1 -0
  212. package/dist/connectors/telegram/commands/callbackMessage.js +13 -0
  213. package/dist/connectors/telegram/commands/callbackMessage.js.map +1 -0
  214. package/dist/connectors/telegram/commands/compaction.d.ts +47 -0
  215. package/dist/connectors/telegram/commands/compaction.d.ts.map +1 -0
  216. package/dist/connectors/telegram/commands/compaction.js +868 -0
  217. package/dist/connectors/telegram/commands/compaction.js.map +1 -0
  218. package/dist/connectors/telegram/commands/displayModes.d.ts +13 -0
  219. package/dist/connectors/telegram/commands/displayModes.d.ts.map +1 -0
  220. package/dist/connectors/telegram/commands/displayModes.js +335 -0
  221. package/dist/connectors/telegram/commands/displayModes.js.map +1 -0
  222. package/dist/connectors/telegram/commands/modelProviders.d.ts +95 -0
  223. package/dist/connectors/telegram/commands/modelProviders.d.ts.map +1 -0
  224. package/dist/connectors/telegram/commands/modelProviders.js +841 -0
  225. package/dist/connectors/telegram/commands/modelProviders.js.map +1 -0
  226. package/dist/connectors/telegram/commands/reminders.d.ts +54 -0
  227. package/dist/connectors/telegram/commands/reminders.d.ts.map +1 -0
  228. package/dist/connectors/telegram/commands/reminders.js +624 -0
  229. package/dist/connectors/telegram/commands/reminders.js.map +1 -0
  230. package/dist/connectors/telegram/fileIntake.d.ts +166 -0
  231. package/dist/connectors/telegram/fileIntake.d.ts.map +1 -0
  232. package/dist/connectors/telegram/fileIntake.js +304 -0
  233. package/dist/connectors/telegram/fileIntake.js.map +1 -0
  234. package/dist/connectors/telegram/fileSendGateway.d.ts +57 -0
  235. package/dist/connectors/telegram/fileSendGateway.d.ts.map +1 -0
  236. package/dist/connectors/telegram/fileSendGateway.js +221 -0
  237. package/dist/connectors/telegram/fileSendGateway.js.map +1 -0
  238. package/dist/connectors/telegram/foreignKeyFallbacks.d.ts +52 -0
  239. package/dist/connectors/telegram/foreignKeyFallbacks.d.ts.map +1 -0
  240. package/dist/connectors/telegram/foreignKeyFallbacks.js +71 -0
  241. package/dist/connectors/telegram/foreignKeyFallbacks.js.map +1 -0
  242. package/dist/connectors/telegram/inbound.d.ts +131 -0
  243. package/dist/connectors/telegram/inbound.d.ts.map +1 -0
  244. package/dist/connectors/telegram/inbound.js +278 -0
  245. package/dist/connectors/telegram/inbound.js.map +1 -0
  246. package/dist/connectors/telegram/languagePicker.d.ts +32 -0
  247. package/dist/connectors/telegram/languagePicker.d.ts.map +1 -0
  248. package/dist/connectors/telegram/languagePicker.js +55 -0
  249. package/dist/connectors/telegram/languagePicker.js.map +1 -0
  250. package/dist/connectors/telegram/messageSplit.d.ts +62 -0
  251. package/dist/connectors/telegram/messageSplit.d.ts.map +1 -0
  252. package/dist/connectors/telegram/messageSplit.js +119 -0
  253. package/dist/connectors/telegram/messageSplit.js.map +1 -0
  254. package/dist/connectors/telegram/outbound.d.ts +62 -0
  255. package/dist/connectors/telegram/outbound.d.ts.map +1 -0
  256. package/dist/connectors/telegram/outbound.js +116 -0
  257. package/dist/connectors/telegram/outbound.js.map +1 -0
  258. package/dist/connectors/telegram/output/createOutputTransport.d.ts +34 -0
  259. package/dist/connectors/telegram/output/createOutputTransport.d.ts.map +1 -0
  260. package/dist/connectors/telegram/output/createOutputTransport.js +77 -0
  261. package/dist/connectors/telegram/output/createOutputTransport.js.map +1 -0
  262. package/dist/connectors/telegram/output/dmOutputTransport.d.ts +47 -0
  263. package/dist/connectors/telegram/output/dmOutputTransport.d.ts.map +1 -0
  264. package/dist/connectors/telegram/output/dmOutputTransport.js +412 -0
  265. package/dist/connectors/telegram/output/dmOutputTransport.js.map +1 -0
  266. package/dist/connectors/telegram/outputBacklogGlue.d.ts +38 -0
  267. package/dist/connectors/telegram/outputBacklogGlue.d.ts.map +1 -0
  268. package/dist/connectors/telegram/outputBacklogGlue.js +49 -0
  269. package/dist/connectors/telegram/outputBacklogGlue.js.map +1 -0
  270. package/dist/connectors/telegram/outputFlushPlan.d.ts +93 -0
  271. package/dist/connectors/telegram/outputFlushPlan.d.ts.map +1 -0
  272. package/dist/connectors/telegram/outputFlushPlan.js +120 -0
  273. package/dist/connectors/telegram/outputFlushPlan.js.map +1 -0
  274. package/dist/connectors/telegram/questionKeyboards.d.ts +30 -0
  275. package/dist/connectors/telegram/questionKeyboards.d.ts.map +1 -0
  276. package/dist/connectors/telegram/questionKeyboards.js +37 -0
  277. package/dist/connectors/telegram/questionKeyboards.js.map +1 -0
  278. package/dist/connectors/telegram/renderAgentHtml.d.ts +27 -0
  279. package/dist/connectors/telegram/renderAgentHtml.d.ts.map +1 -0
  280. package/dist/connectors/telegram/renderAgentHtml.js +97 -0
  281. package/dist/connectors/telegram/renderAgentHtml.js.map +1 -0
  282. package/dist/connectors/telegram/sessionKeyCodec.d.ts +28 -0
  283. package/dist/connectors/telegram/sessionKeyCodec.d.ts.map +1 -0
  284. package/dist/connectors/telegram/sessionKeyCodec.js +107 -0
  285. package/dist/connectors/telegram/sessionKeyCodec.js.map +1 -0
  286. package/dist/connectors/telegram/telegramCallGuard.d.ts +19 -0
  287. package/dist/connectors/telegram/telegramCallGuard.d.ts.map +1 -0
  288. package/dist/connectors/telegram/telegramCallGuard.js +35 -0
  289. package/dist/connectors/telegram/telegramCallGuard.js.map +1 -0
  290. package/dist/connectors/telegram/timezonePicker.d.ts +85 -0
  291. package/dist/connectors/telegram/timezonePicker.d.ts.map +1 -0
  292. package/dist/connectors/telegram/timezonePicker.js +142 -0
  293. package/dist/connectors/telegram/timezonePicker.js.map +1 -0
  294. package/dist/connectors/telegram/updateDispatcher.d.ts +85 -0
  295. package/dist/connectors/telegram/updateDispatcher.d.ts.map +1 -0
  296. package/dist/connectors/telegram/updateDispatcher.js +159 -0
  297. package/dist/connectors/telegram/updateDispatcher.js.map +1 -0
  298. package/dist/connectors/test/connector.d.ts +52 -0
  299. package/dist/connectors/test/connector.d.ts.map +1 -0
  300. package/dist/connectors/test/connector.js +143 -0
  301. package/dist/connectors/test/connector.js.map +1 -0
  302. package/dist/connectors/test/sessionKeyCodec.d.ts +25 -0
  303. package/dist/connectors/test/sessionKeyCodec.d.ts.map +1 -0
  304. package/dist/connectors/test/sessionKeyCodec.js +50 -0
  305. package/dist/connectors/test/sessionKeyCodec.js.map +1 -0
  306. package/dist/diagLog.d.ts +6 -0
  307. package/dist/diagLog.d.ts.map +1 -0
  308. package/dist/diagLog.js +58 -0
  309. package/dist/diagLog.js.map +1 -0
  310. package/dist/effortLevels.d.ts +68 -0
  311. package/dist/effortLevels.d.ts.map +1 -0
  312. package/dist/effortLevels.js +114 -0
  313. package/dist/effortLevels.js.map +1 -0
  314. package/dist/folderName.d.ts +42 -0
  315. package/dist/folderName.d.ts.map +1 -0
  316. package/dist/folderName.js +51 -0
  317. package/dist/folderName.js.map +1 -0
  318. package/dist/i18n/de.d.ts +2 -0
  319. package/dist/i18n/de.d.ts.map +1 -0
  320. package/dist/i18n/de.js +537 -0
  321. package/dist/i18n/de.js.map +1 -0
  322. package/dist/i18n/en.d.ts +7 -0
  323. package/dist/i18n/en.d.ts.map +1 -0
  324. package/dist/i18n/en.js +566 -0
  325. package/dist/i18n/en.js.map +1 -0
  326. package/dist/i18n/es.d.ts +2 -0
  327. package/dist/i18n/es.d.ts.map +1 -0
  328. package/dist/i18n/es.js +537 -0
  329. package/dist/i18n/es.js.map +1 -0
  330. package/dist/i18n/fr.d.ts +2 -0
  331. package/dist/i18n/fr.d.ts.map +1 -0
  332. package/dist/i18n/fr.js +537 -0
  333. package/dist/i18n/fr.js.map +1 -0
  334. package/dist/i18n/hi.d.ts +2 -0
  335. package/dist/i18n/hi.d.ts.map +1 -0
  336. package/dist/i18n/hi.js +537 -0
  337. package/dist/i18n/hi.js.map +1 -0
  338. package/dist/i18n/ja.d.ts +2 -0
  339. package/dist/i18n/ja.d.ts.map +1 -0
  340. package/dist/i18n/ja.js +537 -0
  341. package/dist/i18n/ja.js.map +1 -0
  342. package/dist/i18n/ka.d.ts +2 -0
  343. package/dist/i18n/ka.d.ts.map +1 -0
  344. package/dist/i18n/ka.js +537 -0
  345. package/dist/i18n/ka.js.map +1 -0
  346. package/dist/i18n/pt.d.ts +2 -0
  347. package/dist/i18n/pt.d.ts.map +1 -0
  348. package/dist/i18n/pt.js +537 -0
  349. package/dist/i18n/pt.js.map +1 -0
  350. package/dist/i18n/ru.d.ts +6 -0
  351. package/dist/i18n/ru.d.ts.map +1 -0
  352. package/dist/i18n/ru.js +540 -0
  353. package/dist/i18n/ru.js.map +1 -0
  354. package/dist/i18n/uk.d.ts +8 -0
  355. package/dist/i18n/uk.d.ts.map +1 -0
  356. package/dist/i18n/uk.js +542 -0
  357. package/dist/i18n/uk.js.map +1 -0
  358. package/dist/i18n/uz.d.ts +2 -0
  359. package/dist/i18n/uz.d.ts.map +1 -0
  360. package/dist/i18n/uz.js +537 -0
  361. package/dist/i18n/uz.js.map +1 -0
  362. package/dist/i18n/zh.d.ts +2 -0
  363. package/dist/i18n/zh.d.ts.map +1 -0
  364. package/dist/i18n/zh.js +537 -0
  365. package/dist/i18n/zh.js.map +1 -0
  366. package/dist/i18n.d.ts +115 -0
  367. package/dist/i18n.d.ts.map +1 -0
  368. package/dist/i18n.js +233 -0
  369. package/dist/i18n.js.map +1 -0
  370. package/dist/index.d.ts +3 -0
  371. package/dist/index.d.ts.map +1 -0
  372. package/dist/index.js +10 -0
  373. package/dist/index.js.map +1 -0
  374. package/dist/installManager.d.ts +99 -0
  375. package/dist/installManager.d.ts.map +1 -0
  376. package/dist/installManager.js +943 -0
  377. package/dist/installManager.js.map +1 -0
  378. package/dist/instanceLock.d.ts +161 -0
  379. package/dist/instanceLock.d.ts.map +1 -0
  380. package/dist/instanceLock.js +388 -0
  381. package/dist/instanceLock.js.map +1 -0
  382. package/dist/mcpConfig.d.ts +76 -0
  383. package/dist/mcpConfig.d.ts.map +1 -0
  384. package/dist/mcpConfig.js +270 -0
  385. package/dist/mcpConfig.js.map +1 -0
  386. package/dist/openCodeOpenQuestion.d.ts +44 -0
  387. package/dist/openCodeOpenQuestion.d.ts.map +1 -0
  388. package/dist/openCodeOpenQuestion.js +64 -0
  389. package/dist/openCodeOpenQuestion.js.map +1 -0
  390. package/dist/openCodeQuestionFlow.d.ts +105 -0
  391. package/dist/openCodeQuestionFlow.d.ts.map +1 -0
  392. package/dist/openCodeQuestionFlow.js +143 -0
  393. package/dist/openCodeQuestionFlow.js.map +1 -0
  394. package/dist/openCodeSessionRouting.d.ts +133 -0
  395. package/dist/openCodeSessionRouting.d.ts.map +1 -0
  396. package/dist/openCodeSessionRouting.js +253 -0
  397. package/dist/openCodeSessionRouting.js.map +1 -0
  398. package/dist/openCodeSessionTitle.d.ts +50 -0
  399. package/dist/openCodeSessionTitle.d.ts.map +1 -0
  400. package/dist/openCodeSessionTitle.js +84 -0
  401. package/dist/openCodeSessionTitle.js.map +1 -0
  402. package/dist/outputTrace.d.ts +108 -0
  403. package/dist/outputTrace.d.ts.map +1 -0
  404. package/dist/outputTrace.js +430 -0
  405. package/dist/outputTrace.js.map +1 -0
  406. package/dist/pendingQuestionRepost.d.ts +47 -0
  407. package/dist/pendingQuestionRepost.d.ts.map +1 -0
  408. package/dist/pendingQuestionRepost.js +29 -0
  409. package/dist/pendingQuestionRepost.js.map +1 -0
  410. package/dist/pinnedStatus.d.ts +52 -0
  411. package/dist/pinnedStatus.d.ts.map +1 -0
  412. package/dist/pinnedStatus.js +36 -0
  413. package/dist/pinnedStatus.js.map +1 -0
  414. package/dist/platform/answerSink.d.ts +93 -0
  415. package/dist/platform/answerSink.d.ts.map +1 -0
  416. package/dist/platform/answerSink.js +31 -0
  417. package/dist/platform/answerSink.js.map +1 -0
  418. package/dist/platform/capabilityFallback.d.ts +39 -0
  419. package/dist/platform/capabilityFallback.d.ts.map +1 -0
  420. package/dist/platform/capabilityFallback.js +55 -0
  421. package/dist/platform/capabilityFallback.js.map +1 -0
  422. package/dist/platform/commandRouter.d.ts +51 -0
  423. package/dist/platform/commandRouter.d.ts.map +1 -0
  424. package/dist/platform/commandRouter.js +53 -0
  425. package/dist/platform/commandRouter.js.map +1 -0
  426. package/dist/platform/connectorSet.d.ts +39 -0
  427. package/dist/platform/connectorSet.d.ts.map +1 -0
  428. package/dist/platform/connectorSet.js +55 -0
  429. package/dist/platform/connectorSet.js.map +1 -0
  430. package/dist/platform/inbound.d.ts +147 -0
  431. package/dist/platform/inbound.d.ts.map +1 -0
  432. package/dist/platform/inbound.js +18 -0
  433. package/dist/platform/inbound.js.map +1 -0
  434. package/dist/platform/outbound.d.ts +225 -0
  435. package/dist/platform/outbound.d.ts.map +1 -0
  436. package/dist/platform/outbound.js +30 -0
  437. package/dist/platform/outbound.js.map +1 -0
  438. package/dist/platform/unservedStateGuard.d.ts +27 -0
  439. package/dist/platform/unservedStateGuard.d.ts.map +1 -0
  440. package/dist/platform/unservedStateGuard.js +42 -0
  441. package/dist/platform/unservedStateGuard.js.map +1 -0
  442. package/dist/postToSession.d.ts +94 -0
  443. package/dist/postToSession.d.ts.map +1 -0
  444. package/dist/postToSession.js +56 -0
  445. package/dist/postToSession.js.map +1 -0
  446. package/dist/progressLine.d.ts +180 -0
  447. package/dist/progressLine.d.ts.map +1 -0
  448. package/dist/progressLine.js +293 -0
  449. package/dist/progressLine.js.map +1 -0
  450. package/dist/rateLimiter.d.ts +246 -0
  451. package/dist/rateLimiter.d.ts.map +1 -0
  452. package/dist/rateLimiter.js +555 -0
  453. package/dist/rateLimiter.js.map +1 -0
  454. package/dist/requests/answerRequest.d.ts +57 -0
  455. package/dist/requests/answerRequest.d.ts.map +1 -0
  456. package/dist/requests/answerRequest.js +96 -0
  457. package/dist/requests/answerRequest.js.map +1 -0
  458. package/dist/requests/answerTail.d.ts +83 -0
  459. package/dist/requests/answerTail.d.ts.map +1 -0
  460. package/dist/requests/answerTail.js +102 -0
  461. package/dist/requests/answerTail.js.map +1 -0
  462. package/dist/requests/limitWaitAnswer.d.ts +87 -0
  463. package/dist/requests/limitWaitAnswer.d.ts.map +1 -0
  464. package/dist/requests/limitWaitAnswer.js +83 -0
  465. package/dist/requests/limitWaitAnswer.js.map +1 -0
  466. package/dist/requests/requestGroup.d.ts +44 -0
  467. package/dist/requests/requestGroup.d.ts.map +1 -0
  468. package/dist/requests/requestGroup.js +63 -0
  469. package/dist/requests/requestGroup.js.map +1 -0
  470. package/dist/requests/requestHeader.d.ts +70 -0
  471. package/dist/requests/requestHeader.d.ts.map +1 -0
  472. package/dist/requests/requestHeader.js +83 -0
  473. package/dist/requests/requestHeader.js.map +1 -0
  474. package/dist/requests/requestLedger.d.ts +217 -0
  475. package/dist/requests/requestLedger.d.ts.map +1 -0
  476. package/dist/requests/requestLedger.js +468 -0
  477. package/dist/requests/requestLedger.js.map +1 -0
  478. package/dist/requests/sessionTurnProbe.d.ts +52 -0
  479. package/dist/requests/sessionTurnProbe.d.ts.map +1 -0
  480. package/dist/requests/sessionTurnProbe.js +29 -0
  481. package/dist/requests/sessionTurnProbe.js.map +1 -0
  482. package/dist/requests/types.d.ts +181 -0
  483. package/dist/requests/types.d.ts.map +1 -0
  484. package/dist/requests/types.js +9 -0
  485. package/dist/requests/types.js.map +1 -0
  486. package/dist/requests/wakeUpEngine.d.ts +160 -0
  487. package/dist/requests/wakeUpEngine.d.ts.map +1 -0
  488. package/dist/requests/wakeUpEngine.js +399 -0
  489. package/dist/requests/wakeUpEngine.js.map +1 -0
  490. package/dist/requests/wakeUpRules.d.ts +151 -0
  491. package/dist/requests/wakeUpRules.d.ts.map +1 -0
  492. package/dist/requests/wakeUpRules.js +167 -0
  493. package/dist/requests/wakeUpRules.js.map +1 -0
  494. package/dist/resumeContext.d.ts +87 -0
  495. package/dist/resumeContext.d.ts.map +1 -0
  496. package/dist/resumeContext.js +114 -0
  497. package/dist/resumeContext.js.map +1 -0
  498. package/dist/scheduler/checkRun.d.ts +95 -0
  499. package/dist/scheduler/checkRun.d.ts.map +1 -0
  500. package/dist/scheduler/checkRun.js +172 -0
  501. package/dist/scheduler/checkRun.js.map +1 -0
  502. package/dist/scheduler/delivery.d.ts +99 -0
  503. package/dist/scheduler/delivery.d.ts.map +1 -0
  504. package/dist/scheduler/delivery.js +212 -0
  505. package/dist/scheduler/delivery.js.map +1 -0
  506. package/dist/scheduler/deliveryKind.d.ts +29 -0
  507. package/dist/scheduler/deliveryKind.d.ts.map +1 -0
  508. package/dist/scheduler/deliveryKind.js +39 -0
  509. package/dist/scheduler/deliveryKind.js.map +1 -0
  510. package/dist/scheduler/directoryThreads.d.ts +19 -0
  511. package/dist/scheduler/directoryThreads.d.ts.map +1 -0
  512. package/dist/scheduler/directoryThreads.js +28 -0
  513. package/dist/scheduler/directoryThreads.js.map +1 -0
  514. package/dist/scheduler/engine.d.ts +77 -0
  515. package/dist/scheduler/engine.d.ts.map +1 -0
  516. package/dist/scheduler/engine.js +337 -0
  517. package/dist/scheduler/engine.js.map +1 -0
  518. package/dist/scheduler/injection.d.ts +102 -0
  519. package/dist/scheduler/injection.d.ts.map +1 -0
  520. package/dist/scheduler/injection.js +122 -0
  521. package/dist/scheduler/injection.js.map +1 -0
  522. package/dist/scheduler/mcpBoot.d.ts +65 -0
  523. package/dist/scheduler/mcpBoot.d.ts.map +1 -0
  524. package/dist/scheduler/mcpBoot.js +60 -0
  525. package/dist/scheduler/mcpBoot.js.map +1 -0
  526. package/dist/scheduler/mcpSurface.d.ts +355 -0
  527. package/dist/scheduler/mcpSurface.d.ts.map +1 -0
  528. package/dist/scheduler/mcpSurface.js +1257 -0
  529. package/dist/scheduler/mcpSurface.js.map +1 -0
  530. package/dist/scheduler/rebindResume.d.ts +27 -0
  531. package/dist/scheduler/rebindResume.d.ts.map +1 -0
  532. package/dist/scheduler/rebindResume.js +17 -0
  533. package/dist/scheduler/rebindResume.js.map +1 -0
  534. package/dist/scheduler/recurrence.d.ts +58 -0
  535. package/dist/scheduler/recurrence.d.ts.map +1 -0
  536. package/dist/scheduler/recurrence.js +210 -0
  537. package/dist/scheduler/recurrence.js.map +1 -0
  538. package/dist/scheduler/runLedger.d.ts +59 -0
  539. package/dist/scheduler/runLedger.d.ts.map +1 -0
  540. package/dist/scheduler/runLedger.js +54 -0
  541. package/dist/scheduler/runLedger.js.map +1 -0
  542. package/dist/scheduler/store.d.ts +97 -0
  543. package/dist/scheduler/store.d.ts.map +1 -0
  544. package/dist/scheduler/store.js +124 -0
  545. package/dist/scheduler/store.js.map +1 -0
  546. package/dist/scheduler/timezoneRecompute.d.ts +61 -0
  547. package/dist/scheduler/timezoneRecompute.d.ts.map +1 -0
  548. package/dist/scheduler/timezoneRecompute.js +46 -0
  549. package/dist/scheduler/timezoneRecompute.js.map +1 -0
  550. package/dist/scheduler/types.d.ts +139 -0
  551. package/dist/scheduler/types.d.ts.map +1 -0
  552. package/dist/scheduler/types.js +8 -0
  553. package/dist/scheduler/types.js.map +1 -0
  554. package/dist/sendErrorClassifier.d.ts +45 -0
  555. package/dist/sendErrorClassifier.d.ts.map +1 -0
  556. package/dist/sendErrorClassifier.js +65 -0
  557. package/dist/sendErrorClassifier.js.map +1 -0
  558. package/dist/sessionKey.d.ts +136 -0
  559. package/dist/sessionKey.d.ts.map +1 -0
  560. package/dist/sessionKey.js +153 -0
  561. package/dist/sessionKey.js.map +1 -0
  562. package/dist/sessionPick.d.ts +42 -0
  563. package/dist/sessionPick.d.ts.map +1 -0
  564. package/dist/sessionPick.js +39 -0
  565. package/dist/sessionPick.js.map +1 -0
  566. package/dist/shutdown.d.ts +126 -0
  567. package/dist/shutdown.d.ts.map +1 -0
  568. package/dist/shutdown.js +164 -0
  569. package/dist/shutdown.js.map +1 -0
  570. package/dist/startupPromptBuffer.d.ts +85 -0
  571. package/dist/startupPromptBuffer.d.ts.map +1 -0
  572. package/dist/startupPromptBuffer.js +123 -0
  573. package/dist/startupPromptBuffer.js.map +1 -0
  574. package/dist/state.d.ts +1245 -0
  575. package/dist/state.d.ts.map +1 -0
  576. package/dist/state.js +1830 -0
  577. package/dist/state.js.map +1 -0
  578. package/dist/threadContextPreamble.d.ts +89 -0
  579. package/dist/threadContextPreamble.d.ts.map +1 -0
  580. package/dist/threadContextPreamble.js +102 -0
  581. package/dist/threadContextPreamble.js.map +1 -0
  582. package/dist/threadRouting.d.ts +164 -0
  583. package/dist/threadRouting.d.ts.map +1 -0
  584. package/dist/threadRouting.js +182 -0
  585. package/dist/threadRouting.js.map +1 -0
  586. package/dist/types.d.ts +1015 -0
  587. package/dist/types.d.ts.map +1 -0
  588. package/dist/types.js +3 -0
  589. package/dist/types.js.map +1 -0
  590. package/dist/utils/abortableFifo.d.ts +10 -0
  591. package/dist/utils/abortableFifo.d.ts.map +1 -0
  592. package/dist/utils/abortableFifo.js +55 -0
  593. package/dist/utils/abortableFifo.js.map +1 -0
  594. package/dist/utils/adoptedToolList.d.ts +27 -0
  595. package/dist/utils/adoptedToolList.d.ts.map +1 -0
  596. package/dist/utils/adoptedToolList.js +22 -0
  597. package/dist/utils/adoptedToolList.js.map +1 -0
  598. package/dist/utils/agentEnvironment.d.ts +42 -0
  599. package/dist/utils/agentEnvironment.d.ts.map +1 -0
  600. package/dist/utils/agentEnvironment.js +122 -0
  601. package/dist/utils/agentEnvironment.js.map +1 -0
  602. package/dist/utils/ansiClean.d.ts +11 -0
  603. package/dist/utils/ansiClean.d.ts.map +1 -0
  604. package/dist/utils/ansiClean.js +139 -0
  605. package/dist/utils/ansiClean.js.map +1 -0
  606. package/dist/utils/autoContinueOnLimit.d.ts +49 -0
  607. package/dist/utils/autoContinueOnLimit.d.ts.map +1 -0
  608. package/dist/utils/autoContinueOnLimit.js +69 -0
  609. package/dist/utils/autoContinueOnLimit.js.map +1 -0
  610. package/dist/utils/canonicalPathContainment.d.ts +7 -0
  611. package/dist/utils/canonicalPathContainment.d.ts.map +1 -0
  612. package/dist/utils/canonicalPathContainment.js +48 -0
  613. package/dist/utils/canonicalPathContainment.js.map +1 -0
  614. package/dist/utils/claudeAuthLogin.d.ts +71 -0
  615. package/dist/utils/claudeAuthLogin.d.ts.map +1 -0
  616. package/dist/utils/claudeAuthLogin.js +93 -0
  617. package/dist/utils/claudeAuthLogin.js.map +1 -0
  618. package/dist/utils/claudeChunkClassifier.d.ts +97 -0
  619. package/dist/utils/claudeChunkClassifier.d.ts.map +1 -0
  620. package/dist/utils/claudeChunkClassifier.js +254 -0
  621. package/dist/utils/claudeChunkClassifier.js.map +1 -0
  622. package/dist/utils/claudeCodeVersion.d.ts +31 -0
  623. package/dist/utils/claudeCodeVersion.d.ts.map +1 -0
  624. package/dist/utils/claudeCodeVersion.js +62 -0
  625. package/dist/utils/claudeCodeVersion.js.map +1 -0
  626. package/dist/utils/claudeCompactHook.d.ts +64 -0
  627. package/dist/utils/claudeCompactHook.d.ts.map +1 -0
  628. package/dist/utils/claudeCompactHook.js +98 -0
  629. package/dist/utils/claudeCompactHook.js.map +1 -0
  630. package/dist/utils/claudeLivenessAction.d.ts +208 -0
  631. package/dist/utils/claudeLivenessAction.d.ts.map +1 -0
  632. package/dist/utils/claudeLivenessAction.js +135 -0
  633. package/dist/utils/claudeLivenessAction.js.map +1 -0
  634. package/dist/utils/claudeMcpHeal.d.ts +92 -0
  635. package/dist/utils/claudeMcpHeal.d.ts.map +1 -0
  636. package/dist/utils/claudeMcpHeal.js +91 -0
  637. package/dist/utils/claudeMcpHeal.js.map +1 -0
  638. package/dist/utils/claudeRelayRouting.d.ts +86 -0
  639. package/dist/utils/claudeRelayRouting.d.ts.map +1 -0
  640. package/dist/utils/claudeRelayRouting.js +159 -0
  641. package/dist/utils/claudeRelayRouting.js.map +1 -0
  642. package/dist/utils/claudeRuntimeInfo.d.ts +26 -0
  643. package/dist/utils/claudeRuntimeInfo.d.ts.map +1 -0
  644. package/dist/utils/claudeRuntimeInfo.js +151 -0
  645. package/dist/utils/claudeRuntimeInfo.js.map +1 -0
  646. package/dist/utils/claudeScrapeShapes.d.ts +256 -0
  647. package/dist/utils/claudeScrapeShapes.d.ts.map +1 -0
  648. package/dist/utils/claudeScrapeShapes.js +290 -0
  649. package/dist/utils/claudeScrapeShapes.js.map +1 -0
  650. package/dist/utils/claudeSessionTitle.d.ts +27 -0
  651. package/dist/utils/claudeSessionTitle.d.ts.map +1 -0
  652. package/dist/utils/claudeSessionTitle.js +90 -0
  653. package/dist/utils/claudeSessionTitle.js.map +1 -0
  654. package/dist/utils/claudeStreamJson.d.ts +202 -0
  655. package/dist/utils/claudeStreamJson.d.ts.map +1 -0
  656. package/dist/utils/claudeStreamJson.js +351 -0
  657. package/dist/utils/claudeStreamJson.js.map +1 -0
  658. package/dist/utils/claudeSubagentTail.d.ts +93 -0
  659. package/dist/utils/claudeSubagentTail.d.ts.map +1 -0
  660. package/dist/utils/claudeSubagentTail.js +136 -0
  661. package/dist/utils/claudeSubagentTail.js.map +1 -0
  662. package/dist/utils/clearThreadOutputQueues.d.ts +43 -0
  663. package/dist/utils/clearThreadOutputQueues.d.ts.map +1 -0
  664. package/dist/utils/clearThreadOutputQueues.js +39 -0
  665. package/dist/utils/clearThreadOutputQueues.js.map +1 -0
  666. package/dist/utils/compactCommandRoute.d.ts +45 -0
  667. package/dist/utils/compactCommandRoute.d.ts.map +1 -0
  668. package/dist/utils/compactCommandRoute.js +36 -0
  669. package/dist/utils/compactCommandRoute.js.map +1 -0
  670. package/dist/utils/compactOnIdle.d.ts +329 -0
  671. package/dist/utils/compactOnIdle.d.ts.map +1 -0
  672. package/dist/utils/compactOnIdle.js +379 -0
  673. package/dist/utils/compactOnIdle.js.map +1 -0
  674. package/dist/utils/consoleFileTap.d.ts +51 -0
  675. package/dist/utils/consoleFileTap.d.ts.map +1 -0
  676. package/dist/utils/consoleFileTap.js +86 -0
  677. package/dist/utils/consoleFileTap.js.map +1 -0
  678. package/dist/utils/displayVerbosity.d.ts +44 -0
  679. package/dist/utils/displayVerbosity.d.ts.map +1 -0
  680. package/dist/utils/displayVerbosity.js +75 -0
  681. package/dist/utils/displayVerbosity.js.map +1 -0
  682. package/dist/utils/download.d.ts +72 -0
  683. package/dist/utils/download.d.ts.map +1 -0
  684. package/dist/utils/download.js +216 -0
  685. package/dist/utils/download.js.map +1 -0
  686. package/dist/utils/draftFinalize.d.ts +110 -0
  687. package/dist/utils/draftFinalize.d.ts.map +1 -0
  688. package/dist/utils/draftFinalize.js +100 -0
  689. package/dist/utils/draftFinalize.js.map +1 -0
  690. package/dist/utils/draftId.d.ts +36 -0
  691. package/dist/utils/draftId.d.ts.map +1 -0
  692. package/dist/utils/draftId.js +46 -0
  693. package/dist/utils/draftId.js.map +1 -0
  694. package/dist/utils/draftPacer.d.ts +72 -0
  695. package/dist/utils/draftPacer.d.ts.map +1 -0
  696. package/dist/utils/draftPacer.js +53 -0
  697. package/dist/utils/draftPacer.js.map +1 -0
  698. package/dist/utils/effortStartupKeystroke.d.ts +20 -0
  699. package/dist/utils/effortStartupKeystroke.d.ts.map +1 -0
  700. package/dist/utils/effortStartupKeystroke.js +28 -0
  701. package/dist/utils/effortStartupKeystroke.js.map +1 -0
  702. package/dist/utils/ensureSessionPlan.d.ts +70 -0
  703. package/dist/utils/ensureSessionPlan.d.ts.map +1 -0
  704. package/dist/utils/ensureSessionPlan.js +54 -0
  705. package/dist/utils/ensureSessionPlan.js.map +1 -0
  706. package/dist/utils/fileSendPlan.d.ts +217 -0
  707. package/dist/utils/fileSendPlan.d.ts.map +1 -0
  708. package/dist/utils/fileSendPlan.js +291 -0
  709. package/dist/utils/fileSendPlan.js.map +1 -0
  710. package/dist/utils/fileSendService.d.ts +94 -0
  711. package/dist/utils/fileSendService.d.ts.map +1 -0
  712. package/dist/utils/fileSendService.js +387 -0
  713. package/dist/utils/fileSendService.js.map +1 -0
  714. package/dist/utils/groupFinalizePlan.d.ts +38 -0
  715. package/dist/utils/groupFinalizePlan.d.ts.map +1 -0
  716. package/dist/utils/groupFinalizePlan.js +35 -0
  717. package/dist/utils/groupFinalizePlan.js.map +1 -0
  718. package/dist/utils/identicalOutputGuard.d.ts +83 -0
  719. package/dist/utils/identicalOutputGuard.d.ts.map +1 -0
  720. package/dist/utils/identicalOutputGuard.js +99 -0
  721. package/dist/utils/identicalOutputGuard.js.map +1 -0
  722. package/dist/utils/isoTimestamp.d.ts +16 -0
  723. package/dist/utils/isoTimestamp.d.ts.map +1 -0
  724. package/dist/utils/isoTimestamp.js +34 -0
  725. package/dist/utils/isoTimestamp.js.map +1 -0
  726. package/dist/utils/jsonStreamBusyWatchdog.d.ts +130 -0
  727. package/dist/utils/jsonStreamBusyWatchdog.d.ts.map +1 -0
  728. package/dist/utils/jsonStreamBusyWatchdog.js +122 -0
  729. package/dist/utils/jsonStreamBusyWatchdog.js.map +1 -0
  730. package/dist/utils/jsonStreamHost.d.ts +227 -0
  731. package/dist/utils/jsonStreamHost.d.ts.map +1 -0
  732. package/dist/utils/jsonStreamHost.js +417 -0
  733. package/dist/utils/jsonStreamHost.js.map +1 -0
  734. package/dist/utils/keyedTransitionQueue.d.ts +18 -0
  735. package/dist/utils/keyedTransitionQueue.d.ts.map +1 -0
  736. package/dist/utils/keyedTransitionQueue.js +34 -0
  737. package/dist/utils/keyedTransitionQueue.js.map +1 -0
  738. package/dist/utils/limitEpisodeRecovery.d.ts +92 -0
  739. package/dist/utils/limitEpisodeRecovery.d.ts.map +1 -0
  740. package/dist/utils/limitEpisodeRecovery.js +133 -0
  741. package/dist/utils/limitEpisodeRecovery.js.map +1 -0
  742. package/dist/utils/limitHeldPromptQueue.d.ts +48 -0
  743. package/dist/utils/limitHeldPromptQueue.d.ts.map +1 -0
  744. package/dist/utils/limitHeldPromptQueue.js +59 -0
  745. package/dist/utils/limitHeldPromptQueue.js.map +1 -0
  746. package/dist/utils/limitHeldPrompts.d.ts +58 -0
  747. package/dist/utils/limitHeldPrompts.d.ts.map +1 -0
  748. package/dist/utils/limitHeldPrompts.js +65 -0
  749. package/dist/utils/limitHeldPrompts.js.map +1 -0
  750. package/dist/utils/linkPreviewSuppression.d.ts +18 -0
  751. package/dist/utils/linkPreviewSuppression.d.ts.map +1 -0
  752. package/dist/utils/linkPreviewSuppression.js +37 -0
  753. package/dist/utils/linkPreviewSuppression.js.map +1 -0
  754. package/dist/utils/localClock.d.ts +15 -0
  755. package/dist/utils/localClock.d.ts.map +1 -0
  756. package/dist/utils/localClock.js +29 -0
  757. package/dist/utils/localClock.js.map +1 -0
  758. package/dist/utils/markdownTableRender.d.ts +83 -0
  759. package/dist/utils/markdownTableRender.d.ts.map +1 -0
  760. package/dist/utils/markdownTableRender.js +298 -0
  761. package/dist/utils/markdownTableRender.js.map +1 -0
  762. package/dist/utils/mediaGroupCollector.d.ts +51 -0
  763. package/dist/utils/mediaGroupCollector.d.ts.map +1 -0
  764. package/dist/utils/mediaGroupCollector.js +90 -0
  765. package/dist/utils/mediaGroupCollector.js.map +1 -0
  766. package/dist/utils/messageSendService.d.ts +136 -0
  767. package/dist/utils/messageSendService.d.ts.map +1 -0
  768. package/dist/utils/messageSendService.js +165 -0
  769. package/dist/utils/messageSendService.js.map +1 -0
  770. package/dist/utils/minutesOverride.d.ts +7 -0
  771. package/dist/utils/minutesOverride.d.ts.map +1 -0
  772. package/dist/utils/minutesOverride.js +16 -0
  773. package/dist/utils/minutesOverride.js.map +1 -0
  774. package/dist/utils/modelPickerPlan.d.ts +119 -0
  775. package/dist/utils/modelPickerPlan.d.ts.map +1 -0
  776. package/dist/utils/modelPickerPlan.js +171 -0
  777. package/dist/utils/modelPickerPlan.js.map +1 -0
  778. package/dist/utils/modelSetReplyDecision.d.ts +67 -0
  779. package/dist/utils/modelSetReplyDecision.d.ts.map +1 -0
  780. package/dist/utils/modelSetReplyDecision.js +27 -0
  781. package/dist/utils/modelSetReplyDecision.js.map +1 -0
  782. package/dist/utils/openCodeAuthLogin.d.ts +181 -0
  783. package/dist/utils/openCodeAuthLogin.d.ts.map +1 -0
  784. package/dist/utils/openCodeAuthLogin.js +393 -0
  785. package/dist/utils/openCodeAuthLogin.js.map +1 -0
  786. package/dist/utils/openCodeCompactPlugin.d.ts +81 -0
  787. package/dist/utils/openCodeCompactPlugin.d.ts.map +1 -0
  788. package/dist/utils/openCodeCompactPlugin.js +146 -0
  789. package/dist/utils/openCodeCompactPlugin.js.map +1 -0
  790. package/dist/utils/openCodeTurnActivity.d.ts +67 -0
  791. package/dist/utils/openCodeTurnActivity.d.ts.map +1 -0
  792. package/dist/utils/openCodeTurnActivity.js +27 -0
  793. package/dist/utils/openCodeTurnActivity.js.map +1 -0
  794. package/dist/utils/outputDebounce.d.ts +24 -0
  795. package/dist/utils/outputDebounce.d.ts.map +1 -0
  796. package/dist/utils/outputDebounce.js +30 -0
  797. package/dist/utils/outputDebounce.js.map +1 -0
  798. package/dist/utils/outputFlushTiming.d.ts +54 -0
  799. package/dist/utils/outputFlushTiming.d.ts.map +1 -0
  800. package/dist/utils/outputFlushTiming.js +48 -0
  801. package/dist/utils/outputFlushTiming.js.map +1 -0
  802. package/dist/utils/paginateList.d.ts +25 -0
  803. package/dist/utils/paginateList.d.ts.map +1 -0
  804. package/dist/utils/paginateList.js +32 -0
  805. package/dist/utils/paginateList.js.map +1 -0
  806. package/dist/utils/paneDiff.d.ts +55 -0
  807. package/dist/utils/paneDiff.d.ts.map +1 -0
  808. package/dist/utils/paneDiff.js +88 -0
  809. package/dist/utils/paneDiff.js.map +1 -0
  810. package/dist/utils/paneResizeGuard.d.ts +69 -0
  811. package/dist/utils/paneResizeGuard.d.ts.map +1 -0
  812. package/dist/utils/paneResizeGuard.js +91 -0
  813. package/dist/utils/paneResizeGuard.js.map +1 -0
  814. package/dist/utils/persistAdapterSessionIds.d.ts +39 -0
  815. package/dist/utils/persistAdapterSessionIds.d.ts.map +1 -0
  816. package/dist/utils/persistAdapterSessionIds.js +28 -0
  817. package/dist/utils/persistAdapterSessionIds.js.map +1 -0
  818. package/dist/utils/pinnedBannerSkipDecision.d.ts +44 -0
  819. package/dist/utils/pinnedBannerSkipDecision.d.ts.map +1 -0
  820. package/dist/utils/pinnedBannerSkipDecision.js +20 -0
  821. package/dist/utils/pinnedBannerSkipDecision.js.map +1 -0
  822. package/dist/utils/pollBackoff.d.ts +45 -0
  823. package/dist/utils/pollBackoff.d.ts.map +1 -0
  824. package/dist/utils/pollBackoff.js +44 -0
  825. package/dist/utils/pollBackoff.js.map +1 -0
  826. package/dist/utils/promptDelivery.d.ts +46 -0
  827. package/dist/utils/promptDelivery.d.ts.map +1 -0
  828. package/dist/utils/promptDelivery.js +49 -0
  829. package/dist/utils/promptDelivery.js.map +1 -0
  830. package/dist/utils/providerDisconnectPlan.d.ts +82 -0
  831. package/dist/utils/providerDisconnectPlan.d.ts.map +1 -0
  832. package/dist/utils/providerDisconnectPlan.js +110 -0
  833. package/dist/utils/providerDisconnectPlan.js.map +1 -0
  834. package/dist/utils/rateLimitLog.d.ts +48 -0
  835. package/dist/utils/rateLimitLog.d.ts.map +1 -0
  836. package/dist/utils/rateLimitLog.js +43 -0
  837. package/dist/utils/rateLimitLog.js.map +1 -0
  838. package/dist/utils/recentRelayWindow.d.ts +165 -0
  839. package/dist/utils/recentRelayWindow.d.ts.map +1 -0
  840. package/dist/utils/recentRelayWindow.js +284 -0
  841. package/dist/utils/recentRelayWindow.js.map +1 -0
  842. package/dist/utils/recvPreviewRedaction.d.ts +23 -0
  843. package/dist/utils/recvPreviewRedaction.d.ts.map +1 -0
  844. package/dist/utils/recvPreviewRedaction.js +39 -0
  845. package/dist/utils/recvPreviewRedaction.js.map +1 -0
  846. package/dist/utils/reminderFlow.d.ts +148 -0
  847. package/dist/utils/reminderFlow.d.ts.map +1 -0
  848. package/dist/utils/reminderFlow.js +122 -0
  849. package/dist/utils/reminderFlow.js.map +1 -0
  850. package/dist/utils/reminderScheduleText.d.ts +87 -0
  851. package/dist/utils/reminderScheduleText.d.ts.map +1 -0
  852. package/dist/utils/reminderScheduleText.js +135 -0
  853. package/dist/utils/reminderScheduleText.js.map +1 -0
  854. package/dist/utils/reminderWizard.d.ts +710 -0
  855. package/dist/utils/reminderWizard.d.ts.map +1 -0
  856. package/dist/utils/reminderWizard.js +1164 -0
  857. package/dist/utils/reminderWizard.js.map +1 -0
  858. package/dist/utils/replyQuote.d.ts +111 -0
  859. package/dist/utils/replyQuote.d.ts.map +1 -0
  860. package/dist/utils/replyQuote.js +122 -0
  861. package/dist/utils/replyQuote.js.map +1 -0
  862. package/dist/utils/resolveBinary.d.ts +25 -0
  863. package/dist/utils/resolveBinary.d.ts.map +1 -0
  864. package/dist/utils/resolveBinary.js +99 -0
  865. package/dist/utils/resolveBinary.js.map +1 -0
  866. package/dist/utils/retryAfterHeader.d.ts +7 -0
  867. package/dist/utils/retryAfterHeader.d.ts.map +1 -0
  868. package/dist/utils/retryAfterHeader.js +17 -0
  869. package/dist/utils/retryAfterHeader.js.map +1 -0
  870. package/dist/utils/rotatingJsonlFile.d.ts +40 -0
  871. package/dist/utils/rotatingJsonlFile.d.ts.map +1 -0
  872. package/dist/utils/rotatingJsonlFile.js +106 -0
  873. package/dist/utils/rotatingJsonlFile.js.map +1 -0
  874. package/dist/utils/rotatingLogFile.d.ts +25 -0
  875. package/dist/utils/rotatingLogFile.d.ts.map +1 -0
  876. package/dist/utils/rotatingLogFile.js +74 -0
  877. package/dist/utils/rotatingLogFile.js.map +1 -0
  878. package/dist/utils/sendRateTracker.d.ts +59 -0
  879. package/dist/utils/sendRateTracker.d.ts.map +1 -0
  880. package/dist/utils/sendRateTracker.js +144 -0
  881. package/dist/utils/sendRateTracker.js.map +1 -0
  882. package/dist/utils/serialQueue.d.ts +5 -0
  883. package/dist/utils/serialQueue.d.ts.map +1 -0
  884. package/dist/utils/serialQueue.js +14 -0
  885. package/dist/utils/serialQueue.js.map +1 -0
  886. package/dist/utils/sseStreamLifecycle.d.ts +32 -0
  887. package/dist/utils/sseStreamLifecycle.d.ts.map +1 -0
  888. package/dist/utils/sseStreamLifecycle.js +41 -0
  889. package/dist/utils/sseStreamLifecycle.js.map +1 -0
  890. package/dist/utils/startupReadiness.d.ts +119 -0
  891. package/dist/utils/startupReadiness.d.ts.map +1 -0
  892. package/dist/utils/startupReadiness.js +148 -0
  893. package/dist/utils/startupReadiness.js.map +1 -0
  894. package/dist/utils/statusFlushDecision.d.ts +18 -0
  895. package/dist/utils/statusFlushDecision.d.ts.map +1 -0
  896. package/dist/utils/statusFlushDecision.js +53 -0
  897. package/dist/utils/statusFlushDecision.js.map +1 -0
  898. package/dist/utils/subagentRender.d.ts +52 -0
  899. package/dist/utils/subagentRender.d.ts.map +1 -0
  900. package/dist/utils/subagentRender.js +66 -0
  901. package/dist/utils/subagentRender.js.map +1 -0
  902. package/dist/utils/subagentStatusRender.d.ts +90 -0
  903. package/dist/utils/subagentStatusRender.d.ts.map +1 -0
  904. package/dist/utils/subagentStatusRender.js +124 -0
  905. package/dist/utils/subagentStatusRender.js.map +1 -0
  906. package/dist/utils/telegramError.d.ts +2 -0
  907. package/dist/utils/telegramError.d.ts.map +1 -0
  908. package/dist/utils/telegramError.js +16 -0
  909. package/dist/utils/telegramError.js.map +1 -0
  910. package/dist/utils/terminalEmitPlan.d.ts +74 -0
  911. package/dist/utils/terminalEmitPlan.d.ts.map +1 -0
  912. package/dist/utils/terminalEmitPlan.js +86 -0
  913. package/dist/utils/terminalEmitPlan.js.map +1 -0
  914. package/dist/utils/thinkingRender.d.ts +74 -0
  915. package/dist/utils/thinkingRender.d.ts.map +1 -0
  916. package/dist/utils/thinkingRender.js +85 -0
  917. package/dist/utils/thinkingRender.js.map +1 -0
  918. package/dist/utils/threadStatusReport.d.ts +55 -0
  919. package/dist/utils/threadStatusReport.d.ts.map +1 -0
  920. package/dist/utils/threadStatusReport.js +65 -0
  921. package/dist/utils/threadStatusReport.js.map +1 -0
  922. package/dist/utils/threadToggle.d.ts +29 -0
  923. package/dist/utils/threadToggle.d.ts.map +1 -0
  924. package/dist/utils/threadToggle.js +39 -0
  925. package/dist/utils/threadToggle.js.map +1 -0
  926. package/dist/utils/timezone.d.ts +125 -0
  927. package/dist/utils/timezone.d.ts.map +1 -0
  928. package/dist/utils/timezone.js +278 -0
  929. package/dist/utils/timezone.js.map +1 -0
  930. package/dist/utils/tmuxExec.d.ts +61 -0
  931. package/dist/utils/tmuxExec.d.ts.map +1 -0
  932. package/dist/utils/tmuxExec.js +109 -0
  933. package/dist/utils/tmuxExec.js.map +1 -0
  934. package/dist/utils/tmuxSessionName.d.ts +34 -0
  935. package/dist/utils/tmuxSessionName.d.ts.map +1 -0
  936. package/dist/utils/tmuxSessionName.js +51 -0
  937. package/dist/utils/tmuxSessionName.js.map +1 -0
  938. package/dist/utils/toolResultRender.d.ts +61 -0
  939. package/dist/utils/toolResultRender.d.ts.map +1 -0
  940. package/dist/utils/toolResultRender.js +62 -0
  941. package/dist/utils/toolResultRender.js.map +1 -0
  942. package/dist/utils/topicRequest.d.ts +61 -0
  943. package/dist/utils/topicRequest.d.ts.map +1 -0
  944. package/dist/utils/topicRequest.js +68 -0
  945. package/dist/utils/topicRequest.js.map +1 -0
  946. package/dist/utils/topicView.d.ts +55 -0
  947. package/dist/utils/topicView.d.ts.map +1 -0
  948. package/dist/utils/topicView.js +56 -0
  949. package/dist/utils/topicView.js.map +1 -0
  950. package/dist/utils/transcribeAudio.d.ts +89 -0
  951. package/dist/utils/transcribeAudio.d.ts.map +1 -0
  952. package/dist/utils/transcribeAudio.js +249 -0
  953. package/dist/utils/transcribeAudio.js.map +1 -0
  954. package/dist/utils/transientFrames.d.ts +52 -0
  955. package/dist/utils/transientFrames.d.ts.map +1 -0
  956. package/dist/utils/transientFrames.js +41 -0
  957. package/dist/utils/transientFrames.js.map +1 -0
  958. package/dist/utils/typingActive.d.ts +35 -0
  959. package/dist/utils/typingActive.d.ts.map +1 -0
  960. package/dist/utils/typingActive.js +22 -0
  961. package/dist/utils/typingActive.js.map +1 -0
  962. package/dist/utils/typingLoopBackstop.d.ts +64 -0
  963. package/dist/utils/typingLoopBackstop.d.ts.map +1 -0
  964. package/dist/utils/typingLoopBackstop.js +68 -0
  965. package/dist/utils/typingLoopBackstop.js.map +1 -0
  966. package/dist/utils/verbosityRender.d.ts +18 -0
  967. package/dist/utils/verbosityRender.d.ts.map +1 -0
  968. package/dist/utils/verbosityRender.js +17 -0
  969. package/dist/utils/verbosityRender.js.map +1 -0
  970. package/dist/utils/wedgeRecovery.d.ts +57 -0
  971. package/dist/utils/wedgeRecovery.d.ts.map +1 -0
  972. package/dist/utils/wedgeRecovery.js +24 -0
  973. package/dist/utils/wedgeRecovery.js.map +1 -0
  974. package/dist/utils.d.ts +30 -0
  975. package/dist/utils.d.ts.map +1 -0
  976. package/dist/utils.js +61 -0
  977. package/dist/utils.js.map +1 -0
  978. package/dist/validation.d.ts +113 -0
  979. package/dist/validation.d.ts.map +1 -0
  980. package/dist/validation.js +226 -0
  981. package/dist/validation.js.map +1 -0
  982. package/dist/voiceQueue.d.ts +7 -0
  983. package/dist/voiceQueue.d.ts.map +1 -0
  984. package/dist/voiceQueue.js +33 -0
  985. package/dist/voiceQueue.js.map +1 -0
  986. package/examples/.env.example +17 -0
  987. package/examples/docker-compose.yml +36 -0
  988. package/package.json +83 -4
@@ -0,0 +1,4540 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.OpenCodeAdapter = exports.schedulerMcpRetryDelaysMs = exports.openCodeErrorMessageMaxLength = exports.providerRetryReplacementStartTimeoutMs = exports.criticalSseEventTypes = exports.openCodeRuntimeContextHydrationConcurrency = void 0;
37
+ exports.normaliseOpenCodeSseEvent = normaliseOpenCodeSseEvent;
38
+ exports.buildDirectoryScopedPath = buildDirectoryScopedPath;
39
+ exports.mapOpenCodeMessagesToTurns = mapOpenCodeMessagesToTurns;
40
+ exports.countOpenCodeAssistantMessagesSinceId = countOpenCodeAssistantMessagesSinceId;
41
+ exports.getLatestOpenCodeAssistantMessageId = getLatestOpenCodeAssistantMessageId;
42
+ exports.getLatestOpenCodeCompactionSummary = getLatestOpenCodeCompactionSummary;
43
+ exports.checkIsOpenCodeTurnInFlight = checkIsOpenCodeTurnInFlight;
44
+ exports.checkIsOpenCodeAbortError = checkIsOpenCodeAbortError;
45
+ exports.getOpenCodeErrorMessage = getOpenCodeErrorMessage;
46
+ exports.checkIsOpenCodeSessionBusy = checkIsOpenCodeSessionBusy;
47
+ exports.applyOpenCodeStatusEvent = applyOpenCodeStatusEvent;
48
+ exports.buildPromptBody = buildPromptBody;
49
+ exports.checkIsValidProviderId = checkIsValidProviderId;
50
+ exports.getLatestOpenCodeParentAssistantContext = getLatestOpenCodeParentAssistantContext;
51
+ exports.buildProviderAuthPath = buildProviderAuthPath;
52
+ exports.checkNeedsSchedulerMcpReregister = checkNeedsSchedulerMcpReregister;
53
+ exports.getSchedulerMcpRetryDelayMs = getSchedulerMcpRetryDelayMs;
54
+ exports.buildProviderApiAuthPayload = buildProviderApiAuthPayload;
55
+ exports.resetOpenCodeProviderCaches = resetOpenCodeProviderCaches;
56
+ exports.parseProvidersResponse = parseProvidersResponse;
57
+ const events_1 = require("events");
58
+ const child_process_1 = require("child_process");
59
+ const util_1 = require("util");
60
+ const fs = __importStar(require("fs"));
61
+ const path = __importStar(require("path"));
62
+ const sessionKey_1 = require("../sessionKey");
63
+ const apiErrorRetry_1 = require("../apiErrorRetry");
64
+ const installManager_1 = require("../installManager");
65
+ const state_1 = require("../state");
66
+ const diagLog_1 = require("../diagLog");
67
+ const openCodeSessionRouting_1 = require("../openCodeSessionRouting");
68
+ const i18n_1 = require("../i18n");
69
+ const resumeContext_1 = require("../resumeContext");
70
+ const threadContextPreamble_1 = require("../threadContextPreamble");
71
+ const openCodeOpenQuestion_1 = require("../openCodeOpenQuestion");
72
+ const injection_1 = require("../scheduler/injection");
73
+ const openCodeSessionTitle_1 = require("../openCodeSessionTitle");
74
+ const effortLevels_1 = require("../effortLevels");
75
+ const providerDisconnectPlan_1 = require("../utils/providerDisconnectPlan");
76
+ const sseStreamLifecycle_1 = require("../utils/sseStreamLifecycle");
77
+ const subagentRender_1 = require("../utils/subagentRender");
78
+ const displayVerbosity_1 = require("../utils/displayVerbosity");
79
+ const openCodeTurnActivity_1 = require("../utils/openCodeTurnActivity");
80
+ const openCodeCompactPlugin_1 = require("../utils/openCodeCompactPlugin");
81
+ const openCodeAuthLogin_1 = require("../utils/openCodeAuthLogin");
82
+ const execAsync = (0, util_1.promisify)(child_process_1.exec);
83
+ /**
84
+ * Persist per-thread model selection so it survives bot restarts.
85
+ * Stored in `DATA_DIR` (resolved via `resolveDataDir()` for parity with
86
+ * `state.json` and the Claude adapter) as a JSON map keyed by serialised
87
+ * `SessionKey`: `{ "<chatId>:<threadId>": "provider/model" }` (plan §10.3, D22).
88
+ *
89
+ * Audit S3 / #9: previous fallback chain `DATA_DIR || HOME || /tmp` drifted
90
+ * from `state.ts:resolveDataDir`, which uses `~/.telegramCode` when
91
+ * `DATA_DIR` is unset. Two bots on one host sharing a Linux user would have
92
+ * stored prefs in `$HOME/.opencode-model-prefs.json` and silently collided.
93
+ *
94
+ * Older versions of the bot used `{ "<userId>": "provider/model" }`. Those
95
+ * entries are silently ignored after the 2.0 upgrade — users can re-select
96
+ * their model via `/model`; we deliberately don't try to migrate (one of two
97
+ * adapters, easy to recover, not worth the migration complexity).
98
+ */
99
+ const modelStateFile = path.join((0, state_1.resolveDataDir)(), '.opencode-model-prefs.json');
100
+ function loadSavedModel(key) {
101
+ try {
102
+ if (!fs.existsSync(modelStateFile))
103
+ return null;
104
+ const data = JSON.parse(fs.readFileSync(modelStateFile, 'utf-8'));
105
+ const saved = data[(0, sessionKey_1.keyToString)(key)];
106
+ if (!saved)
107
+ return null;
108
+ const idx = saved.indexOf('/');
109
+ if (idx <= 0)
110
+ return null;
111
+ return { providerID: saved.slice(0, idx), modelID: saved.slice(idx + 1) };
112
+ }
113
+ catch (e) {
114
+ // Audit S15: surface and archive a corrupt prefs file so the next
115
+ // save can start clean; previous silent return left users wondering
116
+ // why /model selections didn't stick across restarts.
117
+ console.error(`[OpenCode] loadSavedModel failed:`, e instanceof Error ? e.message : e);
118
+ if (fs.existsSync(modelStateFile)) {
119
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
120
+ try {
121
+ fs.renameSync(modelStateFile, `${modelStateFile}.corrupted-${ts}`);
122
+ }
123
+ catch (re) {
124
+ console.warn(`[OpenCode] archive of corrupt prefs failed:`, re);
125
+ }
126
+ }
127
+ return null;
128
+ }
129
+ }
130
+ function saveModelPref(key, label) {
131
+ try {
132
+ let data = {};
133
+ if (fs.existsSync(modelStateFile)) {
134
+ data = JSON.parse(fs.readFileSync(modelStateFile, 'utf-8'));
135
+ }
136
+ data[(0, sessionKey_1.keyToString)(key)] = label;
137
+ fs.writeFileSync(modelStateFile, JSON.stringify(data, null, 2));
138
+ }
139
+ catch (e) {
140
+ console.error(`[OpenCode] Failed to save model pref:`, e instanceof Error ? e.message : e);
141
+ }
142
+ }
143
+ /**
144
+ * @description Per-thread OpenCode effort (variant) selection. Plan
145
+ * 2026-05-30-effort-command, D6 — mirrors the model-prefs pattern above
146
+ * exactly: same DATA_DIR placement, same `{ "<chatId>:<threadId>": "<level>" }`
147
+ * shape, same corrupt-file archive behaviour.
148
+ *
149
+ * Effort is applied **per-prompt** by `sendPromptAsync` as the model
150
+ * `variant` on the prompt body. Persisting it here means the choice
151
+ * survives bot restarts and re-attached SSE sessions.
152
+ */
153
+ const effortStateFile = path.join((0, state_1.resolveDataDir)(), '.opencode-effort-prefs.json');
154
+ function loadEffortPrefs() {
155
+ try {
156
+ if (!fs.existsSync(effortStateFile))
157
+ return {};
158
+ const raw = fs.readFileSync(effortStateFile, 'utf-8');
159
+ const parsed = JSON.parse(raw);
160
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
161
+ ? parsed
162
+ : {};
163
+ }
164
+ catch (e) {
165
+ console.error(`[OpenCode] loadEffortPrefs failed:`, e instanceof Error ? e.message : e);
166
+ if (fs.existsSync(effortStateFile)) {
167
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
168
+ try {
169
+ fs.renameSync(effortStateFile, `${effortStateFile}.corrupted-${ts}`);
170
+ }
171
+ catch (re) {
172
+ console.warn(`[OpenCode] archive of corrupt effort prefs failed:`, re);
173
+ }
174
+ }
175
+ return {};
176
+ }
177
+ }
178
+ function loadSavedEffort(key) {
179
+ const prefs = loadEffortPrefs();
180
+ return prefs[(0, sessionKey_1.keyToString)(key)] ?? null;
181
+ }
182
+ function saveEffortPref(key, level) {
183
+ try {
184
+ const data = loadEffortPrefs();
185
+ data[(0, sessionKey_1.keyToString)(key)] = level;
186
+ fs.writeFileSync(effortStateFile, JSON.stringify(data, null, 2));
187
+ }
188
+ catch (e) {
189
+ console.error(`[OpenCode] Failed to save effort pref:`, e instanceof Error ? e.message : e);
190
+ }
191
+ }
192
+ function clearEffortPref(key) {
193
+ try {
194
+ const data = loadEffortPrefs();
195
+ if (!((0, sessionKey_1.keyToString)(key) in data))
196
+ return;
197
+ delete data[(0, sessionKey_1.keyToString)(key)];
198
+ fs.writeFileSync(effortStateFile, JSON.stringify(data, null, 2));
199
+ }
200
+ catch (e) {
201
+ console.error(`[OpenCode] Failed to clear effort pref:`, e instanceof Error ? e.message : e);
202
+ }
203
+ }
204
+ /** How many recent user message ids a session remembers to de-duplicate `message.updated`. */
205
+ const seenUserMessageIdsMax = 100;
206
+ /**
207
+ * OpenCode `/global/event` wraps the real event in `payload`, while `/event`
208
+ * emits it directly. Normalise both shapes before dispatching.
209
+ */
210
+ function normaliseOpenCodeSseEvent(raw) {
211
+ if (!raw || typeof raw !== 'object')
212
+ return null;
213
+ const candidate = raw;
214
+ const event = typeof candidate.type === 'string'
215
+ ? candidate
216
+ : candidate.payload && typeof candidate.payload === 'object'
217
+ ? candidate.payload
218
+ : null;
219
+ if (!event || typeof event.type !== 'string')
220
+ return null;
221
+ const properties = event.properties && typeof event.properties === 'object'
222
+ ? event.properties
223
+ : {};
224
+ const directory = typeof candidate.directory === 'string' && candidate.directory
225
+ ? candidate.directory
226
+ : undefined;
227
+ return directory !== undefined
228
+ ? { type: event.type, properties, directory }
229
+ : { type: event.type, properties };
230
+ }
231
+ /**
232
+ * @description Append the `?directory=` instance selector to an API path when
233
+ * the owning instance is known. Without it the server resolves its serve-cwd
234
+ * default instance, whose in-memory state (questions, permissions) does not
235
+ * contain requests raised in other project instances — the reply then fails
236
+ * with 404 even though the request is alive in its own instance.
237
+ */
238
+ function buildDirectoryScopedPath(basePath, directory) {
239
+ if (!directory)
240
+ return basePath;
241
+ return `${basePath}?directory=${encodeURIComponent(directory)}`;
242
+ }
243
+ /**
244
+ * @description One renderability rule for a message `part`, shared by
245
+ * {@link mapOpenCodeMessagesToTurns} (turn body) and
246
+ * {@link countOpenCodeAssistantMessagesSinceId} (missed count) so both agree on
247
+ * what counts as visible prose: a non-empty `{ type: 'text' }` part (tool / step
248
+ * / empty parts don't count). Type guard so the caller can read `part.text` as a
249
+ * string without a cast.
250
+ */
251
+ function checkIsRenderableTextPart(part) {
252
+ return (!!part &&
253
+ typeof part === 'object' &&
254
+ part.type === 'text' &&
255
+ typeof part.text === 'string' &&
256
+ part.text.trim().length > 0);
257
+ }
258
+ /**
259
+ * @description Map raw `GET /session/:id/message` records to the last `limit`
260
+ * conversational turns (oldest→newest) for the resume context block.
261
+ *
262
+ * Pure + exported (no I/O) so it is unit-testable. For each record it joins the
263
+ * text of every `{ type: 'text' }` part (skipping tool / step / empty parts),
264
+ * trims, and emits a {@link RecentTurn} only when the role is user/assistant
265
+ * AND the joined text is non-empty. Every field is guarded with `typeof` /
266
+ * `Array.isArray` so a malformed record is skipped, never crashes — no casts.
267
+ * Keeps only the last `limit` turns.
268
+ */
269
+ function mapOpenCodeMessagesToTurns(records, limit) {
270
+ if (!Array.isArray(records))
271
+ return [];
272
+ const turns = [];
273
+ for (const record of records) {
274
+ if (!record || typeof record !== 'object')
275
+ continue;
276
+ const { info, parts } = record;
277
+ const role = info?.role;
278
+ if (role !== 'user' && role !== 'assistant')
279
+ continue;
280
+ if (!Array.isArray(parts))
281
+ continue;
282
+ const textChunks = [];
283
+ for (const part of parts) {
284
+ if (checkIsRenderableTextPart(part))
285
+ textChunks.push(part.text.trim());
286
+ }
287
+ if (textChunks.length === 0)
288
+ continue;
289
+ turns.push({ role, text: textChunks.join('\n\n') });
290
+ }
291
+ return turns.slice(-limit);
292
+ }
293
+ /**
294
+ * @description Count the renderable assistant messages that appear AFTER the
295
+ * watermark message in a `GET /session/:id/message` payload — the OpenCode side
296
+ * of "how many agent messages did the user miss while the bot was down".
297
+ *
298
+ * Pure + exported (no I/O) so it is unit-testable against a records array. Walks
299
+ * the chronological records (oldest→newest), skipping everything up to and
300
+ * including the message whose `info.id === watermarkId`, then counts each later
301
+ * `assistant`-role record that carries at least one non-empty text part (the
302
+ * same renderability rule as {@link mapOpenCodeMessagesToTurns}, so the count
303
+ * matches what a turn body would show).
304
+ *
305
+ * `isWatermarkKnown` is `false` — and `missedCount` `0` — when the payload is
306
+ * not an array, no `watermarkId` was given, or the id is absent from the records
307
+ * (pruned / different session). The caller treats that as the fallback
308
+ * (no-count) recap, never a crash.
309
+ */
310
+ function countOpenCodeAssistantMessagesSinceId(records, watermarkId) {
311
+ if (!Array.isArray(records) || !watermarkId) {
312
+ return { missedCount: 0, isWatermarkKnown: false };
313
+ }
314
+ let foundWatermark = false;
315
+ let missedCount = 0;
316
+ for (const record of records) {
317
+ if (!record || typeof record !== 'object')
318
+ continue;
319
+ const { info, parts } = record;
320
+ if (!foundWatermark) {
321
+ if (info?.id === watermarkId)
322
+ foundWatermark = true;
323
+ continue;
324
+ }
325
+ if (info?.role !== 'assistant' || !Array.isArray(parts))
326
+ continue;
327
+ if (parts.some(checkIsRenderableTextPart))
328
+ missedCount += 1;
329
+ }
330
+ if (!foundWatermark)
331
+ return { missedCount: 0, isWatermarkKnown: false };
332
+ return { missedCount, isWatermarkKnown: true };
333
+ }
334
+ /**
335
+ * @description Last assistant message `id` in a `GET /session/:id/message`
336
+ * payload — the OpenCode reattach-recap HEAD watermark ("everything currently in
337
+ * the record is accounted for"). Pure + exported (no I/O) so it is unit-testable.
338
+ *
339
+ * Walks the chronological records and returns the `info.id` of the LAST record
340
+ * whose `info.role === 'assistant'` and whose `info.id` is a string; returns
341
+ * `undefined` for a non-array payload or when no assistant message carries an id
342
+ * (head unknown → the caller omits the watermark and retries next reattach).
343
+ *
344
+ * Anchors on the last ASSISTANT message (not the last record of any role) to
345
+ * mirror the live-advance watermark ({@link OpenCodeSession.lastMessageId}, set
346
+ * on the parent turn's final assistant message) and the count keyed off it in
347
+ * {@link countOpenCodeAssistantMessagesSinceId} — so the next reattach's
348
+ * `[watermark, …)` window starts at the true tail and yields 0 on a clean
349
+ * reattach. Unlike the missed-count it does NOT require renderable text: the
350
+ * watermark must advance past every assistant message seen, tool-only ones too.
351
+ */
352
+ function getLatestOpenCodeAssistantMessageId(records) {
353
+ if (!Array.isArray(records))
354
+ return undefined;
355
+ let latestId;
356
+ for (const record of records) {
357
+ if (!record || typeof record !== 'object')
358
+ continue;
359
+ const { info } = record;
360
+ if (info?.role === 'assistant' && typeof info.id === 'string')
361
+ latestId = info.id;
362
+ }
363
+ return latestId;
364
+ }
365
+ /**
366
+ * @description Text of the NEWEST compaction summary in a `GET /session/:id/message`
367
+ * payload — the assistant message the fork stored with `info.summary === true`.
368
+ * Joins its renderable text parts (same rule as {@link mapOpenCodeMessagesToTurns}).
369
+ * Pure + exported (no I/O) so it is unit-testable. Returns `null` for a non-array
370
+ * payload or when no summary message with text is present, so F2's notice omits
371
+ * the closing block rather than posting an empty one.
372
+ */
373
+ function getLatestOpenCodeCompactionSummary(records) {
374
+ if (!Array.isArray(records))
375
+ return null;
376
+ let summaryText = null;
377
+ for (const record of records) {
378
+ if (!record || typeof record !== 'object')
379
+ continue;
380
+ const { info, parts } = record;
381
+ if (info?.role !== 'assistant' || info.summary !== true || !Array.isArray(parts))
382
+ continue;
383
+ const chunks = [];
384
+ for (const part of parts) {
385
+ if (checkIsRenderableTextPart(part))
386
+ chunks.push(part.text.trim());
387
+ }
388
+ if (chunks.length > 0)
389
+ summaryText = chunks.join('\n\n');
390
+ }
391
+ return summaryText;
392
+ }
393
+ /**
394
+ * @description Best-effort "the turn is still in flight" signal for the reattach
395
+ * recap, derived from the already-fetched `GET /session/:id/message` payload —
396
+ * NO extra HTTP probe. True only when the LAST record is an `assistant` message
397
+ * with no `finish` reason yet (a turn the agent was mid-way through when the bot
398
+ * went down). Any uncertainty (empty / non-array payload, last record not a
399
+ * finished-less assistant message) → `false`, so the trailing "still working"
400
+ * line is never shown on a guess.
401
+ */
402
+ function checkIsOpenCodeTurnInFlight(records) {
403
+ if (!Array.isArray(records) || records.length === 0)
404
+ return false;
405
+ const last = records[records.length - 1];
406
+ if (!last || typeof last !== 'object')
407
+ return false;
408
+ const { info } = last;
409
+ return info?.role === 'assistant' && info.finish === undefined;
410
+ }
411
+ /** Delay (ms) to batch SSE text deltas before emitting output event */
412
+ const sseOutputBatchMs = 500;
413
+ /**
414
+ * Name of OpenCode's delegation tool — the parent session invokes it to run a
415
+ * sub-agent in a CHILD session (verified live 2026-06-10: its tool part carries
416
+ * `state.title` + `state.input.{description, subagent_type, prompt}`, and the
417
+ * completed `state.output` embeds the child session id).
418
+ */
419
+ const delegationToolName = 'task';
420
+ /**
421
+ * Fixed `SseStreamState.directory` label for the single `/global/event` stream
422
+ * in logs/diag. The global stream has no per-folder scope (it multiplexes every
423
+ * project instance), so its "directory" is a constant marker, not a selector.
424
+ */
425
+ const globalStreamLabel = '<global>';
426
+ /** Base delay (ms) for exponential backoff on SSE reconnect */
427
+ const sseReconnectBaseDelayMs = 2000;
428
+ /**
429
+ * SSE reconnect never gives up — the bot must reconnect until success — but the
430
+ * backoff is capped so steady-state retries settle at a fixed interval instead
431
+ * of growing unbounded: delay = min(base · 2^min(attempt, exp), maxDelay).
432
+ */
433
+ const maxSseReconnectBackoffExponent = 5;
434
+ const maxSseReconnectDelayMs = 60_000;
435
+ /** Measured cadence of OpenCode `server.heartbeat` on the global event stream. */
436
+ const sseHeartbeatIntervalMs = 10_000;
437
+ /**
438
+ * If no SSE bytes — not even a heartbeat — arrive within this window, the
439
+ * stream is silently dead (TCP still open, nothing delivered) and the parked
440
+ * `reader.read()` would never return. 4× the heartbeat tolerates a few dropped
441
+ * beats before forcing a reconnect, so it can't false-fire on a healthy idle
442
+ * stream.
443
+ */
444
+ const sseStallTimeoutMs = sseHeartbeatIntervalMs * 4;
445
+ /** Cap on tracked child→parent session links (subagent lineage). */
446
+ const maxTrackedSessionLineageEntries = 1000;
447
+ /** Bound silent-restart metadata reads so many active sessions do not overload OpenCode. */
448
+ exports.openCodeRuntimeContextHydrationConcurrency = 3;
449
+ /**
450
+ * Minimum gap between diag-logged "sse drop" lines for the SAME
451
+ * (eventType, eventSessionId). A truly orphaned session streams hundreds of
452
+ * deltas; without throttling the diag log floods (B19). One line per
453
+ * (type, session) per window is enough to spot a lost turn.
454
+ */
455
+ const sseDropLogThrottleMs = 60_000;
456
+ /** Bound on the drop-throttle map so an unbounded run of distinct orphan
457
+ * sessions can't grow it without limit (matches the lineage-map discipline). */
458
+ const maxSseDropThrottleEntries = 500;
459
+ /** Synthetic "event type" namespacing the throttle key for ignored foreign
460
+ * busy=true status events, so they share the drop-throttle map without
461
+ * colliding with real `session.status` drop entries. */
462
+ const foreignBusyIgnoredLogType = 'busy-ignored';
463
+ /**
464
+ * SSE event types whose loss makes a turn silently hang — diag-logged on drop.
465
+ * `question.asked` / `permission.asked` are here so an unrouted one is LOGGED
466
+ * (the user's question vanishing silently was the worst failure mode): they DO
467
+ * carry a real top-level `sessionID` in the current OpenCode build (verified
468
+ * live 2026-06-08), so they resolve like any other event — by sessionID, with a
469
+ * directory fallback — and a genuine miss is loud, never a no-op.
470
+ */
471
+ exports.criticalSseEventTypes = new Set([
472
+ 'message.part.updated',
473
+ 'message.part.delta',
474
+ 'message.updated',
475
+ 'session.idle',
476
+ 'session.error',
477
+ 'question.asked',
478
+ 'permission.asked',
479
+ ]);
480
+ /**
481
+ * Event types the dispatcher intentionally ignores WITHOUT a stdout log. On the
482
+ * global stream the bot sees OWNED sessions emit global-only bookkeeping events
483
+ * (`sync` / `session.diff`) the per-directory stream never surfaced; logging
484
+ * each "unhandled SSE event" would spam stdout (plan 2026-06-17 S2). The silent
485
+ * `server.heartbeat` is handled before dispatch and is not listed here.
486
+ */
487
+ const ignoredSseEventTypes = new Set(['sync', 'session.diff']);
488
+ /**
489
+ * @description Grace period before the bot-side fallback rename checks whether
490
+ * opencode's native auto-title has landed. Auto-title is generated as a side
491
+ * effect of the first prompt's LLM turn; it was observed live to appear within
492
+ * ~2-3 s. We wait comfortably longer so the fallback only fires when auto-title
493
+ * genuinely failed — it must never overwrite a real LLM name with a raw snippet.
494
+ */
495
+ const fallbackRenameGraceMs = 8000;
496
+ /** Milliseconds per minute for rendering OpenCode's provider-retry notice. */
497
+ const providerRetryMsPerMinute = 60_000;
498
+ /**
499
+ * @description Headroom on top of the worst-case event-stream gap before a
500
+ * replacement prompt is declared never-started.
501
+ */
502
+ const providerRetryReplacementStartHeadroomMs = 20_000;
503
+ /**
504
+ * @description How long a post-provider-retry replacement prompt may take to
505
+ * produce its first `busy` status before the adapter declares that it never
506
+ * started a turn. Derived from the SSE bounds rather than a flat number: the
507
+ * `busy` arrives sub-second on the happy path, but it is DELIVERED over the
508
+ * event stream, so a stall detected at {@link sseStallTimeoutMs} followed by a
509
+ * fully backed-off reconnect ({@link maxSseReconnectDelayMs}) can hide it for
510
+ * their sum. A flat 60s sat inside that window, so a stream hiccup would have
511
+ * been read as a dead turn and forked/restarted a session that was answering.
512
+ * Waiting only delays recovery; firing early discards live work.
513
+ */
514
+ exports.providerRetryReplacementStartTimeoutMs = sseStallTimeoutMs + maxSseReconnectDelayMs + providerRetryReplacementStartHeadroomMs;
515
+ /**
516
+ * @description Console / diag labels per cause of a "prompt delivered but no
517
+ * turn ran" report, kept as data so the single emit choke point stays free of
518
+ * branchy string building. The `wedged` diag label is unchanged from before the
519
+ * bound existed, so log greps for it keep matching.
520
+ */
521
+ const noTurnResponseCauseLabels = {
522
+ wedgedIdle: { console: 'wedged session', diag: 'wedged' },
523
+ replacementNeverStarted: {
524
+ console: 'post-retry replacement never started',
525
+ diag: 'replacement-never-started',
526
+ },
527
+ };
528
+ /**
529
+ * @description Whether a `session.error` message is the bot-issued abort of a
530
+ * running generation (`POST /session/:id/abort`) — the ONLY source of an
531
+ * "Aborted" error in OpenCode (the user has no other abort trigger). It fires
532
+ * on the question-cancel SIGINT, `/esc`, and the provider-retry interrupt, and
533
+ * must be SWALLOWED rather than surfaced as "OpenCode error: Aborted" — that
534
+ * bogus line was the third message on the question-cancel path (parity with the
535
+ * json-stream "Claude error: API error" swallow). Matched as the exact bare
536
+ * word so a real provider error that merely quotes "aborted" still surfaces.
537
+ */
538
+ function checkIsOpenCodeAbortError(errorMsg) {
539
+ return errorMsg.trim().toLowerCase() === 'aborted';
540
+ }
541
+ const genericOpenCodeErrorMessage = 'OpenCode request failed';
542
+ /** Placeholder written over every redacted credential. */
543
+ const openCodeErrorRedactedMarker = '[redacted]';
544
+ /**
545
+ * Cap for a surfaced error string. A provider can attach a whole
546
+ * request/response dump; the head carries the readable reason, the tail is
547
+ * noise that would flood the topic.
548
+ */
549
+ exports.openCodeErrorMessageMaxLength = 500;
550
+ const openCodeErrorAuthSchemeRe = /\b(Bearer|Basic)\s+\S+/gi;
551
+ /**
552
+ * Redaction is BEST-EFFORT and exists for the STRING path only — a provider's
553
+ * prose message or an HTTP error-response body, which must be surfaced for the
554
+ * user to act on. Being name-based it cannot promise to catch a credential a
555
+ * provider ships under a field name (or in a shape) nobody has seen yet.
556
+ *
557
+ * That is precisely why an unrecognised STRUCTURED payload is deliberately NOT
558
+ * rendered: it collapses to {@link genericOpenCodeErrorMessage} instead of
559
+ * being serialised through these rules. Escaped nested JSON, header-pair
560
+ * arrays (`[["x-api-key","…"]]`), unlisted field names and bare values all slip
561
+ * past a name-based rule, and the rendered text goes straight into a Telegram
562
+ * topic and the console log.
563
+ *
564
+ * The value branch stops at a JSON/prose separator instead of running to the
565
+ * next whitespace: a greedy `\S+` swallowed the rest of the object, taking the
566
+ * later fields with it — including the `429` / "rate limited" markers
567
+ * {@link classifyAgentApiError} needs to arm the auto-retry.
568
+ */
569
+ const openCodeErrorSecretAssignmentRe = /\b((?:api|auth|access|refresh|id|app|client|private|session|user|x)?[_ -]?(?:api[_ -]?key|key|token|secret|password|passwd|pwd|authorization|credential|cookie|signature)s?)\b("?\s*[:=]\s*)(?:(?:Bearer|Basic)\s+)?(?:"[^"]*"|'[^']*'|[^\s,{}\]"']+)/gi;
570
+ /**
571
+ * Credential VALUE shapes, redacted whatever carries them: a provider echoes a
572
+ * rejected key in prose with no field name at all ("401 invalid credential
573
+ * sk-ant-…"), inside a header-pair array, or in a URL query string — none of
574
+ * which the name-based rule above can see.
575
+ */
576
+ const openCodeErrorCredentialValueRe = /\bsk-[A-Za-z0-9_-]+|\bghp_[A-Za-z0-9]+|\bAIza[A-Za-z0-9_-]+|\beyJ[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*/g;
577
+ /**
578
+ * URL userinfo (`https://user:password@host`). The password half is not
579
+ * required: a bare `https://<token>@host` is just as much a credential.
580
+ */
581
+ const openCodeErrorUrlUserInfoRe = /(\w+:\/\/)[^\s/@]+@/g;
582
+ /**
583
+ * @description Redact the credentials a surfaced error string is known to carry
584
+ * and cap it. Redaction runs BEFORE the cap: capping first could cut a secret
585
+ * assignment in half and leave the head of the credential visible.
586
+ */
587
+ function redactOpenCodeErrorMessage(message) {
588
+ return message
589
+ .replace(openCodeErrorSecretAssignmentRe, `$1$2${openCodeErrorRedactedMarker}`)
590
+ .replace(openCodeErrorAuthSchemeRe, `$1 ${openCodeErrorRedactedMarker}`)
591
+ .replace(openCodeErrorCredentialValueRe, openCodeErrorRedactedMarker)
592
+ .replace(openCodeErrorUrlUserInfoRe, `$1${openCodeErrorRedactedMarker}@`)
593
+ .slice(0, exports.openCodeErrorMessageMaxLength);
594
+ }
595
+ /**
596
+ * @description Extract the readable, SAFE-TO-SURFACE error form from OpenCode's
597
+ * persisted and SSE payload shapes. Every return value of this function reaches
598
+ * a Telegram topic and the console log, so a payload whose shape carries no
599
+ * known message field yields the fixed {@link genericOpenCodeErrorMessage}: such
600
+ * a payload is an arbitrary provider dump (a rejected request body, a header
601
+ * list, a response echo) and `/connect` PUTs the user's provider key, so
602
+ * rendering it is a credential-leak path that name-based redaction cannot close.
603
+ * The retry classifier reads {@link getOpenCodeErrorClassificationText} instead.
604
+ */
605
+ function getOpenCodeErrorMessage(error) {
606
+ if (typeof error === 'string')
607
+ return redactOpenCodeErrorMessage(error);
608
+ if (!error || typeof error !== 'object')
609
+ return genericOpenCodeErrorMessage;
610
+ const errorRecord = error;
611
+ if (errorRecord.data && typeof errorRecord.data === 'object') {
612
+ const errorData = errorRecord.data;
613
+ if (typeof errorData.message === 'string')
614
+ return redactOpenCodeErrorMessage(errorData.message);
615
+ }
616
+ if (typeof errorRecord.message === 'string')
617
+ return redactOpenCodeErrorMessage(errorRecord.message);
618
+ return genericOpenCodeErrorMessage;
619
+ }
620
+ /**
621
+ * @description Serialise an error payload FOR CLASSIFICATION ONLY. The result
622
+ * is UNREDACTED and may contain the user's provider key verbatim: it must never
623
+ * be emitted to a topic, never logged, and never returned to a caller that
624
+ * surfaces text — the single legal consumer is
625
+ * {@link classifyAgentApiError}, which only runs regexes over it and returns an
626
+ * error CLASS.
627
+ *
628
+ * It exists because the safe user-facing text collapses an unrecognised payload
629
+ * shape to a constant, and the classifier reads text to decide whether an error
630
+ * is an auto-retryable rate-limit / usage-limit. Feeding it that constant
631
+ * silently cancelled the retry of a real rate limit, so the two paths are split:
632
+ * the user gets the constant, the classifier gets the full payload.
633
+ */
634
+ function getOpenCodeErrorClassificationText(error) {
635
+ if (typeof error === 'string')
636
+ return error;
637
+ if (error instanceof Error)
638
+ return error.message;
639
+ if (!error || typeof error !== 'object')
640
+ return '';
641
+ try {
642
+ // A payload whose `toJSON` opts out yields undefined rather than text.
643
+ const serialized = JSON.stringify(error);
644
+ return serialized ?? '';
645
+ }
646
+ catch {
647
+ // Circular / non-serialisable payload (e.g. a self-referencing error object).
648
+ return '';
649
+ }
650
+ }
651
+ /**
652
+ * @description Sync "is this session occupied with a turn?" decision for the
653
+ * scheduler's wait-for-idle loop ({@link AgentAdapter.checkIsBusy}): busy when
654
+ * its own generation runs, a sub-agent runs, or context is compacting. This is
655
+ * a read-only probe — a new prompt to a busy session is never aborted; OpenCode
656
+ * queues it via `prompt_async` and reads it promptly, so unlike the Claude TUI
657
+ * there is no interrupt-before-prompt (user decision 2026-06-06). Pure +
658
+ * exported for unit testing.
659
+ */
660
+ function checkIsOpenCodeSessionBusy(state) {
661
+ return state.isBusy || state.isCompacting || state.busyChildCount > 0;
662
+ }
663
+ /**
664
+ * @description Apply a busy/idle transition (from `session.status` or
665
+ * `session.idle`) to a session's busy-tracking state. The own session's status
666
+ * drives `isBusy`; a lineage-VERIFIED descendant's (sub-agent child session's)
667
+ * status maintains `busyChildSessionIds` — a child going idle must NOT clear
668
+ * the parent's `isBusy`. A foreign non-descendant id (e.g. a wedged sibling
669
+ * top-level session routed in via the directory fallback) is never recorded:
670
+ * its busy=true is ignored — recording it would pin the thread busy forever,
671
+ * since a wedged session never goes idle (live incident 2026-06-10) — while
672
+ * its busy=false still deletes, self-healing ids that slipped in before
673
+ * verification existed. Pure + exported so the routing is unit-testable.
674
+ *
675
+ * @returns `true` when a foreign non-descendant busy=true was ignored, so the
676
+ * caller can diag-log it (throttled — never per event).
677
+ */
678
+ function applyOpenCodeStatusEvent(tracking, ownSessionId, eventSessionId, isBusy, isVerifiedDescendant) {
679
+ if (!eventSessionId || eventSessionId === ownSessionId) {
680
+ tracking.isBusy = isBusy;
681
+ }
682
+ else if (!isBusy) {
683
+ tracking.busyChildSessionIds.delete(eventSessionId);
684
+ }
685
+ else if (isVerifiedDescendant) {
686
+ tracking.busyChildSessionIds.add(eventSessionId);
687
+ }
688
+ else {
689
+ return true;
690
+ }
691
+ return false;
692
+ }
693
+ /**
694
+ * @description Default timeout for non-SSE HTTP requests to OpenCode.
695
+ * 30 s comfortably covers `prompt_async` (returns 204 immediately) and
696
+ * `getSessions` / `getModels` (file scans on the server side). The SSE
697
+ * stream uses its own long-lived connection without this timeout.
698
+ */
699
+ const apiRequestTimeoutMs = 30_000;
700
+ /**
701
+ * @description Validate `OPENCODE_URL`. SSRF guard for audit S7 / #34:
702
+ * an operator-controlled env var was previously trusted verbatim, so a
703
+ * misconfigured `OPENCODE_URL=http://169.254.169.254/` (or similar
704
+ * metadata endpoint) would route every adapter call to that host. We
705
+ * restrict the scheme to http/https and the host to loopback unless
706
+ * `OPENCODE_ALLOW_REMOTE=1` opts in.
707
+ */
708
+ function validateOpenCodeUrl(raw) {
709
+ let parsed;
710
+ try {
711
+ parsed = new URL(raw);
712
+ }
713
+ catch {
714
+ throw new Error(`OPENCODE_URL is not a valid URL: ${raw}`);
715
+ }
716
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
717
+ throw new Error(`OPENCODE_URL must use http(s); got ${parsed.protocol}`);
718
+ }
719
+ const host = parsed.hostname;
720
+ const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1';
721
+ if (!isLoopback && process.env.OPENCODE_ALLOW_REMOTE !== '1') {
722
+ throw new Error(`OPENCODE_URL points at non-loopback host ${host}; set OPENCODE_ALLOW_REMOTE=1 to allow remote OpenCode servers`);
723
+ }
724
+ return parsed.origin + parsed.pathname.replace(/\/$/, '');
725
+ }
726
+ /** Cache for available models from OpenCode CLI */
727
+ let cachedModels = null;
728
+ let modelsCacheTime = 0;
729
+ const modelsCacheTtlMs = 5 * 60 * 1000; // 5 minutes
730
+ /**
731
+ * @description Cache for `GET /config/providers` (OpenCode 1.15.x). Same
732
+ * 5-minute TTL as the models cache — variant maps change about as often as
733
+ * the model list (operator edits `opencode.json`, restarts server).
734
+ * Shape (observed against opencode 1.15.12):
735
+ * { providers: [ { id, name, models: { <modelId>: { variants?: {...} } } } ] }
736
+ * but newer versions may key by provider id directly. The parser below
737
+ * tolerates both.
738
+ */
739
+ let cachedProviders = null;
740
+ let providersCacheTime = 0;
741
+ const providersCacheTtlMs = 5 * 60 * 1000;
742
+ const providerIdRe = /^[a-z0-9][a-z0-9._-]*$/;
743
+ /**
744
+ * @description Build the JSON body for a `prompt_async` POST. Pure + exported
745
+ * so the two per-prompt overrides — the model selector and the reasoning-effort
746
+ * `variant` — can be unit-tested without a live session. Both are optional and
747
+ * ride the same body, parallel to each other.
748
+ *
749
+ * @returns Body with `parts` always present; `model` only when a model override
750
+ * is set; `variant` only when an effort level is set.
751
+ */
752
+ function buildPromptBody(input, modelOverride, effortLevel) {
753
+ const body = {
754
+ parts: [{ type: 'text', text: input }],
755
+ };
756
+ if (modelOverride) {
757
+ const modelParam = { modelID: modelOverride.modelID };
758
+ if (modelOverride.providerID) {
759
+ modelParam.providerID = modelOverride.providerID;
760
+ }
761
+ body.model = modelParam;
762
+ }
763
+ if (effortLevel) {
764
+ body.variant = effortLevel;
765
+ }
766
+ return body;
767
+ }
768
+ function checkIsValidProviderId(providerId) {
769
+ return providerIdRe.test(providerId);
770
+ }
771
+ function checkIsOpenCodeRecord(value) {
772
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
773
+ }
774
+ function checkIsOpenCodeTokenCount(value) {
775
+ return typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value >= 0;
776
+ }
777
+ function getOpenCodeContextUsedTokens(usage) {
778
+ if (!checkIsOpenCodeRecord(usage) || !checkIsOpenCodeTokenCount(usage.input))
779
+ return null;
780
+ const outputTokens = usage.output;
781
+ const cache = usage.cache;
782
+ if ((outputTokens !== undefined && !checkIsOpenCodeTokenCount(outputTokens)) ||
783
+ (cache !== undefined && !checkIsOpenCodeRecord(cache))) {
784
+ return null;
785
+ }
786
+ const cacheReadTokens = cache?.read;
787
+ const cacheWriteTokens = cache?.write;
788
+ if ((cacheReadTokens !== undefined && !checkIsOpenCodeTokenCount(cacheReadTokens)) ||
789
+ (cacheWriteTokens !== undefined && !checkIsOpenCodeTokenCount(cacheWriteTokens))) {
790
+ return null;
791
+ }
792
+ return usage.input + (cacheReadTokens ?? 0) + (cacheWriteTokens ?? 0) + (outputTokens ?? 0);
793
+ }
794
+ function checkIsOpenCodePendingContextPlaceholder(finish, contextUsedTokens) {
795
+ return contextUsedTokens === 0 && (typeof finish !== 'string' || !finish.trim());
796
+ }
797
+ function getOpenCodeRuntimeModel(providerID, modelID) {
798
+ if (typeof providerID !== 'string' ||
799
+ !providerID.trim() ||
800
+ typeof modelID !== 'string' ||
801
+ !modelID.trim()) {
802
+ return null;
803
+ }
804
+ return { providerID, modelID };
805
+ }
806
+ /**
807
+ * @description Return the newest parent assistant message with a valid complete
808
+ * input-context usage total from a session-history payload. A malformed or
809
+ * foreign record is ignored rather than making a previous valid observation
810
+ * unusable.
811
+ */
812
+ function getLatestOpenCodeParentAssistantContext(records, sessionId) {
813
+ if (!Array.isArray(records))
814
+ return null;
815
+ let latestContext = null;
816
+ for (const record of records) {
817
+ if (!checkIsOpenCodeRecord(record) || !checkIsOpenCodeRecord(record.info))
818
+ continue;
819
+ const info = record.info;
820
+ if (info.role !== 'assistant' || typeof info.id !== 'string' || !info.id.trim())
821
+ continue;
822
+ if (info.sessionID !== undefined && info.sessionID !== sessionId)
823
+ continue;
824
+ if (info.error !== undefined && checkIsOpenCodeAbortError(getOpenCodeErrorMessage(info.error)))
825
+ continue;
826
+ const contextUsedTokens = getOpenCodeContextUsedTokens(info.tokens);
827
+ if (contextUsedTokens === null)
828
+ continue;
829
+ if (checkIsOpenCodePendingContextPlaceholder(info.finish, contextUsedTokens))
830
+ continue;
831
+ const model = getOpenCodeRuntimeModel(info.providerID, info.modelID);
832
+ latestContext = {
833
+ messageId: info.id,
834
+ ...(model ? { model } : {}),
835
+ contextUsedTokens,
836
+ };
837
+ }
838
+ return latestContext;
839
+ }
840
+ function getOpenCodeModelVariants(model) {
841
+ const variants = model.variants;
842
+ return checkIsOpenCodeRecord(variants) ? Object.keys(variants) : [];
843
+ }
844
+ function getOpenCodeModelContextWindowTokens(model) {
845
+ const limit = model.limit;
846
+ if (!checkIsOpenCodeRecord(limit) || !checkIsOpenCodeTokenCount(limit.context))
847
+ return null;
848
+ return limit.context;
849
+ }
850
+ function buildProviderAuthPath(providerId) {
851
+ return `/auth/${encodeURIComponent(providerId)}`;
852
+ }
853
+ /**
854
+ * @description Pure decision for the boot self-heal: given opencode's live
855
+ * `GET /mcp` status map (`{ <serverName>: { status, error? }, … }`), does the
856
+ * bot-owned `telegramBot` server need a (re)registration? True when the entry is
857
+ * ABSENT (never registered on this server generation) or its `status` is
858
+ * anything other than `connected` (e.g. `failed` — a stale registration to a
859
+ * dead port left by a previous bot generation). A malformed/empty response is
860
+ * treated as "needs register": a redundant POST is a harmless overwrite on the
861
+ * server, whereas skipping a real failure would leave agents without the tools.
862
+ */
863
+ function checkNeedsSchedulerMcpReregister(mcpStatus) {
864
+ if (!mcpStatus || typeof mcpStatus !== 'object')
865
+ return true;
866
+ const entry = mcpStatus[injection_1.schedulerMcpServerName];
867
+ if (!entry || typeof entry !== 'object')
868
+ return true;
869
+ return entry.status !== 'connected';
870
+ }
871
+ /**
872
+ * Pauses before each retry of a failed scheduler-MCP registration. A server that
873
+ * was just (re)started can take longer than the request timeout to create a
874
+ * folder's instance — live, the first POST after a restart timed out at 30 s and
875
+ * the folder's agent ran without the bot's tools until the next bot boot. After
876
+ * the last pause the bot stops; the next session start or bot boot tries again.
877
+ */
878
+ exports.schedulerMcpRetryDelaysMs = [15_000, 60_000, 5 * 60_000, 15 * 60_000];
879
+ /** @description The pause before retry number `attempt` (0-based), or `null` once the retries are used up. */
880
+ function getSchedulerMcpRetryDelayMs(attempt) {
881
+ return exports.schedulerMcpRetryDelaysMs[attempt] ?? null;
882
+ }
883
+ function buildProviderApiAuthPayload(apiKey) {
884
+ return { type: 'api', key: apiKey };
885
+ }
886
+ /**
887
+ * @description Drop the cached model list + provider config so the next read
888
+ * re-fetches. Called after a credential change (`/connect`), which can change
889
+ * which providers/models the server exposes; also the isolation seam tests use
890
+ * to keep the module-level cache from leaking between cases.
891
+ */
892
+ function resetOpenCodeProviderCaches() {
893
+ cachedModels = null;
894
+ modelsCacheTime = 0;
895
+ cachedProviders = null;
896
+ providersCacheTime = 0;
897
+ }
898
+ function getFreshProvidersConfig(now = Date.now()) {
899
+ if (!cachedProviders || now - providersCacheTime >= providersCacheTtlMs)
900
+ return null;
901
+ return cachedProviders;
902
+ }
903
+ function parseProvidersResponse(raw) {
904
+ const out = { providers: new Map() };
905
+ if (!checkIsOpenCodeRecord(raw))
906
+ return out;
907
+ // Two shapes seen in the wild: `{ providers: [...] }` (array, opencode
908
+ // 1.15.x) and `{ providers: { <id>: {...} } }` (older bundles). Parse
909
+ // both into the same flat (providerId → modelId → model config) map so
910
+ // the rest of the code doesn't care which shape the server sent.
911
+ const collect = (providerId, providerObj) => {
912
+ if (!checkIsOpenCodeRecord(providerObj) || !checkIsOpenCodeRecord(providerObj.models))
913
+ return;
914
+ const modelMap = new Map();
915
+ for (const [modelId, modelObj] of Object.entries(providerObj.models)) {
916
+ if (!checkIsOpenCodeRecord(modelObj)) {
917
+ modelMap.set(modelId, { variants: [], contextWindowTokens: null });
918
+ continue;
919
+ }
920
+ modelMap.set(modelId, {
921
+ variants: getOpenCodeModelVariants(modelObj),
922
+ contextWindowTokens: getOpenCodeModelContextWindowTokens(modelObj),
923
+ });
924
+ }
925
+ out.providers.set(providerId, modelMap);
926
+ };
927
+ const providersField = raw.providers;
928
+ if (Array.isArray(providersField)) {
929
+ for (const item of providersField) {
930
+ if (checkIsOpenCodeRecord(item) && typeof item.id === 'string')
931
+ collect(item.id, item);
932
+ }
933
+ }
934
+ else if (checkIsOpenCodeRecord(providersField)) {
935
+ for (const [id, value] of Object.entries(providersField)) {
936
+ collect(id, value);
937
+ }
938
+ }
939
+ return out;
940
+ }
941
+ /**
942
+ * @description Fetch available models from OpenCode CLI.
943
+ * Results are cached for 5 minutes.
944
+ */
945
+ async function fetchAvailableModels() {
946
+ const now = Date.now();
947
+ if (cachedModels && now - modelsCacheTime < modelsCacheTtlMs) {
948
+ return cachedModels;
949
+ }
950
+ try {
951
+ const opencodeCmd = (0, installManager_1.getToolCommand)('opencode');
952
+ const { stdout } = await execAsync(`${opencodeCmd} models`, {
953
+ timeout: 10000,
954
+ env: (0, installManager_1.getOpenCodeChildEnv)(),
955
+ });
956
+ const models = stdout.trim().split('\n').filter(line => line.includes('/'));
957
+ if (models.length > 0) {
958
+ cachedModels = models;
959
+ modelsCacheTime = now;
960
+ console.log(`[OpenCode] Fetched ${models.length} models from CLI`);
961
+ }
962
+ return models;
963
+ }
964
+ catch (e) {
965
+ console.log(`[OpenCode] Failed to fetch models:`, e instanceof Error ? e.message : e);
966
+ return cachedModels || [];
967
+ }
968
+ }
969
+ /**
970
+ * @description Find model by partial match in available models.
971
+ * Searches for models containing the query string (case-insensitive).
972
+ */
973
+ function findModelByQuery(query, models) {
974
+ const normalized = query.toLowerCase().trim().replace(/\s+/g, '-');
975
+ // Exact match first
976
+ const exact = models.find(m => m.toLowerCase() === normalized);
977
+ if (exact)
978
+ return exact;
979
+ // Match by model name part (after /)
980
+ const byModelName = models.find(m => {
981
+ const modelPart = m.split('/').slice(1).join('/').toLowerCase();
982
+ return modelPart === normalized || modelPart.includes(normalized);
983
+ });
984
+ if (byModelName)
985
+ return byModelName;
986
+ // Fuzzy match - contains query anywhere
987
+ const fuzzy = models.find(m => m.toLowerCase().includes(normalized));
988
+ if (fuzzy)
989
+ return fuzzy;
990
+ return null;
991
+ }
992
+ /**
993
+ * @description Resolve model input to provider/modelId format.
994
+ * Accepts full format (provider/model) or partial query to search in available models.
995
+ */
996
+ async function resolveModelId(input, models) {
997
+ const trimmed = input.trim();
998
+ // If already in provider/model format, validate it exists
999
+ const slashIndex = trimmed.indexOf('/');
1000
+ if (slashIndex > 0) {
1001
+ const providerID = trimmed.slice(0, slashIndex);
1002
+ const modelID = trimmed.slice(slashIndex + 1);
1003
+ // Check if model exists in available list
1004
+ const exists = models.some(m => m.toLowerCase() === trimmed.toLowerCase());
1005
+ if (exists || models.length === 0) {
1006
+ return { providerID, modelID };
1007
+ }
1008
+ // Model not in list, but might be valid - allow it with warning
1009
+ console.log(`[OpenCode] Model "${trimmed}" not in available list, using anyway`);
1010
+ return { providerID, modelID };
1011
+ }
1012
+ // Search by partial match
1013
+ const found = findModelByQuery(trimmed, models);
1014
+ if (found) {
1015
+ const foundSlash = found.indexOf('/');
1016
+ return {
1017
+ providerID: found.slice(0, foundSlash),
1018
+ modelID: found.slice(foundSlash + 1),
1019
+ };
1020
+ }
1021
+ return null;
1022
+ }
1023
+ class OpenCodeAdapter extends events_1.EventEmitter {
1024
+ name = 'opencode';
1025
+ label = 'OpenCode';
1026
+ /**
1027
+ * `summarize` produces a real assistant message, which rides the SSE stream into
1028
+ * the topic as agent output marked `isCompactionSummary` — so the bot must not
1029
+ * post its own copy of the summary here, or the operator would read it twice.
1030
+ */
1031
+ streamsCompactionSummary = true;
1032
+ /**
1033
+ * Map of serialised `SessionKey` (`"<chatId>:<threadId>"`) → live session.
1034
+ * Keyed by string rather than `SessionKey` object so map lookups work — JS
1035
+ * `Map` compares object identity, not structural equality.
1036
+ */
1037
+ sessions = new Map();
1038
+ /**
1039
+ * The ONE `/global/event` SSE stream for the whole adapter (plan 2026-06-17).
1040
+ * It multiplexes every project instance's events, each tagged with a top-level
1041
+ * `directory` field, so a single reader parses each event once and routes it
1042
+ * by envelope `directory` + `sessionID`. Opened when the FIRST active session
1043
+ * (any folder) appears, closed when the LAST one goes away. `null` while no
1044
+ * session is active.
1045
+ */
1046
+ globalStream = null;
1047
+ /**
1048
+ * Directories whose OpenCode instance already has the bot's scheduler MCP
1049
+ * server registered for the CURRENT server generation (plan 2026-06-17 S3).
1050
+ * Registration is now per directory on session start (decoupled from the
1051
+ * stream, which is no longer per-folder); the Set gates it to once per
1052
+ * directory per generation and is cleared on `restartServer` so each dir is
1053
+ * re-registered after the server (and its in-memory MCP table) restarts.
1054
+ */
1055
+ registeredSchedulerMcpDirs = new Set();
1056
+ /**
1057
+ * Directories whose scheduler-MCP registration failed and is waiting for a
1058
+ * retry: the number of retries already scheduled, and the pending timer (null
1059
+ * while the retry itself runs). Cleared by a registration that succeeds.
1060
+ */
1061
+ schedulerMcpRetries = new Map();
1062
+ /**
1063
+ * child sessionID → parent sessionID, learned from `session.updated` events.
1064
+ * OpenCode runs subagents (e.g. `@explore`) in child sessions whose SSE
1065
+ * events carry the child id; this map routes them back to the topic bound to
1066
+ * the parent. Bounded by `maxTrackedSessionLineageEntries`.
1067
+ */
1068
+ sessionLineage = new Map();
1069
+ /**
1070
+ * Last diag-log timestamp per `"<eventType>|<eventSessionId>"`, so a flood of
1071
+ * dropped events for an orphaned session logs at most once per
1072
+ * `sseDropLogThrottleMs` instead of once per delta per bound thread (B19).
1073
+ */
1074
+ sseDropLogThrottle = new Map();
1075
+ baseUrl;
1076
+ authHeader;
1077
+ /**
1078
+ * Grace period (ms) the fallback rename waits for opencode's native
1079
+ * auto-title before stepping in. Instance field (initialised from
1080
+ * {@link fallbackRenameGraceMs}) purely so the naming test can drive it to 0
1081
+ * and assert the PATCH path deterministically — production never changes it.
1082
+ */
1083
+ fallbackRenameGraceMs = fallbackRenameGraceMs;
1084
+ /**
1085
+ * @description Per-key start/stop serialisation queue (audit S8 / #13).
1086
+ * Two near-simultaneous `startSession(key, …)` calls otherwise both
1087
+ * progress through `POST /session`; the second's `stopSession(key)`
1088
+ * (line 529) clears whatever the first inserted into `this.sessions`,
1089
+ * but the losing thread still has its SSE stream and a server-side
1090
+ * session id with no owner.
1091
+ */
1092
+ lifecycleChains = new Map();
1093
+ /** Number of in-flight optional history reads used to hydrate `/status` metadata. */
1094
+ runtimeContextHydrationCount = 0;
1095
+ /** Callers waiting for one of the bounded runtime-context hydration slots. */
1096
+ runtimeContextHydrationWaiters = [];
1097
+ /** Whether a server restart is already in progress (prevents concurrent restart attempts) */
1098
+ isServerRestarting = false;
1099
+ /**
1100
+ * Per-thread display-prefs reader, injected by the bot at boot via
1101
+ * `createAdapter.registerDisplayPrefsReader` (S4). DELIBERATE deviation from
1102
+ * the S2/S3 "adapter stays mode-agnostic" pattern: the sub-agent branch
1103
+ * decides WHAT to accumulate (non-`full` = refresh a status, `full` = stream
1104
+ * into the separate child accumulator), which cannot be deferred to the
1105
+ * bot's render time. `null` until wired → reads fall back to all-`minimal`.
1106
+ */
1107
+ displayPrefsReader = null;
1108
+ /** @description Inject the per-thread display-prefs reader (see the field's JSDoc). */
1109
+ setDisplayPrefsReader(reader) {
1110
+ this.displayPrefsReader = reader;
1111
+ }
1112
+ /** Per-thread locale reader, injected at boot via `createAdapter.registerThreadLocaleReader`. */
1113
+ threadLocaleReader = null;
1114
+ /** @description Inject the per-thread locale reader (for adapter-side `t(...)` calls). */
1115
+ setThreadLocaleReader(reader) {
1116
+ this.threadLocaleReader = reader;
1117
+ }
1118
+ /** @description Run `fn` inside the thread's locale context so `t(...)` resolves correctly. */
1119
+ tl(key, fn) {
1120
+ return (0, i18n_1.runWithLocale)(this.threadLocaleReader?.(key) ?? i18n_1.defaultLocale, fn);
1121
+ }
1122
+ /**
1123
+ * Per-thread seen-watermark writer, injected by the bot at boot via
1124
+ * `createAdapter.registerSeenWatermarkWriter`. Called at turn end
1125
+ * (`session.idle`) to persist how far the live relay had shown the record.
1126
+ * `null` until wired → {@link advanceSeenWatermark} is a no-op.
1127
+ */
1128
+ seenWatermarkWriter = null;
1129
+ /** @description Inject the per-thread seen-watermark writer (see the field's JSDoc). */
1130
+ setSeenWatermarkWriter(writer) {
1131
+ this.seenWatermarkWriter = writer;
1132
+ }
1133
+ /** @description Advance the persisted seen-watermark for `key` (no-op until the
1134
+ * writer is wired). Skips a watermark with no anchor id so the bot's recap
1135
+ * cleanly falls back to its no-count path. */
1136
+ advanceSeenWatermark(key, watermark) {
1137
+ if (watermark.opencodeMessageId === undefined)
1138
+ return;
1139
+ this.seenWatermarkWriter?.(key, watermark);
1140
+ }
1141
+ /** @description Resolve the thread's full display prefs, defaulting every
1142
+ * field to `minimal` for any read before the bot wires the reader at boot. */
1143
+ getDisplayPrefs(key) {
1144
+ return this.displayPrefsReader?.(key) ?? (0, displayVerbosity_1.getDefaultDisplayPrefs)();
1145
+ }
1146
+ /** @description Resolve the thread's `/subagent` mode (the only pref the
1147
+ * OpenCode adapter consults today), via the full prefs reader. */
1148
+ getSubagentMode(key) {
1149
+ return this.getDisplayPrefs(key).subagent;
1150
+ }
1151
+ constructor() {
1152
+ super();
1153
+ // Audit S7 / #34: SSRF guard on OPENCODE_URL before any fetch runs.
1154
+ this.baseUrl = validateOpenCodeUrl(process.env.OPENCODE_URL || installManager_1.defaultOpenCodeUrl);
1155
+ const password = process.env.OPENCODE_PASSWORD;
1156
+ if (password) {
1157
+ const username = process.env.OPENCODE_USERNAME || 'opencode';
1158
+ this.authHeader = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
1159
+ }
1160
+ else {
1161
+ this.authHeader = null;
1162
+ }
1163
+ // Listen for unexpected server crashes (OOM kill, segfault, etc.)
1164
+ (0, installManager_1.onOpenCodeServerExit)((code, signal) => {
1165
+ console.error(`[OpenCode] Server crashed unexpectedly: code=${code}, signal=${signal}`);
1166
+ this.handleServerCrash(code, signal);
1167
+ });
1168
+ }
1169
+ restoreSavedModel(key, session, emitOutput) {
1170
+ const saved = loadSavedModel(key);
1171
+ if (!saved)
1172
+ return false;
1173
+ const label = `${saved.providerID}/${saved.modelID}`;
1174
+ session.currentModelLabel = label;
1175
+ session.modelOverride = saved;
1176
+ session.isModelInfoShown = true;
1177
+ console.log(`[OpenCode] Restored saved model: ${label}`);
1178
+ if (emitOutput)
1179
+ this.emit('output', key, `Model: ${label}`);
1180
+ return true;
1181
+ }
1182
+ /**
1183
+ * @description Handle unexpected server process death.
1184
+ * Notifies all active users and attempts to restart the server.
1185
+ * SSE reconnect loop will detect the restart and recover automatically.
1186
+ */
1187
+ handleServerCrash(code, signal) {
1188
+ const reason = code === 137 ? 'out of memory (OOM killed)' :
1189
+ signal ? `signal ${signal}` : `exit code ${code}`;
1190
+ // Notify all active session threads about the crash
1191
+ for (const session of this.sessions.values()) {
1192
+ if (session.isActive) {
1193
+ this.emit('output', session.key, `OpenCode server crashed (${reason}). Restarting...`);
1194
+ }
1195
+ }
1196
+ // Auto-restart the server (SSE reconnect loop will pick it up)
1197
+ this.restartServer();
1198
+ }
1199
+ /**
1200
+ * @description Restart the OpenCode server process.
1201
+ * Prevents concurrent restart attempts. If restart is already in progress,
1202
+ * waits for it to complete and returns its result.
1203
+ */
1204
+ async restartServer(force = false) {
1205
+ if (this.isServerRestarting) {
1206
+ console.log(`[OpenCode] Server restart already in progress, waiting...`);
1207
+ // Wait for the in-progress restart to complete
1208
+ while (this.isServerRestarting) {
1209
+ await new Promise(resolve => setTimeout(resolve, 500));
1210
+ }
1211
+ // Check if server is now running
1212
+ return (0, installManager_1.checkIsOpenCodeServerRunning)();
1213
+ }
1214
+ this.isServerRestarting = true;
1215
+ try {
1216
+ console.log(`[OpenCode] Attempting server restart...`);
1217
+ if (force)
1218
+ await (0, installManager_1.restartOpenCodeServer)();
1219
+ else
1220
+ await (0, installManager_1.ensureOpenCodeServer)();
1221
+ console.log(`[OpenCode] Server restarted successfully`);
1222
+ // Close the global stream up front: its reader points at the crashed
1223
+ // server's connection. The resume loop below re-opens the (idempotent)
1224
+ // single stream via connectSse (plan 2026-06-17 S4). Without this the
1225
+ // stale reader would only self-heal later via the stall watchdog —
1226
+ // closing here makes recovery deterministic and tied to the restart.
1227
+ this.closeGlobalStream();
1228
+ // The restarted server has a fresh, empty MCP table, so every directory's
1229
+ // scheduler-MCP registration died with the old generation. Clear the gate
1230
+ // so connectSse re-registers each still-active directory on resume.
1231
+ this.registeredSchedulerMcpDirs.clear();
1232
+ // OpenCode persists sessions to disk, so the restarted server still
1233
+ // knows the previous session ids — that's the basis of this recovery.
1234
+ // For each in-memory session that was active, re-resume the SAME id
1235
+ // (verify it on the new server, rebuild the session object, reconnect
1236
+ // SSE) via the normal resume path. Only if the id is genuinely gone
1237
+ // (e.g. GET /session/:id 404) do we fall back to teardown. Take a
1238
+ // snapshot first so the resume/teardown loop can mutate `this.sessions`.
1239
+ const snapshot = Array.from(this.sessions.entries());
1240
+ for (const [k, session] of snapshot) {
1241
+ if (!session.isActive)
1242
+ continue;
1243
+ const { key: sessionKey, sessionId, workDir } = session;
1244
+ try {
1245
+ // resumeSessionInner is the lock-free body; we acquire the per-key
1246
+ // lifecycle lock here exactly like the public resumeSession does, so
1247
+ // the lock is taken once (no double-acquire from the crash handler).
1248
+ await this.withLifecycleLock(k, () => this.resumeSessionInner(sessionKey, workDir, sessionId));
1249
+ this.emit('output', sessionKey, `OpenCode server restarted; session restored. In-flight reply was lost — resend if needed.`);
1250
+ }
1251
+ catch (e) {
1252
+ // The id is gone on the restarted server (or resume otherwise
1253
+ // failed). Tear the session down and notify, same as before. Emit
1254
+ // `closed` so downstream (bot.ts, wired through createAdapter.ts)
1255
+ // wipes the persisted session id too — otherwise the next bot
1256
+ // restart would try to resume an id the server no longer has.
1257
+ console.error(`[OpenCode] Could not restore session ${sessionId} after restart:`, e);
1258
+ this.emit('output', sessionKey, `OpenCode server restarted; previous session lost. Starting a fresh one with /opencode (or /quit to release).`);
1259
+ this.stopSessionInner(sessionKey);
1260
+ this.emit('closed', sessionKey);
1261
+ // Defensive: stopSessionInner deletes from `this.sessions`, but
1262
+ // emit order matters for downstream cleanup races.
1263
+ this.sessions.delete(k);
1264
+ }
1265
+ }
1266
+ return true;
1267
+ }
1268
+ catch (e) {
1269
+ console.error(`[OpenCode] Server restart failed:`, e);
1270
+ // Notify threads about the failure and tear them down
1271
+ // Take a snapshot first so we can safely mutate `this.sessions` inside the loop.
1272
+ const snapshot = Array.from(this.sessions.entries());
1273
+ for (const [k, session] of snapshot) {
1274
+ if (session.isActive) {
1275
+ this.emit('output', session.key, `Failed to restart OpenCode server: ${e instanceof Error ? e.message : String(e)}`);
1276
+ // Mark session as inactive — no point keeping it alive
1277
+ session.isActive = false;
1278
+ // This teardown bypasses stopSessionInner, so the replacement bound
1279
+ // has to be dropped here or it outlives the session it belonged to.
1280
+ this.clearProviderRetryReplacementStartTimer(session);
1281
+ this.sessions.delete(k);
1282
+ this.emit('stopped', session.key);
1283
+ }
1284
+ }
1285
+ return false;
1286
+ }
1287
+ finally {
1288
+ this.isServerRestarting = false;
1289
+ }
1290
+ }
1291
+ /**
1292
+ * @description Reload credentials written by the out-of-band `opencode auth
1293
+ * login` CLI. The CLI updates auth.json directly, but a running OpenCode
1294
+ * server retains its prior provider credential in memory until restarted.
1295
+ * Reuses the normal restart + active-session restoration path.
1296
+ */
1297
+ async reloadProviderAuth() {
1298
+ return this.restartServer(true);
1299
+ }
1300
+ getHeaders() {
1301
+ const headers = {
1302
+ 'Content-Type': 'application/json',
1303
+ 'Accept': 'application/json',
1304
+ };
1305
+ if (this.authHeader) {
1306
+ headers['Authorization'] = this.authHeader;
1307
+ }
1308
+ return headers;
1309
+ }
1310
+ async apiRequest(method, urlPath, body, options) {
1311
+ const url = `${this.baseUrl}${urlPath}`;
1312
+ // Audit S7 / #12: every fetch carries a timeout so a saturated server can
1313
+ // never park a request forever (B18). An external `AbortSignal` (e.g. tied
1314
+ // to session lifetime) is COMPOSED with the timeout via `AbortSignal.any`
1315
+ // (Node 22+) — the previous `options?.signal ?? timeoutSignal` SILENTLY
1316
+ // dropped the timeout whenever a caller passed its own signal, so such a
1317
+ // request could hang indefinitely.
1318
+ const timeoutSignal = AbortSignal.timeout(apiRequestTimeoutMs);
1319
+ const signal = options?.signal
1320
+ ? AbortSignal.any([options.signal, timeoutSignal])
1321
+ : timeoutSignal;
1322
+ const requestInit = {
1323
+ method,
1324
+ headers: this.getHeaders(),
1325
+ signal,
1326
+ };
1327
+ if (body !== undefined) {
1328
+ requestInit.body = JSON.stringify(body);
1329
+ }
1330
+ let response;
1331
+ try {
1332
+ response = await fetch(url, requestInit);
1333
+ }
1334
+ catch (e) {
1335
+ // `e instanceof DOMException && e.name === 'TimeoutError'` is the
1336
+ // AbortSignal.timeout path; treat it like a connection failure so
1337
+ // the user gets a friendly hint, not a stack trace.
1338
+ if (e instanceof DOMException && e.name === 'TimeoutError') {
1339
+ throw new Error(`OpenCode request timed out after ${apiRequestTimeoutMs}ms (${method} ${urlPath})`);
1340
+ }
1341
+ // Node's fetch wraps low-level errors in a TypeError with `.cause`
1342
+ // holding the underlying ErrnoException. Only `'cause' in e` is a
1343
+ // reliable check (TypeScript narrows `cause` to `unknown` even on
1344
+ // TypeError in Node's lib types).
1345
+ const causeUnknown = e.cause;
1346
+ const cause = causeUnknown && typeof causeUnknown === 'object' && 'code' in causeUnknown
1347
+ ? causeUnknown
1348
+ : null;
1349
+ if (cause?.code === 'ECONNREFUSED') {
1350
+ throw new Error(`OpenCode server not available at ${this.baseUrl}. Is "opencode serve" running?`);
1351
+ }
1352
+ throw new Error(`OpenCode server connection failed (${this.baseUrl}): ${getOpenCodeErrorMessage(e)}`);
1353
+ }
1354
+ // prompt_async returns 204 with no body
1355
+ if (response.status === 204) {
1356
+ return undefined;
1357
+ }
1358
+ if (!response.ok) {
1359
+ const errorText = await response.text().catch(() => '');
1360
+ throw new Error(`OpenCode API ${method} ${urlPath} failed: ${response.status} ${getOpenCodeErrorMessage(errorText)}`);
1361
+ }
1362
+ const contentType = response.headers.get('content-type') || '';
1363
+ if (contentType.includes('application/json')) {
1364
+ return await response.json();
1365
+ }
1366
+ // Audit S7 / #35: undici keeps the socket open until the body is
1367
+ // drained. Even when we discard it, we must read it.
1368
+ await response.text().catch(() => '');
1369
+ return undefined;
1370
+ }
1371
+ /**
1372
+ * @description Serialise start/stop ops per key. Returns the queued
1373
+ * promise so callers still see the original result/throw. Audit S8.
1374
+ */
1375
+ withLifecycleLock(k, fn) {
1376
+ const prev = this.lifecycleChains.get(k);
1377
+ const run = async () => {
1378
+ if (prev) {
1379
+ try {
1380
+ await prev;
1381
+ }
1382
+ catch { /* swallow predecessor failures */ }
1383
+ }
1384
+ return fn();
1385
+ };
1386
+ const current = run();
1387
+ const tracked = current.then(() => undefined, () => undefined);
1388
+ this.lifecycleChains.set(k, tracked);
1389
+ return current.finally(() => {
1390
+ if (this.lifecycleChains.get(k) === tracked)
1391
+ this.lifecycleChains.delete(k);
1392
+ });
1393
+ }
1394
+ async startSession(key, workDir, args, _sessionId) {
1395
+ // OpenCode's session id is server-assigned via POST /session, so an
1396
+ // externally-supplied sessionId is not honoured here. The bot keeps the
1397
+ // mapping `SessionKey → opencodeSessionId` in state.json (plan §13.19) so
1398
+ // resumes after a restart go through `resumeSession()`, not startSession.
1399
+ const k = (0, sessionKey_1.keyToString)(key);
1400
+ return this.withLifecycleLock(k, async () => {
1401
+ this.stopSessionInner(key);
1402
+ if (!(0, installManager_1.checkIsInstalled)('opencode')) {
1403
+ this.emit('output', key, 'Installing OpenCode...');
1404
+ await (0, installManager_1.installTool)('opencode');
1405
+ }
1406
+ if (!await (0, installManager_1.checkIsOpenCodeServerRunning)()) {
1407
+ this.emit('output', key, 'Starting OpenCode server...');
1408
+ await (0, installManager_1.ensureOpenCodeServer)();
1409
+ }
1410
+ console.log(`[OpenCode] Starting session for ${k} in ${workDir}`);
1411
+ try {
1412
+ // R1 (verified live 2026-06-04): a session created WITHOUT a title is
1413
+ // auto-named by opencode from its first prompt's LLM turn — nicer than
1414
+ // a raw snippet and it ignores the glued thread-context preamble. A
1415
+ // session created WITH a title is treated as user-set and NEVER
1416
+ // auto-renamed, which is exactly why the old `Telegram session <k>`
1417
+ // default left every session stuck. So only pass a title for the
1418
+ // explicit `/opencode <args>` case (explicit title, no auto-rename).
1419
+ const createBody = {};
1420
+ if (args)
1421
+ createBody.title = args;
1422
+ // The bound folder IS the agent's working directory: create the session
1423
+ // in that folder's project instance (`?directory=`) so its cwd/tools are
1424
+ // scoped there. Without it the server falls back to its serve-cwd default
1425
+ // instance and the agent runs outside the topic's folder.
1426
+ const apiSession = await this.apiRequest('POST', buildDirectoryScopedPath('/session', workDir), createBody);
1427
+ const session = {
1428
+ key,
1429
+ sessionId: apiSession.id,
1430
+ workDir,
1431
+ isActive: true,
1432
+ currentResponseText: '',
1433
+ lastEmittedLength: 0,
1434
+ outputTimer: null,
1435
+ compactionSummaryMessageId: null,
1436
+ isResponseCompactionSummary: false,
1437
+ childResponseText: '',
1438
+ childLastEmittedLength: 0,
1439
+ childOutputTimer: null,
1440
+ activeSubagentTitle: null,
1441
+ isModelInfoShown: false,
1442
+ modelOverride: null,
1443
+ currentModelLabel: null,
1444
+ latestParentRuntimeContext: null,
1445
+ parentAssistantObservationVersion: 0,
1446
+ partTypes: new Map(),
1447
+ statusDebounceTimer: null,
1448
+ pendingStatus: null,
1449
+ reasoningText: '',
1450
+ reasoningStartedAt: null,
1451
+ reasoningTimer: null,
1452
+ emittedToolResultPartIds: new Set(),
1453
+ pendingQuestion: null,
1454
+ effortLevel: loadSavedEffort(key),
1455
+ isBusy: false,
1456
+ awaitingTurnResponse: false,
1457
+ sawTurnActivity: false,
1458
+ unconsumedInputCount: 0,
1459
+ seenUserMessageIds: new Set(),
1460
+ providerRetrySignature: null,
1461
+ isAwaitingModelAfterProviderRetryAbort: false,
1462
+ isAwaitingProviderRetryAbortIdle: false,
1463
+ isAwaitingProviderRetryReplacementStart: false,
1464
+ providerRetryReplacementStartTimer: null,
1465
+ providerRetryAbortPromise: null,
1466
+ isCompacting: false,
1467
+ busyChildSessionIds: new Set(),
1468
+ // No assistant turn has finished yet — the seen-watermark anchor is
1469
+ // set on the first `session.idle` after a turn completes.
1470
+ lastMessageId: undefined,
1471
+ // Only untitled (no-args) sessions are eligible for the bot-side
1472
+ // fallback rename; an explicit `args` title is user-set and final.
1473
+ isAutoNamePending: !args,
1474
+ };
1475
+ this.detachDuplicateSessionOwners(k, session.sessionId);
1476
+ this.sessions.set(k, session);
1477
+ this.connectSse(key);
1478
+ // Ready BEFORE model resolution (B18): a busy server can stall
1479
+ // GET /config for tens of seconds, and the topic showed no "ready"
1480
+ // reply meanwhile. The session exists and SSE is live the moment we
1481
+ // get here, so announce readiness now and resolve the model after.
1482
+ this.emit('started', key);
1483
+ // Resolve the model after readiness so a slow /config can never gate
1484
+ // the ready reply. SILENT (emitOutput=false), like `resumeSession`: the
1485
+ // bot's own `agent.ready` notice already names the resolved model (it
1486
+ // reads `getCurrentModel` after `startSession` returns), so emitting a
1487
+ // standalone `Model: <label>` line here posted a SECOND message for
1488
+ // every `/new` / `/opencode` start. Resolution itself must still run —
1489
+ // it populates `modelOverride`/`currentModelLabel` for that notice, for
1490
+ // `/effort`, and for the prompt body. A transient `/config` failure
1491
+ // leaves `isModelInfoShown` false, so the first assistant message still
1492
+ // corrects the label out loud (B9 unchanged).
1493
+ await this.fetchModelInfo(key, false);
1494
+ // Seed the bot's default reasoning effort (xhigh, clamped to the now
1495
+ // resolved model's variants) UNLESS the thread already has an explicit
1496
+ // /effort pref — the model ref is resolved by fetchModelInfo above.
1497
+ await this.applyDefaultEffortIfUnset(key);
1498
+ // If args provided, send as first message. After fetchModelInfo so the
1499
+ // resolved model override rides the prompt body.
1500
+ if (args) {
1501
+ this.sendPromptAsync(key, args);
1502
+ }
1503
+ }
1504
+ catch (e) {
1505
+ console.error(`[OpenCode] Failed to start session:`, e);
1506
+ throw e;
1507
+ }
1508
+ });
1509
+ }
1510
+ stopSession(key) {
1511
+ // Public API contract: stop is fire-and-forget. We still queue it on
1512
+ // the lifecycle chain so an in-flight start finishes first.
1513
+ const k = (0, sessionKey_1.keyToString)(key);
1514
+ void this.withLifecycleLock(k, async () => { this.stopSessionInner(key); });
1515
+ }
1516
+ /** Lock-free body of `stopSession`; safe to call from start/resume paths
1517
+ * that already hold the lifecycle lock. */
1518
+ stopSessionInner(key) {
1519
+ const k = (0, sessionKey_1.keyToString)(key);
1520
+ const session = this.sessions.get(k);
1521
+ if (!session)
1522
+ return;
1523
+ console.log(`[OpenCode] Stopping session for ${k}`);
1524
+ session.isActive = false;
1525
+ if (session.outputTimer) {
1526
+ clearTimeout(session.outputTimer);
1527
+ session.outputTimer = null;
1528
+ }
1529
+ // Same teardown for the child (sub-agent) debounce — a late fire would
1530
+ // emit a marked chunk to a thread that just announced `stopped`.
1531
+ if (session.childOutputTimer) {
1532
+ clearTimeout(session.childOutputTimer);
1533
+ session.childOutputTimer = null;
1534
+ }
1535
+ // Audit S8 / #11: previously the status timer kept firing after
1536
+ // `stopSession`, emitting transient `status` events to a thread that
1537
+ // had just announced `stopped`.
1538
+ if (session.statusDebounceTimer) {
1539
+ clearTimeout(session.statusDebounceTimer);
1540
+ session.statusDebounceTimer = null;
1541
+ }
1542
+ // Same rationale: a live `thinking` debounce timer must not fire (emitting a
1543
+ // stale "thinking …" frame) after the session announced `stopped`.
1544
+ if (session.reasoningTimer) {
1545
+ clearTimeout(session.reasoningTimer);
1546
+ session.reasoningTimer = null;
1547
+ }
1548
+ // Same rationale, higher stakes: a fired replacement bound would emit
1549
+ // `noResponse` against a session that no longer exists, kicking wedge
1550
+ // recovery for a thread that was deliberately stopped.
1551
+ this.clearProviderRetryReplacementStartTimer(session);
1552
+ // Tear down the global SSE stream if this was the last active session
1553
+ // anywhere (also clears that stream's reconnect + stall timers).
1554
+ this.disconnectSse(key);
1555
+ // Abort any running generation. Audit S15: log failures instead
1556
+ // of swallowing — a 4xx here usually means the server already
1557
+ // dropped the session, which is worth surfacing.
1558
+ this.apiRequest('POST', `/session/${session.sessionId}/abort`)
1559
+ .catch(e => console.warn(`[OpenCode] abort on stopSession failed:`, e instanceof Error ? e.message : e));
1560
+ this.sessions.delete(k);
1561
+ this.emit('stopped', key);
1562
+ }
1563
+ checkIsActive(key) {
1564
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
1565
+ return session?.isActive ?? false;
1566
+ }
1567
+ checkHasUnconsumedInput(key) {
1568
+ return (this.sessions.get((0, sessionKey_1.keyToString)(key))?.unconsumedInputCount ?? 0) > 0;
1569
+ }
1570
+ /**
1571
+ * Count a parent-session user message the first time it shows up: OpenCode
1572
+ * re-sends `message.updated` for one message, so ids are de-duplicated (the set
1573
+ * is bounded — only recent ids can still repeat).
1574
+ */
1575
+ noteUserMessageSeen(session, messageId) {
1576
+ if (messageId === undefined || session.seenUserMessageIds.has(messageId))
1577
+ return;
1578
+ session.seenUserMessageIds.add(messageId);
1579
+ if (session.seenUserMessageIds.size > seenUserMessageIdsMax) {
1580
+ const oldestId = session.seenUserMessageIds.values().next().value;
1581
+ if (oldestId !== undefined)
1582
+ session.seenUserMessageIds.delete(oldestId);
1583
+ }
1584
+ session.unconsumedInputCount = Math.max(0, session.unconsumedInputCount - 1);
1585
+ }
1586
+ checkIsBusy(key) {
1587
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
1588
+ if (!session?.isActive)
1589
+ return false;
1590
+ return checkIsOpenCodeSessionBusy({
1591
+ isBusy: session.isBusy,
1592
+ isCompacting: session.isCompacting,
1593
+ busyChildCount: session.busyChildSessionIds.size,
1594
+ });
1595
+ }
1596
+ sendInput(key, input) {
1597
+ this.sendPromptAsync(key, input);
1598
+ }
1599
+ /**
1600
+ * @description Send message via async endpoint (returns 204, response streams via SSE).
1601
+ * Fire-and-forget — errors are logged but don't block.
1602
+ *
1603
+ * A normal busy turn is preserved and receives the prompt through OpenCode's
1604
+ * queue. A provider-managed retry is interrupted first: otherwise a model
1605
+ * switch plus "continue" is accepted into the transcript but remains unread
1606
+ * behind the old provider's retry window.
1607
+ *
1608
+ * **Per-prompt effort apply:** if the thread has a stored effort level
1609
+ * (a model variant), it's sent as `body.variant` alongside `body.model`
1610
+ * — the same per-prompt override the prompt endpoint already uses for the
1611
+ * model selector. No separate request, no env configuration.
1612
+ */
1613
+ sendPromptAsync(key, input) {
1614
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
1615
+ if (!session?.isActive) {
1616
+ console.log(`[OpenCode] sendInput: no active session for ${(0, sessionKey_1.keyToString)(key)}`);
1617
+ (0, diagLog_1.appendDiagLog)(`prompt DROPPED no-active-session key=${(0, sessionKey_1.keyToString)(key)}`);
1618
+ return;
1619
+ }
1620
+ console.log(`[OpenCode] sendPromptAsync: "${input}"`);
1621
+ session.unconsumedInputCount += 1;
1622
+ // Bot-side fallback naming (R1 safety net): on the first MEANINGFUL prompt
1623
+ // of an untitled session, schedule a rename that only fires if opencode's
1624
+ // own auto-title never lands. Uses the RAW user text (preamble stripped) so
1625
+ // a fallback title can never carry the service header. Fire-and-forget — it
1626
+ // must not gate prompt delivery, and a failure leaves the placeholder.
1627
+ this.maybeScheduleFallbackRename(session, input);
1628
+ // Reset accumulated response text for new message
1629
+ session.currentResponseText = '';
1630
+ session.lastEmittedLength = 0;
1631
+ session.isResponseCompactionSummary = false;
1632
+ // The child (sub-agent) accumulator resets with the parent's — a new turn
1633
+ // starts clean (a stale debounce firing later emits nothing: empty tail).
1634
+ session.childResponseText = '';
1635
+ session.childLastEmittedLength = 0;
1636
+ // New turn → previous turn's tool-result dedup ids can no longer matter
1637
+ // (see the field's JSDoc for why the turn flush is too early to clear).
1638
+ session.emittedToolResultPartIds.clear();
1639
+ // Mark busy optimistically so an immediately-following message correctly
1640
+ // sees a turn in flight; the `session.status` stream corrects/confirms it.
1641
+ session.isBusy = true;
1642
+ const isReplacingProviderRetry = session.providerRetrySignature !== null || session.providerRetryAbortPromise !== null;
1643
+ if (!isReplacingProviderRetry) {
1644
+ // Arm the wedged-turn detector: a healthy turn sets `sawTurnActivity` from
1645
+ // its first assistant message/part; if the next own-session idle arrives
1646
+ // with none, the prompt produced no turn (a wedged session accepts prompts
1647
+ // but its agent loop exits at once) and we surface a notice instead of
1648
+ // hanging silently.
1649
+ session.awaitingTurnResponse = true;
1650
+ session.sawTurnActivity = false;
1651
+ }
1652
+ else {
1653
+ // The prior retry's pending detector belongs to the turn being aborted.
1654
+ // The replacement is armed only at its own observed busy transition.
1655
+ session.awaitingTurnResponse = false;
1656
+ session.sawTurnActivity = false;
1657
+ }
1658
+ // Model + reasoning-effort overrides ride the prompt body (see buildPromptBody).
1659
+ const body = buildPromptBody(input, session.modelOverride, session.effortLevel);
1660
+ void (async () => {
1661
+ try {
1662
+ const retryAbortPromise = this.getProviderRetryAbortPromise(session);
1663
+ if (retryAbortPromise)
1664
+ await retryAbortPromise;
1665
+ if (isReplacingProviderRetry) {
1666
+ const current = this.sessions.get((0, sessionKey_1.keyToString)(key));
1667
+ if (current !== session || !current.isActive)
1668
+ return;
1669
+ // SSE can report busy before prompt_async resolves. Arm the boundary
1670
+ // before sending so that busy unambiguously belongs to this prompt.
1671
+ this.armProviderRetryReplacementStart(current);
1672
+ }
1673
+ await this.apiRequest('POST', `/session/${session.sessionId}/prompt_async`, body);
1674
+ }
1675
+ catch (e) {
1676
+ // A prompt OpenCode never accepted will never show up as a user message,
1677
+ // so it must stop counting as unconsumed — left counted, every later turn
1678
+ // of this session would read as "not taken in yet" and never end.
1679
+ session.unconsumedInputCount = Math.max(0, session.unconsumedInputCount - 1);
1680
+ // The optimistic `isBusy = true` above never gets a `session.status`
1681
+ // idle to clear it if the POST failed — clear it so the next prompt
1682
+ // doesn't eat a spurious abort + wait.
1683
+ const current = this.sessions.get((0, sessionKey_1.keyToString)(key));
1684
+ if (current && !current.providerRetrySignature) {
1685
+ current.isBusy = false;
1686
+ current.awaitingTurnResponse = false;
1687
+ this.clearProviderRetryReplacementBoundary(current);
1688
+ }
1689
+ console.error(`[OpenCode] Failed to send message:`, e);
1690
+ this.emit('error', key, e instanceof Error ? e : new Error(String(e)));
1691
+ }
1692
+ })();
1693
+ }
1694
+ /**
1695
+ * @description Return the one shared abort request for a provider-managed
1696
+ * retry. Concurrent prompts await the same request, preventing a later abort
1697
+ * from cancelling a prompt that an earlier caller already posted.
1698
+ */
1699
+ getProviderRetryAbortPromise(session) {
1700
+ if (session.providerRetryAbortPromise)
1701
+ return session.providerRetryAbortPromise;
1702
+ if (!session.providerRetrySignature)
1703
+ return null;
1704
+ console.log(`[OpenCode] Aborting provider retry before new prompt`);
1705
+ session.isAwaitingModelAfterProviderRetryAbort = true;
1706
+ // The abort's idle may arrive before this request resolves, so arm its
1707
+ // one-shot consumer before dispatching rather than in the success handler.
1708
+ session.isAwaitingProviderRetryAbortIdle = true;
1709
+ const abortPromise = this.apiRequest('POST', `/session/${session.sessionId}/abort`).then(() => undefined);
1710
+ session.providerRetryAbortPromise = abortPromise;
1711
+ void abortPromise.then(() => {
1712
+ const current = this.sessions.get((0, sessionKey_1.keyToString)(session.key));
1713
+ if (current !== session || current.providerRetryAbortPromise !== abortPromise)
1714
+ return;
1715
+ current.providerRetrySignature = null;
1716
+ current.providerRetryAbortPromise = null;
1717
+ }, () => {
1718
+ const current = this.sessions.get((0, sessionKey_1.keyToString)(session.key));
1719
+ if (current === session && current.providerRetryAbortPromise === abortPromise) {
1720
+ current.isAwaitingModelAfterProviderRetryAbort = false;
1721
+ current.isAwaitingProviderRetryAbortIdle = false;
1722
+ this.clearProviderRetryReplacementBoundary(current);
1723
+ current.providerRetryAbortPromise = null;
1724
+ }
1725
+ });
1726
+ return abortPromise;
1727
+ }
1728
+ /**
1729
+ * @description Arm the post-abort replacement boundary together with the bound
1730
+ * on how long that boundary may stay armed.
1731
+ *
1732
+ * The boundary exists because `session.status` carries no turn identifier: the
1733
+ * first observed `busy` is taken as the replacement turn's start. A wedged
1734
+ * session never produces that `busy`, so without a bound the boundary latches
1735
+ * forever and the topic hangs with nothing able to recover it.
1736
+ */
1737
+ armProviderRetryReplacementStart(session) {
1738
+ // A second concurrent replacement prompt re-arms: drop the previous bound so
1739
+ // at most one timer per session is ever pending.
1740
+ this.clearProviderRetryReplacementStartTimer(session);
1741
+ session.isAwaitingProviderRetryReplacementStart = true;
1742
+ const timer = setTimeout(() => {
1743
+ const current = this.sessions.get((0, sessionKey_1.keyToString)(session.key));
1744
+ // A restart / resume swapped the session object: the new session owns its
1745
+ // own bound, so this expired one has nothing left to report.
1746
+ if (current !== session)
1747
+ return;
1748
+ const isReplacementMissing = (0, openCodeTurnActivity_1.checkIsReplacementTurnMissing)({
1749
+ isSessionActive: current.isActive,
1750
+ isAwaitingReplacementStart: current.isAwaitingProviderRetryReplacementStart,
1751
+ sawActivity: current.sawTurnActivity,
1752
+ });
1753
+ // Release the boundary UNCONDITIONALLY, before deciding whether to report.
1754
+ // Returning early while the flag is still armed would leave it with no
1755
+ // bound behind it — the exact latch this bound exists to prevent (the idle
1756
+ // guard swallows every own idle while it is set). It is reachable: activity
1757
+ // from a CHILD session satisfies the predicate, while only a PARENT message
1758
+ // releases the boundary on the normal path.
1759
+ this.clearProviderRetryReplacementBoundary(current);
1760
+ if (!isReplacementMissing)
1761
+ return;
1762
+ // End the abort episode here: the optimistic busy flag waits on a `busy`
1763
+ // that is never coming, and the wedged-turn detector stays disarmed because
1764
+ // this path defers arming it to that same `busy`. `isAwaitingProviderRetryAbortIdle`
1765
+ // is deliberately LEFT armed — the aborted retry's own idle is exactly the
1766
+ // event that arrives late here, and it must still be consumed once rather
1767
+ // than counting as the recovery prompt's idle.
1768
+ current.isBusy = false;
1769
+ this.reportNoTurnResponse(current.key, current, 'replacementNeverStarted');
1770
+ }, exports.providerRetryReplacementStartTimeoutMs);
1771
+ // A pending boundary bound must never keep the process alive on shutdown.
1772
+ timer.unref();
1773
+ session.providerRetryReplacementStartTimer = timer;
1774
+ }
1775
+ /** Drop a pending replacement-start bound (teardown, or before re-arming). */
1776
+ clearProviderRetryReplacementStartTimer(session) {
1777
+ if (!session.providerRetryReplacementStartTimer)
1778
+ return;
1779
+ clearTimeout(session.providerRetryReplacementStartTimer);
1780
+ session.providerRetryReplacementStartTimer = null;
1781
+ }
1782
+ /**
1783
+ * @description Resolve the replacement boundary: the flag and its bound are
1784
+ * cleared together so the invariant "boundary disarmed ⇒ no pending timer"
1785
+ * holds at every site, and a resolved boundary can never fire a late
1786
+ * `noResponse` against a turn that did start.
1787
+ */
1788
+ clearProviderRetryReplacementBoundary(session) {
1789
+ session.isAwaitingProviderRetryReplacementStart = false;
1790
+ this.clearProviderRetryReplacementStartTimer(session);
1791
+ }
1792
+ /**
1793
+ * @description The single emit point for "the delivered prompt produced no
1794
+ * turn". Both detectors watch the SAME prompt from different angles — the
1795
+ * idle-time wedge check and the replacement-start bound — so every trigger is
1796
+ * disarmed here before emitting, and one prompt can never reach the bot's
1797
+ * recovery escalation twice.
1798
+ */
1799
+ reportNoTurnResponse(key, session, cause) {
1800
+ session.awaitingTurnResponse = false;
1801
+ this.clearProviderRetryReplacementBoundary(session);
1802
+ const labels = noTurnResponseCauseLabels[cause];
1803
+ console.warn(`[OpenCode] Prompt produced no turn (${labels.console}) for ${(0, sessionKey_1.keyToString)(key)}`);
1804
+ (0, diagLog_1.appendDiagLog)(`prompt NO-RESPONSE ${labels.diag} key=${(0, sessionKey_1.keyToString)(key)} session=${session.sessionId}`);
1805
+ this.emit('noResponse', key);
1806
+ }
1807
+ /**
1808
+ * @description Bot-side fallback for naming an untitled session (R1 safety
1809
+ * net). The primary mechanism is opencode's own LLM auto-title; this runs
1810
+ * ONLY if that never lands.
1811
+ *
1812
+ * On the first MEANINGFUL prompt of an eligible (untitled, not-yet-renamed)
1813
+ * session it clears the pending flag — so it tries at most once — then, after
1814
+ * a grace period that lets auto-title land, reads the live title and PATCHes a
1815
+ * snippet ONLY when the title is still the bare placeholder. The snippet
1816
+ * derives from the RAW user text (preamble stripped) so it can never carry the
1817
+ * thread-context header. Fire-and-forget; a failure leaves the placeholder.
1818
+ */
1819
+ maybeScheduleFallbackRename(session, input) {
1820
+ if (!session.isAutoNamePending)
1821
+ return;
1822
+ // The text arriving here is the preamble-glued prompt; meaningfulness and
1823
+ // the snippet must both be judged on the RAW user text, never the glue.
1824
+ const rawText = (0, threadContextPreamble_1.stripThreadContextPreamble)(input);
1825
+ if (!(0, openCodeSessionTitle_1.checkIsMeaningfulPrompt)(rawText))
1826
+ return;
1827
+ // First meaningful prompt: try at most once, regardless of the outcome.
1828
+ session.isAutoNamePending = false;
1829
+ const snippet = (0, openCodeSessionTitle_1.buildSessionTitleSnippet)(rawText);
1830
+ if (!snippet)
1831
+ return;
1832
+ const { sessionId } = session;
1833
+ void (async () => {
1834
+ await new Promise((resolve) => setTimeout(resolve, this.fallbackRenameGraceMs));
1835
+ try {
1836
+ const apiSession = await this.apiRequest('GET', `/session/${sessionId}`);
1837
+ // Auto-title already produced a real (LLM) name — leave it; the native
1838
+ // name is better than our snippet.
1839
+ if (!(0, openCodeSessionTitle_1.checkIsPlaceholderTitle)(apiSession.title))
1840
+ return;
1841
+ await this.apiRequest('PATCH', `/session/${sessionId}`, { title: snippet });
1842
+ console.log(`[OpenCode] Fallback-renamed untitled session ${sessionId} to "${snippet}"`);
1843
+ }
1844
+ catch (e) {
1845
+ // Best-effort: a failed rename just leaves the placeholder title.
1846
+ console.warn(`[OpenCode] fallback session rename failed:`, e instanceof Error ? e.message : e);
1847
+ }
1848
+ })();
1849
+ }
1850
+ sendSignal(key, signal) {
1851
+ if (signal === 'SIGINT') {
1852
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
1853
+ if (!session?.isActive)
1854
+ return;
1855
+ console.log(`[OpenCode] Aborting session (SIGINT)`);
1856
+ this.apiRequest('POST', `/session/${session.sessionId}/abort`).catch((e) => {
1857
+ console.error(`[OpenCode] abort error:`, e);
1858
+ });
1859
+ }
1860
+ }
1861
+ /**
1862
+ * @description Connect an OpenCode provider through the same API-key auth
1863
+ * endpoint the OpenCode UI uses (`PUT /auth/{providerID}`). Providers with a
1864
+ * custom auth entry must expose a one-field API method; ordinary providers
1865
+ * (including OpenRouter) use the generic API-key method synthesized from the
1866
+ * full `/provider` catalog.
1867
+ */
1868
+ async connectProvider(_key, providerId, apiKey) {
1869
+ const normalizedProviderId = providerId.trim().toLowerCase();
1870
+ if (!checkIsValidProviderId(normalizedProviderId)) {
1871
+ return (0, i18n_1.t)('connect.invalid_provider', { provider: providerId });
1872
+ }
1873
+ const trimmedApiKey = apiKey.trim();
1874
+ if (!trimmedApiKey)
1875
+ return (0, i18n_1.t)('connect.empty_key');
1876
+ try {
1877
+ const methods = await this.fetchProviderAuthMethods(normalizedProviderId);
1878
+ if (!methods.some(openCodeAuthLogin_1.checkIsSimpleApiMethod)) {
1879
+ return (0, i18n_1.t)('connect.unsupported_provider', { provider: normalizedProviderId });
1880
+ }
1881
+ await this.apiRequest('PUT', buildProviderAuthPath(normalizedProviderId), buildProviderApiAuthPayload(trimmedApiKey));
1882
+ resetOpenCodeProviderCaches();
1883
+ console.log(`[OpenCode] Connected provider: ${normalizedProviderId}`);
1884
+ return null;
1885
+ }
1886
+ catch (error) {
1887
+ const reason = error instanceof Error ? error.message : String(error);
1888
+ console.warn(`[OpenCode] provider connect failed for ${normalizedProviderId}:`, reason);
1889
+ return (0, i18n_1.t)('connect.failed', { provider: normalizedProviderId, reason });
1890
+ }
1891
+ }
1892
+ /**
1893
+ * @description Disconnect a provider through the same auth endpoint
1894
+ * `/connect` writes to (`DELETE /auth/{providerID}`), then report what the
1895
+ * delete actually achieved.
1896
+ *
1897
+ * `DELETE /auth` only clears OpenCode's own credential store. A provider
1898
+ * OpenCode enables from an ENVIRONMENT VARIABLE (`openrouter` ←
1899
+ * `OPENROUTER_API_KEY`) is still fully active afterwards, so we re-read
1900
+ * `GET /config/providers` (caches dropped first, exactly like
1901
+ * `connectProvider` does after a credential change) and hand the still-active
1902
+ * provider ids to {@link getProviderDisconnectOutcome}. Claiming a clean
1903
+ * disconnect there would be a lie — that case is what the bot-side "hide
1904
+ * provider" toggle in `/model` exists for.
1905
+ */
1906
+ async disconnectProvider(_key, providerId) {
1907
+ const normalizedProviderId = providerId.trim().toLowerCase();
1908
+ if (!checkIsValidProviderId(normalizedProviderId)) {
1909
+ return (0, i18n_1.t)('disconnect.invalid_provider', { provider: providerId });
1910
+ }
1911
+ try {
1912
+ await this.ensureProviderAuthServerReady();
1913
+ await this.apiRequest('DELETE', buildProviderAuthPath(normalizedProviderId));
1914
+ resetOpenCodeProviderCaches();
1915
+ const config = await this.getProvidersConfig();
1916
+ const outcome = (0, providerDisconnectPlan_1.getProviderDisconnectOutcome)(normalizedProviderId, [...config.providers.keys()]);
1917
+ console.log(`[OpenCode] Disconnected provider: ${normalizedProviderId} (${outcome})`);
1918
+ return outcome === 'stillActiveViaEnv'
1919
+ ? (0, i18n_1.t)('disconnect.still_active_env', { provider: normalizedProviderId })
1920
+ : null;
1921
+ }
1922
+ catch (error) {
1923
+ const reason = error instanceof Error ? error.message : String(error);
1924
+ console.warn(`[OpenCode] provider disconnect failed for ${normalizedProviderId}:`, reason);
1925
+ return (0, i18n_1.t)('disconnect.failed', { provider: normalizedProviderId, reason });
1926
+ }
1927
+ }
1928
+ async ensureProviderAuthServerReady() {
1929
+ if (!(0, installManager_1.checkIsInstalled)('opencode')) {
1930
+ await (0, installManager_1.installTool)('opencode');
1931
+ }
1932
+ if (!await (0, installManager_1.checkIsOpenCodeServerRunning)()) {
1933
+ await (0, installManager_1.ensureOpenCodeServer)();
1934
+ }
1935
+ }
1936
+ /**
1937
+ * @description Fetch a provider's auth methods for the `/connect` picker.
1938
+ * Custom OAuth/multi-step methods come from `/provider/auth`; a provider that
1939
+ * only appears in the full `/provider` catalog gets OpenCode's ordinary
1940
+ * one-field API-key method. Throws on a server/transport error.
1941
+ */
1942
+ async fetchProviderAuthMethods(providerId) {
1943
+ await this.ensureProviderAuthServerReady();
1944
+ const authCatalog = await this.apiRequest('GET', '/provider/auth');
1945
+ if ((0, openCodeAuthLogin_1.checkProviderHasAuthCatalogEntry)(authCatalog, providerId)) {
1946
+ return (0, openCodeAuthLogin_1.parseProviderAuthMethods)(authCatalog, providerId);
1947
+ }
1948
+ const providerCatalog = await this.apiRequest('GET', '/provider');
1949
+ return (0, openCodeAuthLogin_1.getGenericProviderAuthMethods)(providerCatalog, providerId);
1950
+ }
1951
+ /**
1952
+ * @description Set model override for the current session.
1953
+ * Accepts either "provider/modelId" format or partial name to search.
1954
+ * @returns Error message if model not found, null on success
1955
+ */
1956
+ async setModel(key, modelId) {
1957
+ // Resolve first — both calls hit the server/CLI, not session state, so a
1958
+ // model can be picked BEFORE a session exists. `getAvailableModels` is the
1959
+ // stubbable wrapper around `fetchAvailableModels`.
1960
+ const models = await this.getAvailableModels();
1961
+ const resolved = await resolveModelId(modelId, models);
1962
+ if (!resolved) {
1963
+ return `Model "${modelId}" not found. Use /model to see available models.`;
1964
+ }
1965
+ const label = `${resolved.providerID}/${resolved.modelID}`;
1966
+ // Persist the choice unconditionally: a session started later replays the
1967
+ // saved pref via `restoreSavedModel`, so picking a model before `/opencode`
1968
+ // is no longer lost (the "No active session" bug).
1969
+ saveModelPref(key, label);
1970
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
1971
+ if (session?.isActive) {
1972
+ session.modelOverride = resolved;
1973
+ session.isModelInfoShown = false;
1974
+ session.currentModelLabel = label;
1975
+ console.log(`[OpenCode] Model set to: ${label}`);
1976
+ // Discriminate by the PERSISTED pref, not session.effortLevel (which may
1977
+ // already hold a clamped default we seeded). An explicit /effort pick is
1978
+ // validated against the new model and cleared-with-notice if invalid
1979
+ // (today's behavior, plan 2026-05-30-effort-command / S4). With no
1980
+ // explicit pref the default is silently re-clamped for the new model.
1981
+ const explicit = loadSavedEffort(key);
1982
+ if (explicit) {
1983
+ const stillValid = (await this.getAvailableEffortLevels(key)).includes(explicit);
1984
+ if (stillValid) {
1985
+ session.effortLevel = explicit;
1986
+ }
1987
+ else {
1988
+ session.effortLevel = null;
1989
+ clearEffortPref(key);
1990
+ this.emit('output', key, (0, i18n_1.t)('effort.cleared_on_model_switch', { level: explicit, model: label }));
1991
+ }
1992
+ }
1993
+ else {
1994
+ session.effortLevel = (0, effortLevels_1.clampEffortToAvailable)(effortLevels_1.defaultEffortLevel, await this.getModelVariants(resolved));
1995
+ }
1996
+ return null;
1997
+ }
1998
+ // No active session: the pref is saved above. Guard the SAME invariant as
1999
+ // the live branch — a saved effort the new model can't honour would make
2000
+ // the next session POST an invalid `body.variant`, so drop it now and tell
2001
+ // the user, mirroring the live `effort.cleared_on_model_switch` notice.
2002
+ const savedEffort = loadSavedEffort(key);
2003
+ if (savedEffort) {
2004
+ const stillValid = (await this.getModelVariants(resolved)).includes(savedEffort);
2005
+ if (!stillValid) {
2006
+ clearEffortPref(key);
2007
+ this.emit('output', key, (0, i18n_1.t)('effort.cleared_on_model_switch', { level: savedEffort, model: label }));
2008
+ }
2009
+ }
2010
+ console.log(`[OpenCode] Model pref saved (no active session): ${label}`);
2011
+ return null;
2012
+ }
2013
+ /**
2014
+ * @description Get list of available models from OpenCode CLI.
2015
+ */
2016
+ async getAvailableModels() {
2017
+ return fetchAvailableModels();
2018
+ }
2019
+ getCurrentModel(key) {
2020
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
2021
+ if (session?.currentModelLabel)
2022
+ return session.currentModelLabel;
2023
+ // No live label (no session, or session not yet resolved) → fall back to
2024
+ // the saved pref so the /model header and success copy stay correct
2025
+ // pre-session. Mirrors getEffort's session-then-disk read order.
2026
+ const saved = loadSavedModel(key);
2027
+ return saved ? `${saved.providerID}/${saved.modelID}` : null;
2028
+ }
2029
+ async getRuntimeInfo(key) {
2030
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
2031
+ if (!session?.isActive) {
2032
+ return { version: null, model: null, contextWindowTokens: null, contextUsedTokens: null };
2033
+ }
2034
+ const runtimeContext = session.latestParentRuntimeContext;
2035
+ const runtimeModel = runtimeContext?.model ?? null;
2036
+ // Fetch (not cache-peek) the provider config: a cache-only read reported
2037
+ // "context limit unavailable" for the whole TTL window after a server
2038
+ // restart. A failed lookup degrades to an unknown limit — `/status` must
2039
+ // never throw. Skipped entirely without an observed model, since there is
2040
+ // then no model to look a limit up for; the two independent HTTP reads run
2041
+ // concurrently so `/status` waits for the slower one, not for their sum.
2042
+ const [health, providers] = await Promise.all([
2043
+ (0, installManager_1.getOpenCodeServerHealth)(),
2044
+ runtimeModel
2045
+ ? this.getProvidersConfig().catch((error) => {
2046
+ console.warn('[OpenCode] getRuntimeInfo providers lookup failed:', error instanceof Error ? error.message : error);
2047
+ return null;
2048
+ })
2049
+ : null,
2050
+ ]);
2051
+ const contextWindowTokens = runtimeModel && providers
2052
+ ? providers.providers.get(runtimeModel.providerID)?.get(runtimeModel.modelID)?.contextWindowTokens ?? null
2053
+ : null;
2054
+ return {
2055
+ version: health.version,
2056
+ model: runtimeModel ? `${runtimeModel.providerID}/${runtimeModel.modelID}` : null,
2057
+ contextWindowTokens,
2058
+ contextUsedTokens: runtimeContext?.contextUsedTokens ?? null,
2059
+ };
2060
+ }
2061
+ // ── Reasoning effort (variants) ───────────────────────────────────────────
2062
+ /**
2063
+ * @description Fetch + cache the provider/variant config
2064
+ * (`GET /config/providers`). 5-minute TTL — variant maps change about as
2065
+ * often as the model list. The response is not logged because provider
2066
+ * configuration can contain credentials.
2067
+ */
2068
+ async getProvidersConfig() {
2069
+ const now = Date.now();
2070
+ const freshConfig = getFreshProvidersConfig(now);
2071
+ if (freshConfig)
2072
+ return freshConfig;
2073
+ const raw = await this.apiRequest('GET', '/config/providers');
2074
+ const parsed = parseProvidersResponse(raw);
2075
+ cachedProviders = parsed;
2076
+ providersCacheTime = now;
2077
+ return parsed;
2078
+ }
2079
+ /**
2080
+ * @description Variant names a `{providerID, modelID}` model exposes, or
2081
+ * `[]` when the ref is incomplete or the model declares no variants.
2082
+ *
2083
+ * Takes a model ref (not a session) so the no-session `setModel` path can
2084
+ * validate a freshly-picked model's variants before any session exists —
2085
+ * `getAvailableEffortLevels` resolves the ref from the live session.
2086
+ */
2087
+ async getModelVariants(model) {
2088
+ const providerID = model?.providerID;
2089
+ const modelID = model?.modelID;
2090
+ if (!providerID || !modelID)
2091
+ return [];
2092
+ try {
2093
+ const config = await this.getProvidersConfig();
2094
+ return config.providers.get(providerID)?.get(modelID)?.variants ?? [];
2095
+ }
2096
+ catch (e) {
2097
+ console.warn(`[OpenCode] getProvidersConfig failed:`, e instanceof Error ? e.message : e);
2098
+ return [];
2099
+ }
2100
+ }
2101
+ /**
2102
+ * @description Resolve the live session's current model into a
2103
+ * `{providerID, modelID}` ref from the override (preferred) or the label.
2104
+ */
2105
+ getSessionModelRef(session) {
2106
+ if (session.modelOverride)
2107
+ return session.modelOverride;
2108
+ const label = session.currentModelLabel;
2109
+ if (label && label.includes('/')) {
2110
+ return {
2111
+ providerID: label.slice(0, label.indexOf('/')),
2112
+ modelID: label.slice(label.indexOf('/') + 1),
2113
+ };
2114
+ }
2115
+ return null;
2116
+ }
2117
+ /**
2118
+ * @description Seed the bot's default reasoning effort onto a freshly
2119
+ * started/resumed session that has NO explicit per-thread `/effort` pref.
2120
+ *
2121
+ * Explicit prefs always win and are left untouched (early return). With no
2122
+ * pref, the default (`defaultEffortLevel`) is clamped to the resolved
2123
+ * model's variants — OpenCode effort is a model variant and not every model
2124
+ * ships `xhigh`, so an unclamped value would POST an invalid `body.variant`.
2125
+ * A model with no variants resolves to `null` (no effort), which is correct.
2126
+ *
2127
+ * Never persisted: the default stays a derived fallback, the prefs file
2128
+ * remains "explicit choices only".
2129
+ */
2130
+ async applyDefaultEffortIfUnset(key) {
2131
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
2132
+ if (!session?.isActive)
2133
+ return;
2134
+ if (loadSavedEffort(key) !== null)
2135
+ return; // explicit pref wins
2136
+ const variants = await this.getModelVariants(this.getSessionModelRef(session));
2137
+ session.effortLevel = (0, effortLevels_1.clampEffortToAvailable)(effortLevels_1.defaultEffortLevel, variants);
2138
+ }
2139
+ /**
2140
+ * @description Resolve the model the thread's effort applies to, WITHOUT
2141
+ * requiring a live session — the prospective model the NEXT session will
2142
+ * use. Priority mirrors `fetchModelInfo`'s resolution order so the
2143
+ * pre-session effort surface lines up with what a started session would
2144
+ * actually run:
2145
+ * 1. live session's model ref (active session wins), else
2146
+ * 2. the thread's saved `/model` pref (`loadSavedModel`), else
2147
+ * 3. the OpenCode server's default model (`GET /config`
2148
+ * `defaultModel` / `model`).
2149
+ * Returns `null` only when none of these yield a complete `{providerID,
2150
+ * modelID}` ref, so `/effort` pre-session validates against the same model
2151
+ * `/model` would persist.
2152
+ */
2153
+ async getProspectiveModelRef(key) {
2154
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
2155
+ if (session?.isActive) {
2156
+ const liveRef = this.getSessionModelRef(session);
2157
+ if (liveRef)
2158
+ return liveRef;
2159
+ }
2160
+ const saved = loadSavedModel(key);
2161
+ if (saved)
2162
+ return saved;
2163
+ // No session and no saved pref → fall back to the server's default model,
2164
+ // resolved the SAME way `fetchModelInfo` does (defaultModel, then a
2165
+ // `provider/model` config string). Best-effort: a sick/booting server
2166
+ // just yields null and the picker reports "no levels".
2167
+ try {
2168
+ const config = await this.apiRequest('GET', '/config');
2169
+ if (config?.defaultModel?.providerID && config?.defaultModel?.modelID) {
2170
+ return { providerID: config.defaultModel.providerID, modelID: config.defaultModel.modelID };
2171
+ }
2172
+ if (config?.model) {
2173
+ const slashIdx = config.model.indexOf('/');
2174
+ if (slashIdx > 0) {
2175
+ return {
2176
+ providerID: config.model.slice(0, slashIdx),
2177
+ modelID: config.model.slice(slashIdx + 1),
2178
+ };
2179
+ }
2180
+ }
2181
+ }
2182
+ catch (e) {
2183
+ console.warn(`[OpenCode] getProspectiveModelRef config lookup failed:`, e instanceof Error ? e.message : e);
2184
+ }
2185
+ return null;
2186
+ }
2187
+ /**
2188
+ * @description Effort levels the thread can use: exactly the variants its
2189
+ * model declares. Works pre-session too — with no live session it resolves
2190
+ * the PROSPECTIVE model (saved `/model` pref → server default) so the
2191
+ * `/effort` picker can list the next session's variants before `/opencode`,
2192
+ * mirroring how `/model` works pre-session. Empty when the (live or
2193
+ * prospective) model declares no variants or can't be resolved.
2194
+ */
2195
+ async getAvailableEffortLevels(key) {
2196
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
2197
+ if (session?.isActive) {
2198
+ return this.getModelVariants(this.getSessionModelRef(session));
2199
+ }
2200
+ return this.getModelVariants(await this.getProspectiveModelRef(key));
2201
+ }
2202
+ getEffort(key) {
2203
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
2204
+ if (session)
2205
+ return session.effortLevel;
2206
+ return loadSavedEffort(key);
2207
+ }
2208
+ /**
2209
+ * @description Set the per-thread effort (variant). Validates against the
2210
+ * thread's available variants — live model when a session is running, the
2211
+ * PROSPECTIVE model (saved `/model` pref → server default) when not — then
2212
+ * persists on success (applied per-prompt by `sendPromptAsync`, plan D3; a
2213
+ * later session seeds `effortLevel` from this pref at creation). Returns a
2214
+ * user-facing notice string on any non-success, `null` on success.
2215
+ *
2216
+ * Mirrors `setModel`'s persist-first symmetry (no `'No active session'`
2217
+ * hard-fail): a level picked BEFORE `/opencode` is saved and replayed by the
2218
+ * next session, instead of being lost — same intent as the model pref.
2219
+ */
2220
+ async setEffort(key, level) {
2221
+ // Session-free capable: resolves variants from the live model if active,
2222
+ // else the prospective model the next session will use.
2223
+ const available = await this.getAvailableEffortLevels(key);
2224
+ if (available.length === 0) {
2225
+ // Name the model the validation ran against — the live label if a
2226
+ // session is up, else the prospective model — so the notice is correct
2227
+ // pre-session (the live `currentModelLabel` is null then).
2228
+ const modelLabel = await this.getEffortModelLabel(key);
2229
+ return (0, i18n_1.t)('effort.not_supported', { model: modelLabel });
2230
+ }
2231
+ if (!available.includes(level)) {
2232
+ return (0, i18n_1.t)('effort.invalid_level', { level, valid: available.join(', ') });
2233
+ }
2234
+ // Persist unconditionally (like `setModel`'s `saveModelPref`): a session
2235
+ // started later seeds `effortLevel: loadSavedEffort(key)` at creation.
2236
+ saveEffortPref(key, level);
2237
+ // Live session → also apply now so the in-flight session honours it on the
2238
+ // next prompt without waiting for a restart.
2239
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
2240
+ if (session?.isActive)
2241
+ session.effortLevel = level;
2242
+ console.log(`[OpenCode] Effort set to: ${level}`);
2243
+ return null;
2244
+ }
2245
+ /**
2246
+ * @description Human label for the model the thread's effort validates
2247
+ * against — the live session's label when active, else the prospective
2248
+ * model's `provider/model` — for the `effort.not_supported` notice. `'?'`
2249
+ * only when no model can be resolved at all.
2250
+ */
2251
+ async getEffortModelLabel(key) {
2252
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
2253
+ if (session?.isActive && session.currentModelLabel)
2254
+ return session.currentModelLabel;
2255
+ const prospective = await this.getProspectiveModelRef(key);
2256
+ return prospective ? `${prospective.providerID}/${prospective.modelID}` : '?';
2257
+ }
2258
+ /**
2259
+ * @description Manually rename the live session via `PATCH /session/:id
2260
+ * { title }`, scoped to the session's owning project instance
2261
+ * (`?directory=<workDir>`) so it hits the same instance the session was
2262
+ * created in. Reuses {@link apiRequest} — no duplicate HTTP code.
2263
+ *
2264
+ * A manual rename is final: it clears `isAutoNamePending` so the bot-side
2265
+ * auto-name fallback can never overwrite the user's title later (the
2266
+ * fallback only fires while that flag is set — see
2267
+ * {@link maybeScheduleFallbackRename}).
2268
+ */
2269
+ async renameSession(key, title) {
2270
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
2271
+ if (!session?.isActive)
2272
+ return (0, i18n_1.t)('rename_session.start_agent_first');
2273
+ // The user explicitly named it — auto-title must never win over a manual
2274
+ // rename, so retire the fallback for this session regardless of the PATCH
2275
+ // outcome.
2276
+ session.isAutoNamePending = false;
2277
+ try {
2278
+ await this.apiRequest('PATCH', buildDirectoryScopedPath(`/session/${session.sessionId}`, session.workDir), { title });
2279
+ console.log(`[OpenCode] Renamed session ${session.sessionId} to "${title}"`);
2280
+ return null;
2281
+ }
2282
+ catch (e) {
2283
+ const reason = e instanceof Error ? e.message : String(e);
2284
+ console.warn(`[OpenCode] manual session rename failed:`, reason);
2285
+ return (0, i18n_1.t)('rename_session.failed', { reason });
2286
+ }
2287
+ }
2288
+ /**
2289
+ * @description Compact the live session's context via `POST /session/:id/
2290
+ * summarize`, scoped to the session's owning project instance
2291
+ * (`?directory=<workDir>`) so it hits the same instance the session was
2292
+ * created in. Reuses {@link apiRequest} — no duplicate HTTP code.
2293
+ *
2294
+ * This is the REAL compaction path. `prompt_async` (the only other transport)
2295
+ * does no slash-command parsing server-side, so forwarding the literal
2296
+ * `/compact` text there just burned a model turn answering a two-word prompt
2297
+ * — the bug this method fixes.
2298
+ *
2299
+ * `summarize` runs a summarisation turn, so it REQUIRES a complete model ref:
2300
+ * the session's `modelOverride` (populated from the server default at session
2301
+ * start) wins, else {@link getProspectiveModelRef} resolves it exactly the way
2302
+ * `/effort` does (saved `/model` pref → server `defaultModel`). Unresolvable →
2303
+ * a notice, never a partial/empty `providerID`/`modelID`.
2304
+ *
2305
+ * `auto` is deliberately omitted: this is a MANUAL compaction and the server
2306
+ * defaults the flag to false (the automatic, overflow-triggered compaction is
2307
+ * server-side and untouched by this).
2308
+ */
2309
+ async compactContext(key, instruction) {
2310
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
2311
+ if (!session?.isActive)
2312
+ return { ok: false, error: (0, i18n_1.t)('compact.start_agent_first') };
2313
+ const modelRef = session.modelOverride ?? (await this.getProspectiveModelRef(key));
2314
+ if (!modelRef)
2315
+ return { ok: false, error: (0, i18n_1.t)('compact.model_unresolved') };
2316
+ // The fork's summarize endpoint appends `instruction` to its baked
2317
+ // compaction prompt (F2/S6) — only send it when non-empty so an ordinary
2318
+ // `/compact` stays byte-identical to before.
2319
+ const trimmedInstruction = instruction?.trim();
2320
+ try {
2321
+ await this.apiRequest('POST', buildDirectoryScopedPath(`/session/${session.sessionId}/summarize`, session.workDir), {
2322
+ providerID: modelRef.providerID,
2323
+ modelID: modelRef.modelID,
2324
+ ...(trimmedInstruction ? { instruction: trimmedInstruction } : {}),
2325
+ });
2326
+ console.log(`[OpenCode] Compacting session ${session.sessionId} with ${modelRef.providerID}/${modelRef.modelID}`);
2327
+ // Counts are always `null` here: `/summarize` reports none, so the bot's
2328
+ // completion message drops the numbers rather than inventing them.
2329
+ return { ok: true, preTokens: null, postTokens: null };
2330
+ }
2331
+ catch (e) {
2332
+ const reason = e instanceof Error ? e.message : String(e);
2333
+ console.warn(`[OpenCode] context compaction failed:`, reason);
2334
+ return { ok: false, error: (0, i18n_1.t)('compact.failed', { reason }) };
2335
+ }
2336
+ }
2337
+ /**
2338
+ * @description Whether this thread's directory instance loaded the bot's
2339
+ * compaction plugin, read from its merged config (`GET /config`, directory
2340
+ * scoped). An instance created before the plugin was installed lacks it until
2341
+ * it is recreated, and then the bot must keep sending the skills guidance
2342
+ * itself. A read failure answers `false`: the guidance then appears twice,
2343
+ * which beats not at all.
2344
+ */
2345
+ async checkHasCompactionSkillsHook(key) {
2346
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
2347
+ if (!session?.isActive)
2348
+ return false;
2349
+ try {
2350
+ const config = await this.apiRequest('GET', buildDirectoryScopedPath('/config', session.workDir));
2351
+ return (0, openCodeCompactPlugin_1.checkHasOpenCodeCompactPlugin)(config?.plugin);
2352
+ }
2353
+ catch (e) {
2354
+ console.warn(`[OpenCode] reading the server config failed:`, e instanceof Error ? e.message : e);
2355
+ return false;
2356
+ }
2357
+ }
2358
+ /**
2359
+ * @description Read the newest compaction summary for the live session via
2360
+ * `GET /session/:id/message` (directory-scoped) and lift its text out with the
2361
+ * pure {@link getLatestOpenCodeCompactionSummary}. Used by F2 to surface the
2362
+ * appended "Where we stopped" closing section in the idle-compaction notice. A
2363
+ * read failure / no summary yields `null` (the notice then omits the block).
2364
+ */
2365
+ async getLatestCompactionSummary(key) {
2366
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
2367
+ if (!session?.isActive)
2368
+ return null;
2369
+ try {
2370
+ const records = await this.apiRequest('GET', buildDirectoryScopedPath(`/session/${session.sessionId}/message`, session.workDir));
2371
+ return getLatestOpenCodeCompactionSummary(records);
2372
+ }
2373
+ catch (e) {
2374
+ console.warn(`[OpenCode] reading compaction summary failed:`, e instanceof Error ? e.message : e);
2375
+ return null;
2376
+ }
2377
+ }
2378
+ getOpenCodeSessionId(key) {
2379
+ return this.sessions.get((0, sessionKey_1.keyToString)(key))?.sessionId ?? null;
2380
+ }
2381
+ // Folder-scoped listing: `GET /session?directory=<workDir>` returns only the
2382
+ // sessions of the bound folder's project instance. The topic is always bound
2383
+ // before this is reached (the bot gates `/sessions` on a binding), so workDir
2384
+ // is the real working folder, never a serve-cwd fallback. Sessions created in
2385
+ // other instances (e.g. by-hand serve-cwd scatter) are intentionally absent.
2386
+ async getSessions(_key, workDir) {
2387
+ try {
2388
+ const apiSessions = await this.apiRequest('GET', buildDirectoryScopedPath('/session', workDir));
2389
+ if (!Array.isArray(apiSessions))
2390
+ return [];
2391
+ return apiSessions.map(s => ({
2392
+ id: s.id,
2393
+ title: s.title || s.id,
2394
+ // `time.created`/`time.updated` are already epoch MILLISECONDS
2395
+ // (13-digit values verified live via GET /session). The old `* 1000`
2396
+ // pushed every date millennia into the future, so the /sessions list
2397
+ // showed "(just now)" for every entry (B13).
2398
+ createdAt: s.time?.created ? new Date(s.time.created) : new Date(),
2399
+ updatedAt: s.time?.updated ? new Date(s.time.updated) : new Date(),
2400
+ }));
2401
+ }
2402
+ catch (e) {
2403
+ console.error(`[OpenCode] Failed to get sessions:`, e);
2404
+ return [];
2405
+ }
2406
+ }
2407
+ /**
2408
+ * @description Read the last `limit` conversational turns of a session for the
2409
+ * resume context block via `GET /session/:id/message`. `key`/`workDir` are
2410
+ * unused — OpenCode scopes messages by session id, server-side. Maps the raw
2411
+ * records with the pure {@link mapOpenCodeMessagesToTurns}; a request failure
2412
+ * yields `[]`, so the caller posts no context block.
2413
+ */
2414
+ async getRecentTurns(_key, _workDir, sessionId, limit) {
2415
+ try {
2416
+ const records = await this.apiRequest('GET', `/session/${sessionId}/message`);
2417
+ return mapOpenCodeMessagesToTurns(records, limit);
2418
+ }
2419
+ catch (e) {
2420
+ console.error(`[OpenCode] Failed to read recent turns:`, e);
2421
+ return [];
2422
+ }
2423
+ }
2424
+ /**
2425
+ * @description Assemble the silent-reattach recap for OpenCode: ONE
2426
+ * `GET /session/:id/message` read serves both the missed-message count (since
2427
+ * the watermark id) and the last-{@link resumeContextTurnLimit} turns. A read
2428
+ * failure degrades to an empty, watermark-unknown recap (the caller posts
2429
+ * nothing). The watermark only counts when its `sessionId` matches the session
2430
+ * being recapped — a stale watermark from a different session (`/new`,
2431
+ * `/sessions` resume keep the agent row) is treated as unknown. `isActive` is
2432
+ * derived from the fetched records (last record = an unfinished assistant
2433
+ * turn), never a blocking server probe.
2434
+ */
2435
+ async getReattachRecap(_key, _workDir, sessionId, watermark) {
2436
+ let records;
2437
+ try {
2438
+ records = await this.apiRequest('GET', `/session/${sessionId}/message`);
2439
+ }
2440
+ catch (e) {
2441
+ console.error(`[OpenCode] getReattachRecap read failed:`, e);
2442
+ return { missedCount: 0, turns: [], isWatermarkKnown: false, isActive: false };
2443
+ }
2444
+ // Only trust the watermark id when it was taken against THIS session.
2445
+ const watermarkId = watermark?.sessionId === sessionId ? watermark.opencodeMessageId : undefined;
2446
+ const { missedCount, isWatermarkKnown } = countOpenCodeAssistantMessagesSinceId(records, watermarkId);
2447
+ const turns = mapOpenCodeMessagesToTurns(records, resumeContext_1.resumeContextTurnLimit);
2448
+ const isActive = checkIsOpenCodeTurnInFlight(records);
2449
+ // Head watermark = the latest assistant message id, scoped to THIS session.
2450
+ // Omitted when none resolvable (head unknown → no idempotent advance).
2451
+ const latestId = getLatestOpenCodeAssistantMessageId(records);
2452
+ const headWatermark = latestId === undefined ? undefined : { sessionId, opencodeMessageId: latestId };
2453
+ return { missedCount, turns, isWatermarkKnown, isActive, headWatermark };
2454
+ }
2455
+ async resumeSession(key, workDir, sessionId, options) {
2456
+ // `workDir` is now an explicit argument from the bot, sourced from the
2457
+ // thread's binding in state.json. The old code defaulted to
2458
+ // `process.env.WORK_DIR || '/workspace'`, which silently mis-routed
2459
+ // resumes to the wrong folder as soon as the bot started managing
2460
+ // multiple bindings (plan §10.3, fix to old openCodeAdapter.ts:599).
2461
+ const k = (0, sessionKey_1.keyToString)(key);
2462
+ return this.withLifecycleLock(k, () => this.resumeSessionInner(key, workDir, sessionId, options));
2463
+ }
2464
+ /**
2465
+ * @description Fork the thread's CURRENT session into a new one that carries
2466
+ * the full conversation, and attach to it. Used by the bot's wedged-turn
2467
+ * recovery (tier 2) to restore the last dialog into a fresh, unwedged session
2468
+ * before replaying the prompt — so a stuck session doesn't lose context. The
2469
+ * new session's id is exposed via {@link getOpenCodeSessionId}, so the bot
2470
+ * persists it with the shared `persistAdapterSessionIds`. Returns the new
2471
+ * session id, or null when there is no active session or the fork failed.
2472
+ */
2473
+ async forkSession(key) {
2474
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
2475
+ if (!session?.isActive)
2476
+ return null;
2477
+ const { workDir, sessionId } = session;
2478
+ try {
2479
+ const forked = await this.apiRequest('POST', `/session/${sessionId}/fork`);
2480
+ if (!forked?.id)
2481
+ return null;
2482
+ // resumeSession (locked) stops the wedged session and attaches to the fork
2483
+ // — same machinery as a /sessions pick, so SSE routing + watermarking are
2484
+ // re-seeded for the new id.
2485
+ await this.resumeSession(key, workDir, forked.id);
2486
+ console.log(`[OpenCode] Forked ${sessionId} -> ${forked.id} for ${(0, sessionKey_1.keyToString)(key)} (dialog preserved)`);
2487
+ return forked.id;
2488
+ }
2489
+ catch (e) {
2490
+ console.error(`[OpenCode] forkSession failed:`, e instanceof Error ? e.message : e);
2491
+ return null;
2492
+ }
2493
+ }
2494
+ /**
2495
+ * @description Detach any RUNNING sub-agent that is synchronously blocking the
2496
+ * thread's session, so a new prompt can be answered while the sub-agent keeps
2497
+ * working in the background (OpenCode injects its result back when it finishes).
2498
+ * No-op (returns false) when the session is inactive or has no busy sub-agent.
2499
+ * Needs the server started with `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true`
2500
+ * (the bot always sets it); if not enabled the endpoint 400s and we swallow it.
2501
+ * Best-effort — a failure must never block the prompt that follows it.
2502
+ */
2503
+ async detachRunningSubagents(key) {
2504
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
2505
+ if (!session?.isActive || session.busyChildSessionIds.size === 0)
2506
+ return false;
2507
+ try {
2508
+ const dir = encodeURIComponent(session.workDir);
2509
+ const result = await this.apiRequest('POST', `/experimental/session/${session.sessionId}/background?directory=${dir}`);
2510
+ if (result)
2511
+ console.log(`[OpenCode] Backgrounded sub-agent(s) for ${(0, sessionKey_1.keyToString)(key)} to unblock the session`);
2512
+ return result === true;
2513
+ }
2514
+ catch (e) {
2515
+ console.warn(`[OpenCode] detachRunningSubagents failed:`, e instanceof Error ? e.message : e);
2516
+ return false;
2517
+ }
2518
+ }
2519
+ async resumeSessionInner(key, workDir, sessionId, options) {
2520
+ this.stopSessionInner(key);
2521
+ const k = (0, sessionKey_1.keyToString)(key);
2522
+ if (!(0, installManager_1.checkIsInstalled)('opencode')) {
2523
+ this.emit('output', key, 'Installing OpenCode...');
2524
+ await (0, installManager_1.installTool)('opencode');
2525
+ }
2526
+ if (!await (0, installManager_1.checkIsOpenCodeServerRunning)()) {
2527
+ this.emit('output', key, 'Starting OpenCode server...');
2528
+ await (0, installManager_1.ensureOpenCodeServer)();
2529
+ }
2530
+ console.log(`[OpenCode] Resuming session ${sessionId} for ${k} in ${workDir}`);
2531
+ try {
2532
+ // Verify session exists
2533
+ const apiSession = await this.apiRequest('GET', `/session/${sessionId}`);
2534
+ const session = {
2535
+ key,
2536
+ sessionId: apiSession.id,
2537
+ workDir,
2538
+ isActive: true,
2539
+ currentResponseText: '',
2540
+ lastEmittedLength: 0,
2541
+ outputTimer: null,
2542
+ compactionSummaryMessageId: null,
2543
+ isResponseCompactionSummary: false,
2544
+ childResponseText: '',
2545
+ childLastEmittedLength: 0,
2546
+ childOutputTimer: null,
2547
+ activeSubagentTitle: null,
2548
+ isModelInfoShown: false,
2549
+ modelOverride: null,
2550
+ currentModelLabel: null,
2551
+ latestParentRuntimeContext: null,
2552
+ parentAssistantObservationVersion: 0,
2553
+ partTypes: new Map(),
2554
+ statusDebounceTimer: null,
2555
+ pendingStatus: null,
2556
+ reasoningText: '',
2557
+ reasoningStartedAt: null,
2558
+ reasoningTimer: null,
2559
+ emittedToolResultPartIds: new Set(),
2560
+ pendingQuestion: null,
2561
+ effortLevel: loadSavedEffort(key),
2562
+ isBusy: false,
2563
+ awaitingTurnResponse: false,
2564
+ sawTurnActivity: false,
2565
+ unconsumedInputCount: 0,
2566
+ seenUserMessageIds: new Set(),
2567
+ providerRetrySignature: null,
2568
+ isAwaitingModelAfterProviderRetryAbort: false,
2569
+ isAwaitingProviderRetryAbortIdle: false,
2570
+ isAwaitingProviderRetryReplacementStart: false,
2571
+ providerRetryReplacementStartTimer: null,
2572
+ providerRetryAbortPromise: null,
2573
+ isCompacting: false,
2574
+ busyChildSessionIds: new Set(),
2575
+ // The watermark anchor is re-learned from the next finished turn; the
2576
+ // persisted watermark (read by the bot for the reattach recap) is
2577
+ // independent of this fresh-session field.
2578
+ lastMessageId: undefined,
2579
+ // A resumed session already has a name and history — never auto-rename.
2580
+ isAutoNamePending: false,
2581
+ };
2582
+ this.detachDuplicateSessionOwners(k, session.sessionId);
2583
+ this.sessions.set(k, session);
2584
+ this.connectSse(key);
2585
+ // Ready BEFORE model resolution (B18): a busy server can stall GET /config
2586
+ // for tens of seconds; the topic must not wait on it for the reattach
2587
+ // notice. Session + SSE are live here.
2588
+ this.emit('started', key);
2589
+ // History is only a fallback for context that predates this attachment.
2590
+ // It must not hold up the resumed session's ready signal or post a recap.
2591
+ void this.hydrateLatestParentAssistantContext(key, session);
2592
+ // Re-surface a question still open on the server (G1): this method runs on
2593
+ // BOTH silent restart re-attach AND explicit /sessions resume, so an
2594
+ // unanswered question is recovered in either case. Runs UNCONDITIONALLY
2595
+ // (not gated on isWithRecentContext) because the silent reattach — the
2596
+ // path that auto-unsticks a wedged topic after a hot rebuild — passes it
2597
+ // false. Awaited so the buttons re-post before model resolution; it
2598
+ // never throws (best-effort inside).
2599
+ await this.restoreOpenQuestion(key, apiSession.id, workDir);
2600
+ // Post the short last-N-turn context block so a resume shows where the
2601
+ // conversation left off (parity with Claude — OpenCode has no flood, but
2602
+ // gets the same block). ONLY on the explicit user resume: this method
2603
+ // also runs on silent re-attach after every bot restart and on crash
2604
+ // recovery, and posting the block there spammed every active topic on
2605
+ // every hot rebuild. Best-effort: a read failure or empty history posts
2606
+ // nothing extra. Runs before model resolution for the same
2607
+ // responsiveness reason as `emit('started')` above.
2608
+ if (options?.isWithRecentContext) {
2609
+ try {
2610
+ const turns = await this.getRecentTurns(key, workDir, apiSession.id, resumeContext_1.resumeContextTurnLimit);
2611
+ const rendered = (0, resumeContext_1.formatResumeContext)(turns);
2612
+ // Complete one-shot block — post instantly, never via the dm draft
2613
+ // channel (its typing animation would "draw" this already-whole text).
2614
+ if (rendered)
2615
+ this.emit('output', key, rendered, { isComplete: true });
2616
+ }
2617
+ catch (e) {
2618
+ console.warn(`[OpenCode] resume context block failed:`, e instanceof Error ? e.message : e);
2619
+ }
2620
+ }
2621
+ // Re-resolve the model on every resume so a session that took its model
2622
+ // from the server default (no saved /model pref) keeps a populated
2623
+ // modelOverride/currentModelLabel after a bot restart — otherwise /effort
2624
+ // can't resolve the model and reports "levels unavailable" (B17).
2625
+ // Silent (emitOutput=false): the label is unchanged from the previous run
2626
+ // and already shown in the topic, so re-emitting on each restart is noise.
2627
+ await this.fetchModelInfo(key, false);
2628
+ // Re-establish the default effort on resume too (no explicit pref →
2629
+ // clamp xhigh to the re-resolved model's variants). Mirrors startSession.
2630
+ await this.applyDefaultEffortIfUnset(key);
2631
+ }
2632
+ catch (e) {
2633
+ console.error(`[OpenCode] Failed to resume session:`, e);
2634
+ throw e;
2635
+ }
2636
+ }
2637
+ /**
2638
+ * @description Best-effort hydration for an idle resumed session's context
2639
+ * usage. A live SSE observation wins over this historical read even when the
2640
+ * history request resolves later.
2641
+ */
2642
+ async hydrateLatestParentAssistantContext(key, session) {
2643
+ // Snapshot before waiting: a queued read must never overwrite an SSE tuple
2644
+ // that arrives while the semaphore is occupied by other resumed sessions.
2645
+ const observationVersion = session.parentAssistantObservationVersion;
2646
+ await this.acquireRuntimeContextHydrationSlot();
2647
+ try {
2648
+ const currentSession = this.sessions.get((0, sessionKey_1.keyToString)(key));
2649
+ if (currentSession !== session ||
2650
+ !currentSession.isActive ||
2651
+ currentSession.parentAssistantObservationVersion !== observationVersion) {
2652
+ return;
2653
+ }
2654
+ const records = await this.apiRequest('GET', `/session/${session.sessionId}/message`);
2655
+ const latestContext = getLatestOpenCodeParentAssistantContext(records, session.sessionId);
2656
+ if (!latestContext)
2657
+ return;
2658
+ const currentSessionAfterRead = this.sessions.get((0, sessionKey_1.keyToString)(key));
2659
+ if (currentSessionAfterRead !== session ||
2660
+ !currentSessionAfterRead.isActive ||
2661
+ currentSessionAfterRead.parentAssistantObservationVersion !== observationVersion) {
2662
+ return;
2663
+ }
2664
+ currentSessionAfterRead.latestParentRuntimeContext = latestContext;
2665
+ }
2666
+ catch (error) {
2667
+ console.warn(`[OpenCode] context hydration failed for ${(0, sessionKey_1.keyToString)(key)}:`, error instanceof Error ? error.message : error);
2668
+ }
2669
+ finally {
2670
+ this.releaseRuntimeContextHydrationSlot();
2671
+ }
2672
+ }
2673
+ /** @description Wait for a bounded slot before fetching optional history metadata. */
2674
+ async acquireRuntimeContextHydrationSlot() {
2675
+ if (this.runtimeContextHydrationCount < exports.openCodeRuntimeContextHydrationConcurrency) {
2676
+ this.runtimeContextHydrationCount += 1;
2677
+ return;
2678
+ }
2679
+ await new Promise((resolve) => {
2680
+ this.runtimeContextHydrationWaiters.push(resolve);
2681
+ });
2682
+ }
2683
+ /** @description Hand a completed metadata-read slot to the next queued session. */
2684
+ releaseRuntimeContextHydrationSlot() {
2685
+ const nextWaiter = this.runtimeContextHydrationWaiters.shift();
2686
+ if (nextWaiter) {
2687
+ nextWaiter();
2688
+ return;
2689
+ }
2690
+ this.runtimeContextHydrationCount -= 1;
2691
+ }
2692
+ /**
2693
+ * @description Ensure the single global SSE stream is open and the session's
2694
+ * directory has its scheduler MCP registered (plan 2026-06-17). Called after
2695
+ * a session is inserted active into `this.sessions` (start / resume / restart).
2696
+ * The stream is shared by ALL sessions, so it opens only for the FIRST active
2697
+ * session anywhere; later calls reuse it. Scheduler-MCP registration is per
2698
+ * directory (decoupled from the stream), gated to once per dir per server
2699
+ * generation. Keeps the `(key)` signature so the lifecycle paths and tests
2700
+ * (which stub `connectSse` / `pollSseStream`) are unchanged.
2701
+ */
2702
+ connectSse(key) {
2703
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
2704
+ if (!session)
2705
+ return;
2706
+ this.ensureGlobalStream();
2707
+ void this.registerSchedulerMcpForDirectory(session.workDir);
2708
+ }
2709
+ /**
2710
+ * Re-register active directories once the bot scheduler listener is ready,
2711
+ * for sessions that connected while injection was still inert. Boot binds
2712
+ * the listener before re-attaching sessions, so it does not call this.
2713
+ */
2714
+ registerSchedulerMcpForActiveSessions() {
2715
+ for (const session of this.sessions.values()) {
2716
+ if (session.isActive) {
2717
+ void this.registerSchedulerMcpForDirectory(session.workDir);
2718
+ }
2719
+ }
2720
+ }
2721
+ /**
2722
+ * @description Boot self-heal for the adopt path. When the bot restarts and
2723
+ * ADOPTS an already-running opencode (the server outlived the bot), that
2724
+ * server still holds the PREVIOUS bot generation's `telegramBot` registration
2725
+ * pointing at a now-dead ephemeral port — so every session in the directory
2726
+ * reports the MCP `failed` and loses its scheduling / file-send tools. For
2727
+ * each active session's directory (de-duped — a folder can host several
2728
+ * threads), read the LIVE `GET /mcp` status and, when the `telegramBot` entry
2729
+ * is missing or not `connected`, drop the Set gate and force a fresh POST (the
2730
+ * server overwrites a same-name registration with the current port). A dir
2731
+ * already `connected` is left untouched. Best-effort per directory: a read /
2732
+ * registration error is logged and swallowed — it must never throw out of
2733
+ * boot. Supersedes {@link registerSchedulerMcpForActiveSessions} at the boot
2734
+ * call site (a not-yet-registered dir has no entry → treated as needing one).
2735
+ */
2736
+ async reconcileSchedulerMcpForActiveSessions() {
2737
+ const directories = new Set();
2738
+ for (const session of this.sessions.values()) {
2739
+ if (session.isActive)
2740
+ directories.add(session.workDir);
2741
+ }
2742
+ // Reconcile every directory concurrently so one slow/unresponsive dir can't
2743
+ // delay the others; each is best-effort (never rejects), so allSettled is
2744
+ // just a join point.
2745
+ await Promise.allSettled(Array.from(directories, (directory) => this.reconcileSchedulerMcpForDirectory(directory)));
2746
+ }
2747
+ /**
2748
+ * @description Boot step that makes every active directory's instance load the
2749
+ * bot's compaction plugin. OpenCode reads plugins once per directory instance,
2750
+ * so an instance created before the plugin file existed (a server that outlived
2751
+ * the bot, actagent's included) never gets it on its own. Installs the file,
2752
+ * then per directory (de-duped, concurrently) recreates the instance when it
2753
+ * lacks the plugin and nothing in it is running. A recreated directory loses
2754
+ * its runtime `telegramBot` registration, so its Set gate is dropped here and
2755
+ * the boot caller reconciles the MCP right after.
2756
+ */
2757
+ async activateCompactionPluginForActiveSessions() {
2758
+ const directories = new Set();
2759
+ for (const session of this.sessions.values()) {
2760
+ if (session.isActive)
2761
+ directories.add(session.workDir);
2762
+ }
2763
+ if (directories.size === 0)
2764
+ return;
2765
+ if ((0, openCodeCompactPlugin_1.installOpenCodeCompactPlugin)() === 'failed')
2766
+ return;
2767
+ await Promise.allSettled(Array.from(directories, (directory) => this.activateCompactionPluginForDirectory(directory)));
2768
+ }
2769
+ /**
2770
+ * @description One directory of {@link activateCompactionPluginForActiveSessions}:
2771
+ * read its instance's plugin list and session status, and recreate it
2772
+ * (`POST /instance/dispose`) only when the plugin is missing and every session
2773
+ * is idle. Best-effort — errors are logged and swallowed.
2774
+ */
2775
+ async activateCompactionPluginForDirectory(directory) {
2776
+ try {
2777
+ const config = await this.apiRequest('GET', buildDirectoryScopedPath('/config', directory));
2778
+ const sessionStatus = await this.apiRequest('GET', buildDirectoryScopedPath('/session/status', directory));
2779
+ const activation = (0, openCodeCompactPlugin_1.getCompactPluginActivation)({ pluginSpecs: config?.plugin, sessionStatus });
2780
+ if (activation === 'loaded')
2781
+ return;
2782
+ if (activation === 'busy') {
2783
+ console.log(`[OpenCode] compaction plugin not loaded in ${directory} yet; it is busy, the next boot retries`);
2784
+ return;
2785
+ }
2786
+ await this.apiRequest('POST', buildDirectoryScopedPath('/instance/dispose', directory));
2787
+ this.registeredSchedulerMcpDirs.delete(directory);
2788
+ console.log(`[OpenCode] recreated the ${directory} instance so it loads the compaction plugin`);
2789
+ }
2790
+ catch (e) {
2791
+ console.warn(`[OpenCode] compaction plugin activation failed for ${directory}:`, e instanceof Error ? e.message : e);
2792
+ }
2793
+ }
2794
+ /**
2795
+ * @description Reconcile ONE directory: read the live `GET /mcp` status and,
2796
+ * when `telegramBot` is missing or not `connected`, drop the Set gate and
2797
+ * force a fresh POST. Best-effort — a read/registration error is logged,
2798
+ * scheduled for a retry, and swallowed so it never rejects out of
2799
+ * {@link reconcileSchedulerMcpForActiveSessions}.
2800
+ */
2801
+ async reconcileSchedulerMcpForDirectory(directory) {
2802
+ try {
2803
+ const mcpStatus = await this.apiRequest('GET', buildDirectoryScopedPath('/mcp', directory));
2804
+ if (!checkNeedsSchedulerMcpReregister(mcpStatus)) {
2805
+ // A registration that timed out can still have landed on the server.
2806
+ this.clearSchedulerMcpRetry(directory);
2807
+ return;
2808
+ }
2809
+ // The live server contradicts the Set gate (stale entry from a prior
2810
+ // generation): clear it so the re-POST actually fires.
2811
+ this.registeredSchedulerMcpDirs.delete(directory);
2812
+ await this.registerSchedulerMcpForDirectory(directory);
2813
+ }
2814
+ catch (e) {
2815
+ console.warn(`[OpenCode] scheduler MCP reconcile failed for ${directory}:`, e instanceof Error ? e.message : e);
2816
+ this.scheduleSchedulerMcpRetry(directory);
2817
+ }
2818
+ }
2819
+ /**
2820
+ * @description Retry a failed scheduler-MCP registration for `directory` after
2821
+ * the next pause of {@link schedulerMcpRetryDelaysMs}. The retry runs the
2822
+ * reconcile rather than a bare POST: a registration that timed out may have
2823
+ * landed after all, and then nothing needs sending. A retry already pending
2824
+ * covers a second failure, and one that fires after the folder's last session
2825
+ * ended does nothing.
2826
+ */
2827
+ scheduleSchedulerMcpRetry(directory) {
2828
+ const retry = this.schedulerMcpRetries.get(directory) ?? { attempt: 0, timer: null };
2829
+ if (retry.timer)
2830
+ return;
2831
+ const delayMs = getSchedulerMcpRetryDelayMs(retry.attempt);
2832
+ if (delayMs === null) {
2833
+ this.schedulerMcpRetries.delete(directory);
2834
+ console.warn(`[OpenCode] scheduler MCP for ${directory} is still not registered; the next session start or bot boot retries`);
2835
+ return;
2836
+ }
2837
+ retry.attempt += 1;
2838
+ retry.timer = setTimeout(() => {
2839
+ retry.timer = null;
2840
+ const hasActiveSession = Array.from(this.sessions.values()).some((session) => session.isActive && session.workDir === directory);
2841
+ if (!hasActiveSession) {
2842
+ this.schedulerMcpRetries.delete(directory);
2843
+ return;
2844
+ }
2845
+ void this.reconcileSchedulerMcpForDirectory(directory);
2846
+ }, delayMs);
2847
+ retry.timer.unref?.();
2848
+ this.schedulerMcpRetries.set(directory, retry);
2849
+ console.log(`[OpenCode] retrying the scheduler MCP registration for ${directory} in ${Math.round(delayMs / 1000)}s ` +
2850
+ `(${retry.attempt}/${exports.schedulerMcpRetryDelaysMs.length})`);
2851
+ }
2852
+ /** @description Forget `directory`'s pending registration retry: it is registered now. */
2853
+ clearSchedulerMcpRetry(directory) {
2854
+ const retry = this.schedulerMcpRetries.get(directory);
2855
+ if (!retry)
2856
+ return;
2857
+ if (retry.timer)
2858
+ clearTimeout(retry.timer);
2859
+ this.schedulerMcpRetries.delete(directory);
2860
+ }
2861
+ /**
2862
+ * @description Mark a session inactive and tear down the global SSE stream IFF
2863
+ * it was the LAST active session anywhere. The stream is reference-counted by
2864
+ * the TOTAL active-session count (recomputed from `this.sessions` so it can
2865
+ * never drift): any other active session — in any folder — keeps it open.
2866
+ *
2867
+ * Order-independent: callers vary in whether they pre-set `isActive = false`
2868
+ * (`stopSessionInner` does, before calling here), so the decision counts OTHER
2869
+ * active sessions and treats this one as departing. `before = other + 1`,
2870
+ * `after = other` → `close` exactly when no sibling keeps the stream alive.
2871
+ */
2872
+ disconnectSse(key) {
2873
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
2874
+ if (!session)
2875
+ return;
2876
+ session.isActive = false;
2877
+ // Count OTHER active sessions (excluding this departing one) — `before =
2878
+ // other + 1`, `after = other` → `close` exactly when no sibling anywhere
2879
+ // keeps the global stream alive.
2880
+ const otherActive = this.countActiveSessions(key);
2881
+ if ((0, sseStreamLifecycle_1.getSseStreamTransition)(otherActive + 1, otherActive) === 'close') {
2882
+ this.closeGlobalStream();
2883
+ }
2884
+ }
2885
+ /**
2886
+ * @description Total count of currently-active sessions, optionally excluding
2887
+ * one key (the departing session in a teardown decision) — the reference count
2888
+ * the single global stream's lifecycle keys on.
2889
+ */
2890
+ countActiveSessions(excludeKey) {
2891
+ const excludeKeyStr = excludeKey ? (0, sessionKey_1.keyToString)(excludeKey) : null;
2892
+ let count = 0;
2893
+ for (const [keyStr, session] of this.sessions) {
2894
+ if (keyStr === excludeKeyStr)
2895
+ continue;
2896
+ if (session.isActive)
2897
+ count += 1;
2898
+ }
2899
+ return count;
2900
+ }
2901
+ /**
2902
+ * @description Open the single `/global/event` stream if it is not already
2903
+ * open (plan 2026-06-17). Idempotent — a second active session finds the
2904
+ * stream present and no-ops. The reader runs detached; a fatal start error
2905
+ * surfaces to every active session so the user isn't left with a silently-dead
2906
+ * session (audit S10 / #43).
2907
+ */
2908
+ ensureGlobalStream() {
2909
+ if (this.globalStream)
2910
+ return;
2911
+ const stream = {
2912
+ directory: globalStreamLabel,
2913
+ controller: null,
2914
+ stallTimer: null,
2915
+ reconnectTimer: null,
2916
+ isClosed: false,
2917
+ };
2918
+ this.globalStream = stream;
2919
+ // `/global/event` multiplexes every project instance's events (each wrapped
2920
+ // in `payload`, tagged with a top-level `directory`), delivering reliably
2921
+ // regardless of connection age (plan 2026-06-17 S1: the per-directory
2922
+ // endpoint goes silent for an aged sole subscriber on opencode 1.14.41).
2923
+ // Each event is parsed once here and routed by envelope directory + sessionID.
2924
+ const sseUrl = `${this.baseUrl}/global/event`;
2925
+ console.log(`[OpenCode] Connecting global SSE: ${sseUrl}`);
2926
+ (0, diagLog_1.appendDiagLog)(`sse open ${globalStreamLabel}`);
2927
+ this.pollSseStream(stream, sseUrl).catch((e) => {
2928
+ console.error(`[OpenCode] global SSE connection error:`, e);
2929
+ this.emitToAllActiveSessions('error', e instanceof Error ? e : new Error(String(e)));
2930
+ });
2931
+ }
2932
+ /**
2933
+ * @description Register the bot's scheduler MCP server for `directory`'s
2934
+ * OpenCode instance via the runtime `POST /mcp?directory=` endpoint (plan
2935
+ * 2026-06-17 S3). Idempotent on the server (re-POSTing the same `name`
2936
+ * overwrites/no-ops) and gated by `registeredSchedulerMcpDirs` so it runs once
2937
+ * per directory per server generation — `restartServer` clears the Set so a
2938
+ * dir is re-registered after the server's MCP table is wiped. Inert until
2939
+ * injection is configured — the builder returns `null` and this no-ops. A
2940
+ * registration FAILURE is logged, scheduled for a retry, and swallowed:
2941
+ * scheduling tools are an enhancement, the session must still start.
2942
+ */
2943
+ async registerSchedulerMcpForDirectory(directory) {
2944
+ if (this.registeredSchedulerMcpDirs.has(directory))
2945
+ return;
2946
+ const registration = await (0, injection_1.buildOpenCodeSchedulerMcpRegistration)(directory);
2947
+ if (!registration)
2948
+ return;
2949
+ if (this.registeredSchedulerMcpDirs.has(directory))
2950
+ return;
2951
+ try {
2952
+ await this.apiRequest('POST', buildDirectoryScopedPath('/mcp', directory), registration);
2953
+ this.registeredSchedulerMcpDirs.add(directory);
2954
+ this.clearSchedulerMcpRetry(directory);
2955
+ (0, diagLog_1.appendDiagLog)(`scheduler mcp registered dir=${directory}`);
2956
+ }
2957
+ catch (e) {
2958
+ console.warn(`[OpenCode] scheduler MCP registration failed for ${directory}:`, e instanceof Error ? e.message : e);
2959
+ this.scheduleSchedulerMcpRetry(directory);
2960
+ }
2961
+ }
2962
+ /**
2963
+ * @description Tear down the global SSE stream: latch it closed, abort the
2964
+ * in-flight reader (unblocks `reader.read()` immediately — audit S7 / #12),
2965
+ * cancel its stall + reconnect timers, and drop the reference.
2966
+ */
2967
+ closeGlobalStream() {
2968
+ const stream = this.globalStream;
2969
+ if (!stream)
2970
+ return;
2971
+ stream.isClosed = true;
2972
+ if (stream.controller) {
2973
+ stream.controller.abort();
2974
+ stream.controller = null;
2975
+ }
2976
+ this.clearStreamStallTimer(stream);
2977
+ if (stream.reconnectTimer) {
2978
+ clearTimeout(stream.reconnectTimer);
2979
+ stream.reconnectTimer = null;
2980
+ }
2981
+ this.globalStream = null;
2982
+ (0, diagLog_1.appendDiagLog)(`sse close ${globalStreamLabel}`);
2983
+ }
2984
+ /** Emit an event to every active session. */
2985
+ emitToAllActiveSessions(eventName, error) {
2986
+ for (const session of this.sessions.values()) {
2987
+ if (session.isActive) {
2988
+ this.emit(eventName, session.key, error);
2989
+ }
2990
+ }
2991
+ }
2992
+ /**
2993
+ * @description Resolve model for the session. Priority:
2994
+ * 1. Thread's saved preference (from previous /model selection)
2995
+ * 2. OpenCode server's defaultModel (config.model -> model.json recent -> first provider)
2996
+ * 3. "not set"
2997
+ *
2998
+ * `emitOutput` controls whether the resolved `Model: <label>` line is sent to
2999
+ * the topic. A fresh `startSession` emits it (the user just opened the
3000
+ * session); a `resumeSession` after a bot restart re-resolves the SAME model
3001
+ * silently — the label is already in the topic from the previous run, so
3002
+ * re-emitting on every hot-restart is noise (B17). Resolution must still run
3003
+ * to repopulate `modelOverride`/`currentModelLabel` so `/effort` can read them.
3004
+ */
3005
+ async fetchModelInfo(key, emitOutput = true) {
3006
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
3007
+ if (!session?.isActive || session.isModelInfoShown)
3008
+ return;
3009
+ // 1. Check this thread's saved model preference
3010
+ if (this.restoreSavedModel(key, session, emitOutput))
3011
+ return;
3012
+ // 2. Ask OpenCode server for default model
3013
+ try {
3014
+ const config = await this.apiRequest('GET', '/config');
3015
+ console.log(`[OpenCode] GET /config: model=${config?.model || 'unset'}, defaultModel=${config?.defaultModel ? `${config.defaultModel.providerID}/${config.defaultModel.modelID}` : 'unset'}`);
3016
+ if (config?.defaultModel?.providerID && config?.defaultModel?.modelID) {
3017
+ const label = `${config.defaultModel.providerID}/${config.defaultModel.modelID}`;
3018
+ session.currentModelLabel = label;
3019
+ session.modelOverride = {
3020
+ providerID: config.defaultModel.providerID,
3021
+ modelID: config.defaultModel.modelID,
3022
+ };
3023
+ session.isModelInfoShown = true;
3024
+ console.log(`[OpenCode] Default model: ${label}`);
3025
+ if (emitOutput)
3026
+ this.emit('output', key, `Model: ${label}`);
3027
+ return;
3028
+ }
3029
+ if (config?.model) {
3030
+ const slashIdx = config.model.indexOf('/');
3031
+ if (slashIdx > 0) {
3032
+ session.modelOverride = {
3033
+ providerID: config.model.slice(0, slashIdx),
3034
+ modelID: config.model.slice(slashIdx + 1),
3035
+ };
3036
+ }
3037
+ session.currentModelLabel = config.model;
3038
+ session.isModelInfoShown = true;
3039
+ console.log(`[OpenCode] Default model (config): ${config.model}`);
3040
+ if (emitOutput)
3041
+ this.emit('output', key, `Model: ${config.model}`);
3042
+ return;
3043
+ }
3044
+ }
3045
+ catch (e) {
3046
+ // Transient failure (server booting / sick). Do NOT claim "not set":
3047
+ // leave isModelInfoShown false and currentModelLabel untouched so the
3048
+ // next assistant message (handleMessageUpdate) corrects it with the
3049
+ // real model label.
3050
+ console.log(`[OpenCode] fetchModelInfo failed:`, e instanceof Error ? e.message : e);
3051
+ return;
3052
+ }
3053
+ // 3. Server answered but reported no default model — genuinely not set.
3054
+ console.log(`[OpenCode] No default model resolved`);
3055
+ session.currentModelLabel = 'not set';
3056
+ if (emitOutput)
3057
+ this.emit('output', key, `Model: not set (use /model to select)`);
3058
+ }
3059
+ /**
3060
+ * @description Fetch-based SSE reader for the single `/global/event` stream
3061
+ * (plan 2026-06-17). OpenCode sends every event as a `data:` line with a JSON
3062
+ * envelope `{ directory, payload: { type, properties } }`; this one stream
3063
+ * multiplexes every project instance, so each line is parsed once here and
3064
+ * routed by the envelope `directory` + `sessionID` to its owning session.
3065
+ *
3066
+ * On connection failure: checks if the server is alive, attempts a restart if
3067
+ * dead, and retries forever with capped exponential backoff until reconnect
3068
+ * succeeds — as long as the stream is still wanted (not closed).
3069
+ */
3070
+ async pollSseStream(stream, sseUrl, reconnectAttempt = 0, reconnectStartTs = Date.now()) {
3071
+ if (stream.isClosed)
3072
+ return;
3073
+ // The "directory" is the fixed `globalStreamLabel` — a log marker only; the
3074
+ // global stream has no per-folder scope (events carry their own directory).
3075
+ const streamLabel = stream.directory;
3076
+ const headers = {
3077
+ 'Accept': 'text/event-stream',
3078
+ };
3079
+ if (this.authHeader) {
3080
+ headers['Authorization'] = this.authHeader;
3081
+ }
3082
+ // Audit S7 / #12: SSE was previously read with no abort path —
3083
+ // `await reader.read()` blocks until bytes arrive, so a teardown during a
3084
+ // silent server couldn't actually free the connection. The stream-scoped
3085
+ // controller is stored so `closeGlobalStream` can abort the in-flight
3086
+ // `fetch` and `reader.read` immediately.
3087
+ const controller = new AbortController();
3088
+ stream.controller = controller;
3089
+ let sawData = false;
3090
+ try {
3091
+ const response = await fetch(sseUrl, { headers, signal: controller.signal });
3092
+ if (!response.ok || !response.body) {
3093
+ console.error(`[OpenCode] SSE connection failed for ${streamLabel}: ${response.status}`);
3094
+ await response.body?.cancel().catch(() => { });
3095
+ await this.handleSseReconnect(stream, sseUrl, reconnectAttempt, reconnectStartTs, `HTTP ${response.status}`);
3096
+ return;
3097
+ }
3098
+ console.log(`[OpenCode] SSE connected for ${streamLabel}`);
3099
+ (0, diagLog_1.appendDiagLog)(`sse connected dir=${streamLabel} attempt=${reconnectAttempt}`);
3100
+ const reader = response.body.getReader();
3101
+ const decoder = new TextDecoder();
3102
+ let buffer = '';
3103
+ // Arm the stall watchdog before the first read and re-arm on every chunk;
3104
+ // a silently dead stream (no bytes, not even a heartbeat) would otherwise
3105
+ // park `reader.read()` forever with no reconnect.
3106
+ this.armSseStallWatchdog(stream, controller);
3107
+ while (!stream.isClosed) {
3108
+ const { done, value } = await reader.read();
3109
+ if (done)
3110
+ break;
3111
+ this.armSseStallWatchdog(stream, controller);
3112
+ // Audit S7 / #12: a flapping server used to reset `reconnectAttempt`
3113
+ // on a *successful TCP connect*, so the 5-attempts ceiling never
3114
+ // bounded anything. Reset only once we observe actual application
3115
+ // data, and keep a wall-clock budget so even data-producing flaps
3116
+ // get capped.
3117
+ if (!sawData) {
3118
+ sawData = true;
3119
+ reconnectAttempt = 0;
3120
+ reconnectStartTs = Date.now();
3121
+ (0, diagLog_1.appendDiagLog)(`sse first-data dir=${streamLabel}`);
3122
+ }
3123
+ buffer += decoder.decode(value, { stream: true });
3124
+ const lines = buffer.split('\n');
3125
+ buffer = lines.pop() || '';
3126
+ for (const line of lines) {
3127
+ if (!line.startsWith('data: '))
3128
+ continue;
3129
+ const dataStr = line.slice(6);
3130
+ this.routeSseData(dataStr);
3131
+ }
3132
+ }
3133
+ // Leaving the read loop: disarm before any reconnect await so the
3134
+ // watchdog can't fire against this (now-finished) controller.
3135
+ this.clearStreamStallTimer(stream);
3136
+ reader.cancel().catch(() => { });
3137
+ // If the stream is still wanted but the server closed it, reconnect.
3138
+ if (!stream.isClosed) {
3139
+ console.log(`[OpenCode] SSE stream for ${streamLabel} ended while wanted, reconnecting...`);
3140
+ await this.handleSseReconnect(stream, sseUrl, reconnectAttempt, reconnectStartTs, 'stream ended');
3141
+ }
3142
+ }
3143
+ catch (e) {
3144
+ this.clearStreamStallTimer(stream);
3145
+ // An aborted controller means either `closeGlobalStream` (stream no
3146
+ // longer wanted → just exit) or the stall watchdog firing on a silently
3147
+ // dead stream (stream still wanted → reconnect).
3148
+ if (controller.signal.aborted) {
3149
+ if (!stream.isClosed) {
3150
+ await this.handleSseReconnect(stream, sseUrl, reconnectAttempt, reconnectStartTs, 'stall');
3151
+ }
3152
+ return;
3153
+ }
3154
+ if (!stream.isClosed) {
3155
+ const errorMessage = e instanceof Error ? e.message : String(e);
3156
+ console.error(`[OpenCode] SSE error for ${streamLabel}:`, errorMessage);
3157
+ await this.handleSseReconnect(stream, sseUrl, reconnectAttempt, reconnectStartTs, errorMessage);
3158
+ }
3159
+ }
3160
+ finally {
3161
+ this.clearStreamStallTimer(stream);
3162
+ if (stream.controller === controller)
3163
+ stream.controller = null;
3164
+ }
3165
+ }
3166
+ /**
3167
+ * @description (Re)arm a stream's stall watchdog. OpenCode emits a
3168
+ * `server.heartbeat` every ~10 s, so a live stream always delivers bytes
3169
+ * within `sseStallTimeoutMs`; if none arrive the socket is silently dead
3170
+ * (open TCP, no FIN/RST) and `reader.read()` would park forever. Aborting the
3171
+ * controller unblocks the reader so `pollSseStream`'s catch path reconnects.
3172
+ * Re-armed on every chunk, so heartbeats keep a healthy stream alive.
3173
+ */
3174
+ armSseStallWatchdog(stream, controller) {
3175
+ this.clearStreamStallTimer(stream);
3176
+ stream.stallTimer = setTimeout(() => {
3177
+ stream.stallTimer = null;
3178
+ (0, diagLog_1.appendDiagLog)(`sse stall dir=${stream.directory} idle>${sseStallTimeoutMs}ms`);
3179
+ controller.abort();
3180
+ }, sseStallTimeoutMs);
3181
+ }
3182
+ /** Cancel a stream's stall watchdog if armed. Idempotent. */
3183
+ clearStreamStallTimer(stream) {
3184
+ if (stream.stallTimer) {
3185
+ clearTimeout(stream.stallTimer);
3186
+ stream.stallTimer = null;
3187
+ }
3188
+ }
3189
+ /**
3190
+ * @description Handle reconnection for the single global stream with server
3191
+ * health check, auto-restart, and exponential backoff. If the server is dead,
3192
+ * attempts a restart before reconnecting. Never gives up while the stream is
3193
+ * still wanted — reconnects until success — with backoff capped at
3194
+ * `maxSseReconnectDelayMs`. The only exits are an unrestartable server
3195
+ * (handled by `restartServer`) or a closed stream.
3196
+ */
3197
+ async handleSseReconnect(stream, sseUrl, attempt, reconnectStartTs, reason) {
3198
+ if (stream.isClosed)
3199
+ return;
3200
+ const streamLabel = stream.directory;
3201
+ const elapsed = Date.now() - reconnectStartTs;
3202
+ // Check if the server process is still alive
3203
+ const isServerAlive = await (0, installManager_1.checkIsOpenCodeServerRunning)();
3204
+ if (!isServerAlive) {
3205
+ console.log(`[OpenCode] Server is not responding, attempting restart before SSE reconnect...`);
3206
+ const restarted = await this.restartServer();
3207
+ if (!restarted) {
3208
+ // restartServer already notified threads and cleaned up sessions
3209
+ return;
3210
+ }
3211
+ // restartServer re-opens the global stream via the resume path; if this
3212
+ // stream was torn down in the process, stop here so we don't double-open it.
3213
+ if (stream.isClosed)
3214
+ return;
3215
+ // Server restarted — reset the attempt counter so backoff starts fresh.
3216
+ attempt = 0;
3217
+ }
3218
+ // Never give up while the stream is wanted: reconnect forever until
3219
+ // success. Cap the exponential backoff so steady-state retries settle at
3220
+ // `maxSseReconnectDelayMs` instead of growing without bound.
3221
+ const delay = Math.min(sseReconnectBaseDelayMs * Math.pow(2, Math.min(attempt, maxSseReconnectBackoffExponent)), maxSseReconnectDelayMs);
3222
+ console.log(`[OpenCode] SSE reconnecting ${streamLabel} in ${delay}ms (attempt ${attempt + 1}, reason: ${reason}, elapsed: ${Math.round(elapsed / 1000)}s)`);
3223
+ (0, diagLog_1.appendDiagLog)(`sse reconnect dir=${streamLabel} attempt=${attempt + 1} reason=${reason} delay=${delay}ms`);
3224
+ // Store the timer handle on the stream so `closeGlobalStream` can cancel
3225
+ // it; otherwise a `setTimeout` fired after the stream is gone re-enters
3226
+ // `pollSseStream` for a dead stream and keeps the event loop alive (audit
3227
+ // S8 / #14).
3228
+ await new Promise(resolve => {
3229
+ stream.reconnectTimer = setTimeout(() => {
3230
+ stream.reconnectTimer = null;
3231
+ resolve();
3232
+ }, delay);
3233
+ });
3234
+ if (!stream.isClosed) {
3235
+ this.pollSseStream(stream, sseUrl, attempt + 1, reconnectStartTs).catch(() => { });
3236
+ }
3237
+ }
3238
+ /**
3239
+ * @description Route a single raw SSE `data:` line from the global stream
3240
+ * (plan 2026-06-17). The JSON is parsed ONCE here, the owning session is
3241
+ * resolved from its sessionID/lineage (with an envelope-directory fallback),
3242
+ * and the event is dispatched to that session. The owning project instance
3243
+ * comes from the event ENVELOPE'S `directory` field — `/global/event` tags
3244
+ * every session event with it — so instance-local replies
3245
+ * (questions/permissions) target the right instance.
3246
+ *
3247
+ * Also the entry the SSE unit tests drive (a session injected into the map +
3248
+ * synthesized payload-wrapped envelope), so the real parse→route→dispatch
3249
+ * path is exercised end to end.
3250
+ */
3251
+ routeSseData(dataStr) {
3252
+ let parsed;
3253
+ try {
3254
+ parsed = JSON.parse(dataStr);
3255
+ }
3256
+ catch {
3257
+ return;
3258
+ }
3259
+ const event = normaliseOpenCodeSseEvent(parsed);
3260
+ if (!event)
3261
+ return;
3262
+ const ownerKeyStr = this.resolveSseEventOwner(event);
3263
+ if (ownerKeyStr === undefined)
3264
+ return;
3265
+ if (ownerKeyStr === null) {
3266
+ // Session-less event (server.connected / heartbeat) — no owner to look up.
3267
+ this.dispatchSessionLessEvent(event);
3268
+ return;
3269
+ }
3270
+ const ownerSession = this.sessions.get(ownerKeyStr);
3271
+ if (!ownerSession?.isActive)
3272
+ return;
3273
+ // The owning project instance is the envelope's `directory` (session-less
3274
+ // events have none and need none) — used for instance-scoped replies.
3275
+ this.dispatchSseEvent(ownerSession.key, event, event.directory);
3276
+ }
3277
+ /**
3278
+ * @description Resolve which thread (if any) should process `event`, applying
3279
+ * the single-owner invariant (B20) and recording subagent lineage. Owner
3280
+ * resolution is by sessionID (direct id, else lineage ancestor) with a
3281
+ * DIRECTORY fallback off the event ENVELOPE'S `directory` when both miss
3282
+ * (plan 2026-06-17). Returns:
3283
+ * - the owning thread's serialised key for a per-session event;
3284
+ * - `null` for a session-less event (`server.connected`/`heartbeat`) that
3285
+ * every reader handles directly;
3286
+ * - `undefined` to signal "do not dispatch" (missing sessionID, or no bound
3287
+ * thread owns the session — a genuine drop, loud-logged for every critical
3288
+ * type incl. question/permission, so a routable event is never a no-op).
3289
+ * Events for directories the bot does not own (the user's by-hand opencode
3290
+ * in other folders, now visible on the global stream) drop here cheaply.
3291
+ */
3292
+ resolveSseEventOwner(event) {
3293
+ const eventType = event.type;
3294
+ // OpenCode runs subagents in CHILD sessions; their events carry the child
3295
+ // sessionID, not the bound parent's. The child→parent link must be known
3296
+ // BEFORE the child's first routed event or that event drops "no owner".
3297
+ // `session.updated` is no longer the ONLY source — record from ANY event
3298
+ // whose properties expose `parentID` (S2 lineage durability), capturing it
3299
+ // BEFORE the session-id gate below so descendant events route to the owner.
3300
+ this.trackSessionLineage(event.properties);
3301
+ // Session-less events are server-wide by design; they have no owner to
3302
+ // resolve and are processed directly by the dispatch switch.
3303
+ if (eventType === 'server.connected' || eventType === 'server.heartbeat') {
3304
+ return null;
3305
+ }
3306
+ const eventSessionId = this.getSessionIdFromEvent(event);
3307
+ if (!eventSessionId) {
3308
+ console.warn(`[OpenCode] dropping ${eventType} without sessionID`);
3309
+ if (exports.criticalSseEventTypes.has(eventType)) {
3310
+ this.logSseDropOncePerWindow(eventType, '<no-session-id>');
3311
+ }
3312
+ return undefined;
3313
+ }
3314
+ // Single-owner delivery (B20): resolve the ONE thread that owns
3315
+ // `eventSessionId` (direct id match, else nearest lineage ancestor). A
3316
+ // false lineage link or a duplicated session id must never emit the same
3317
+ // answer to two topics. On success, refresh the lineage links walked so an
3318
+ // actively-routing child is never the eviction victim (S2 durability).
3319
+ const ownerKeyStr = this.resolveEventOwnerKey(eventSessionId, { refreshLineageOnUse: true });
3320
+ if (ownerKeyStr !== null)
3321
+ return ownerKeyStr;
3322
+ // Id/lineage resolution failed. Before dropping, fall back to the event
3323
+ // ENVELOPE'S DIRECTORY (plan 2026-06-17): `/global/event` tags every session
3324
+ // event with its owning folder, so the event provably belongs to a thread
3325
+ // bound to THAT folder even when the per-session lineage map briefly
3326
+ // disagrees (link evicted / not yet recorded). The fallback also drops an
3327
+ // event for a directory the bot does not own (the user's by-hand opencode in
3328
+ // another folder): no active bound session there → `null` → cheap drop. SYNC
3329
+ // only — no HTTP on the per-event hot path; the decision uses in-memory state.
3330
+ const eventDirectory = event.directory;
3331
+ const fallbackOwnerKeyStr = eventDirectory !== undefined
3332
+ ? this.resolveOwnerByDirectoryFallback(eventSessionId, eventDirectory)
3333
+ : null;
3334
+ if (fallbackOwnerKeyStr !== null) {
3335
+ (0, diagLog_1.appendDiagLog)(`sse dir-fallback ${eventType} es=${eventSessionId} dir=${eventDirectory} -> ${fallbackOwnerKeyStr}`);
3336
+ return fallbackOwnerKeyStr;
3337
+ }
3338
+ // A genuine loss is "no thread owns this event at all". Throttle the log
3339
+ // per (eventType, eventSessionId) — an orphaned session's delta firehose
3340
+ // would otherwise flood the diag file (B19). question/permission are now
3341
+ // critical, so a vanished question is LOUD, never silent (S1).
3342
+ if (exports.criticalSseEventTypes.has(eventType)) {
3343
+ this.logSseDropOncePerWindow(eventType, eventSessionId);
3344
+ }
3345
+ return undefined;
3346
+ }
3347
+ /**
3348
+ * @description Handle a session-less event (`server.connected` /
3349
+ * `server.heartbeat`) — server-wide by design, with no owning topic. Kept
3350
+ * separate from {@link dispatchSseEvent} so the latter always has a real
3351
+ * owner key.
3352
+ */
3353
+ dispatchSessionLessEvent(event) {
3354
+ if (event.type === 'server.connected') {
3355
+ console.log(`[OpenCode] SSE: server.connected`);
3356
+ }
3357
+ // server.heartbeat is intentionally silent — it only re-arms the watchdog.
3358
+ }
3359
+ /**
3360
+ * @description Dispatch a normalised, OWNED SSE event to its owning session's
3361
+ * handlers. `key` is the resolved single owner (B20); `directory` is the
3362
+ * owning project instance (the event envelope's `directory`), used for
3363
+ * instance-scoped question / permission replies.
3364
+ */
3365
+ dispatchSseEvent(key, event, directory) {
3366
+ const eventType = event.type;
3367
+ const eventSessionId = this.getSessionIdFromEvent(event);
3368
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
3369
+ // A routed event whose session id is NOT the owner's own id reached us via
3370
+ // lineage descent → it belongs to a SUB-AGENT (child session). Computed
3371
+ // ONCE here and threaded into the part handlers — they must never
3372
+ // re-derive it (S4).
3373
+ const isSubagentEvent = Boolean(session?.isActive && eventSessionId && eventSessionId !== session.sessionId);
3374
+ if (session && isSubagentEvent) {
3375
+ (0, diagLog_1.appendDiagLog)(`sse route-descendant ${eventType} es=${eventSessionId} -> ${session.sessionId}`);
3376
+ }
3377
+ switch (eventType) {
3378
+ case 'message.part.updated':
3379
+ case 'message.part.delta':
3380
+ this.handlePartUpdate(key, event.properties, isSubagentEvent);
3381
+ break;
3382
+ case 'message.updated':
3383
+ this.handleMessageUpdate(key, event.properties);
3384
+ break;
3385
+ case 'session.idle':
3386
+ this.handleSessionIdle(key, event.properties);
3387
+ break;
3388
+ case 'session.status':
3389
+ this.handleSessionStatus(key, eventSessionId, event.properties);
3390
+ break;
3391
+ case 'session.next.compaction.started':
3392
+ this.setCompacting(key, true);
3393
+ break;
3394
+ case 'session.next.compaction.ended':
3395
+ case 'session.compacted':
3396
+ this.setCompacting(key, false);
3397
+ break;
3398
+ case 'session.error':
3399
+ this.handleSessionError(key, eventSessionId, event.properties);
3400
+ break;
3401
+ case 'permission.asked':
3402
+ this.handlePermissionAsked(key, event.properties, directory);
3403
+ break;
3404
+ case 'question.asked':
3405
+ this.handleQuestionAsked(key, event.properties, directory);
3406
+ break;
3407
+ default:
3408
+ // Log unhandled event types for debugging, skipping the ones we
3409
+ // intentionally ignore (heartbeat + global-only bookkeeping) so the
3410
+ // multiplexed global stream doesn't spam stdout (plan 2026-06-17 S2).
3411
+ if (eventType !== 'server.heartbeat' && !ignoredSseEventTypes.has(eventType)) {
3412
+ console.log(`[OpenCode] SSE event: ${eventType}`);
3413
+ }
3414
+ break;
3415
+ }
3416
+ }
3417
+ getSessionIdFromEvent(event) {
3418
+ const props = event.properties;
3419
+ // Different events carry sessionID in different places
3420
+ if (typeof props.sessionID === 'string')
3421
+ return props.sessionID;
3422
+ if (props.part && typeof props.part.sessionID === 'string') {
3423
+ return props.part.sessionID;
3424
+ }
3425
+ if (props.info && typeof props.info.sessionID === 'string') {
3426
+ return props.info.sessionID;
3427
+ }
3428
+ return null;
3429
+ }
3430
+ /**
3431
+ * @description Record a child→parent session link from ANY event that exposes
3432
+ * `parentID`, so subagent (child-session) events route to the topic bound to
3433
+ * the parent. Reliably populated by `session.updated` (its `info` carries the
3434
+ * child id + `parentID`); also reads a top-level `properties.parentID`
3435
+ * (paired with the event's session id) when a build emits it on other events,
3436
+ * so a child's link is known BEFORE its first routed event instead of dropping
3437
+ * "no owner" until the next `session.updated` beat (S2 durability). Root
3438
+ * sessions (no `parentID`) and non-session ids are ignored by
3439
+ * {@link updateSessionLineage}; the map is bounded by
3440
+ * `maxTrackedSessionLineageEntries`.
3441
+ */
3442
+ trackSessionLineage(properties) {
3443
+ const info = properties.info;
3444
+ // `session.updated` shape: info.id is the CHILD, info.parentID its parent.
3445
+ this.recordSessionLineageLink(info?.id, info?.parentID);
3446
+ // Other events: a top-level parentID belongs to the event's OWN session id.
3447
+ const topLevelParentId = properties.parentID;
3448
+ if (typeof topLevelParentId === 'string') {
3449
+ this.recordSessionLineageLink(this.getSessionIdFromEvent({ type: '', properties }), topLevelParentId);
3450
+ }
3451
+ }
3452
+ /** @description Store one child→parent link and diag-log it when it is new. */
3453
+ recordSessionLineageLink(childSessionId, parentSessionId) {
3454
+ const recorded = (0, openCodeSessionRouting_1.updateSessionLineage)(this.sessionLineage, childSessionId ?? undefined, parentSessionId, maxTrackedSessionLineageEntries);
3455
+ if (recorded) {
3456
+ (0, diagLog_1.appendDiagLog)(`sse lineage child=${childSessionId} parent=${parentSessionId}`);
3457
+ }
3458
+ }
3459
+ /**
3460
+ * @description Tear down any OTHER active thread already bound to
3461
+ * `sessionId` before `keyStr` adopts it (B20 prevention). One server-side
3462
+ * OpenCode session belongs to exactly one thread; if two threads held the
3463
+ * same id (e.g. resuming a session already live in another topic), the
3464
+ * server's multiplexed events would match BOTH and the same answer would
3465
+ * land in two topics. Detaching the stale owner keeps the single-owner
3466
+ * invariant true at the source, not just in dispatch.
3467
+ */
3468
+ detachDuplicateSessionOwners(keyStr, sessionId) {
3469
+ for (const [otherKeyStr, otherSession] of this.sessions) {
3470
+ if (otherKeyStr === keyStr)
3471
+ continue;
3472
+ if (!otherSession.isActive || otherSession.sessionId !== sessionId)
3473
+ continue;
3474
+ console.warn(`[OpenCode] session ${sessionId} already owned by ${otherKeyStr}; detaching it before ${keyStr} adopts it`);
3475
+ (0, diagLog_1.appendDiagLog)(`sse dup-owner-detach session=${sessionId} from=${otherKeyStr} to=${keyStr}`);
3476
+ // SOFT detach: tear down the stale thread's local tracking (SSE, timers,
3477
+ // map entry) and announce `stopped`, but do NOT abort the server session
3478
+ // — the new thread is adopting that exact live session, so aborting it
3479
+ // would kill the generation the new owner wants to keep streaming.
3480
+ this.softDetachSession(otherSession.key);
3481
+ }
3482
+ }
3483
+ /**
3484
+ * @description Tear down a thread's local session tracking WITHOUT aborting
3485
+ * the server-side session. Used when another thread is adopting the same
3486
+ * server session (B20 prevention) — a normal `stopSessionInner` would abort
3487
+ * the shared generation. Mirrors `stopSessionInner` minus the abort POST.
3488
+ */
3489
+ softDetachSession(key) {
3490
+ const k = (0, sessionKey_1.keyToString)(key);
3491
+ const session = this.sessions.get(k);
3492
+ if (!session)
3493
+ return;
3494
+ session.isActive = false;
3495
+ if (session.outputTimer) {
3496
+ clearTimeout(session.outputTimer);
3497
+ session.outputTimer = null;
3498
+ }
3499
+ if (session.childOutputTimer) {
3500
+ clearTimeout(session.childOutputTimer);
3501
+ session.childOutputTimer = null;
3502
+ }
3503
+ if (session.statusDebounceTimer) {
3504
+ clearTimeout(session.statusDebounceTimer);
3505
+ session.statusDebounceTimer = null;
3506
+ }
3507
+ // The adopting thread owns this server session now; a bound left running
3508
+ // here would report a missing turn on behalf of the detached owner.
3509
+ this.clearProviderRetryReplacementStartTimer(session);
3510
+ // Tear down the directory's SSE stream if this was its last active session.
3511
+ this.disconnectSse(key);
3512
+ this.sessions.delete(k);
3513
+ this.emit('stopped', key);
3514
+ }
3515
+ /**
3516
+ * @description Resolve the SINGLE active thread that owns an SSE event for
3517
+ * `eventSessionId` (single-owner delivery, B20). Direct session-id match wins
3518
+ * over lineage descent; ties resolve deterministically. Returns the owning
3519
+ * thread's serialised key, or `null` if no active thread owns it.
3520
+ *
3521
+ * `refreshLineageOnUse` marks the lineage links walked to reach the owner as
3522
+ * most-recently-used, so an actively-routing child can never be the eviction
3523
+ * victim of the bounded lineage map (S2 durability) — only set on the live
3524
+ * routing path, never when merely probing.
3525
+ */
3526
+ resolveEventOwnerKey(eventSessionId, options) {
3527
+ const boundSessions = this.getActiveBoundSessions();
3528
+ const ownerKeyStr = (0, openCodeSessionRouting_1.getEventOwnerKey)(eventSessionId, boundSessions, this.sessionLineage);
3529
+ if (ownerKeyStr !== null && options?.refreshLineageOnUse) {
3530
+ const ownerSessionId = boundSessions.find((bound) => bound.keyStr === ownerKeyStr)?.sessionId;
3531
+ // Only a descendant (event id ≠ owner id) walked the lineage chain; a
3532
+ // direct id match used no links to refresh.
3533
+ if (ownerSessionId !== undefined && ownerSessionId !== eventSessionId) {
3534
+ (0, openCodeSessionRouting_1.touchLineageOnUse)(this.sessionLineage, eventSessionId, ownerSessionId);
3535
+ }
3536
+ }
3537
+ return ownerKeyStr;
3538
+ }
3539
+ /** @description Every currently-active session as a routing `BoundSessionRef`. */
3540
+ getActiveBoundSessions() {
3541
+ const boundSessions = [];
3542
+ for (const [keyStr, session] of this.sessions) {
3543
+ if (session.isActive)
3544
+ boundSessions.push({ keyStr, sessionId: session.sessionId });
3545
+ }
3546
+ return boundSessions;
3547
+ }
3548
+ /**
3549
+ * @description Directory fallback (plan 2026-06-17): resolve an owner from the
3550
+ * event envelope's `directory` when id/lineage resolution already failed.
3551
+ * `/global/event` tags every session event with its owning folder, so an event
3552
+ * provably belongs to a thread bound to that folder — and an event for a
3553
+ * directory with no active bound session (the user's by-hand opencode
3554
+ * elsewhere) resolves to `null` and is dropped. SYNC — uses only in-memory
3555
+ * session state, NEVER an HTTP call on the per-event hot path. Delegates the
3556
+ * decision to the pure {@link resolveOwnerByDirectoryFallbackPure}; the wiring
3557
+ * here is just selecting the directory's active sessions.
3558
+ */
3559
+ resolveOwnerByDirectoryFallback(eventSessionId, directory) {
3560
+ const directoryActiveSessions = [];
3561
+ for (const [keyStr, session] of this.sessions) {
3562
+ if (session.isActive && session.workDir === directory) {
3563
+ directoryActiveSessions.push({ keyStr, sessionId: session.sessionId });
3564
+ }
3565
+ }
3566
+ return (0, openCodeSessionRouting_1.resolveOwnerByDirectoryFallback)(eventSessionId, directoryActiveSessions, this.sessionLineage);
3567
+ }
3568
+ /**
3569
+ * @description Diag-log one "sse drop" line for an event no thread owns,
3570
+ * throttled to at most once per (eventType, eventSessionId) per
3571
+ * `sseDropLogThrottleMs`. Without throttling an orphaned session's delta
3572
+ * firehose floods the diag log — once per delta per bound thread (B19).
3573
+ */
3574
+ logSseDropOncePerWindow(eventType, eventSessionId) {
3575
+ const shouldLog = (0, openCodeSessionRouting_1.checkShouldLogDrop)(this.sseDropLogThrottle, eventType, eventSessionId, Date.now(), sseDropLogThrottleMs, maxSseDropThrottleEntries);
3576
+ if (shouldLog) {
3577
+ (0, diagLog_1.appendDiagLog)(`sse drop ${eventType} es=${eventSessionId} (no owner)`);
3578
+ }
3579
+ }
3580
+ /**
3581
+ * @description Handle streaming part updates from assistant response.
3582
+ * Two event formats:
3583
+ * message.part.updated: { part: OpenCodePart, delta?: string }
3584
+ * message.part.delta: { sessionID, messageID, partID, field, delta }
3585
+ *
3586
+ * Text parts are accumulated and emitted as 'output' (permanent messages).
3587
+ * Tool, reasoning, step-* parts are emitted as 'status' (transient messages).
3588
+ *
3589
+ * `isSubagent` (resolved ONCE in `dispatchSseEvent`) marks parts streamed by
3590
+ * a CHILD session (sub-agent); they are routed per the
3591
+ * {@link getSubagentPartAction} matrix instead of the parent's handlers, so
3592
+ * a child transcript can never merge unmarked into the parent's reply (S4).
3593
+ */
3594
+ handlePartUpdate(key, properties, isSubagent) {
3595
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
3596
+ if (!session?.isActive)
3597
+ return;
3598
+ const part = properties.part;
3599
+ const delta = properties.delta;
3600
+ const field = properties.field;
3601
+ const partId = properties.partID || part?.id;
3602
+ const messageId = properties.messageID || part?.messageID;
3603
+ // Track part type from message.part.updated events (full part object)
3604
+ if (part?.type && partId) {
3605
+ session.partTypes.set(partId, part.type);
3606
+ }
3607
+ // Resolve the part type: from the part object or from our tracking map
3608
+ const partType = part?.type || (partId ? session.partTypes.get(partId) : undefined) || 'text';
3609
+ // Reasoning end signal #1: the first NON-reasoning part after reasoning was
3610
+ // streaming (the answer or a tool call begins). The most responsive of the
3611
+ // three end signals; `endReasoning` is idempotent so the later
3612
+ // `message.updated` finish / `session.idle` signals are harmless no-ops.
3613
+ // Child parts are excluded: a sub-agent streaming must not collapse the
3614
+ // PARENT's thinking indicator (its own first non-reasoning part — e.g. the
3615
+ // `task` tool call — already ended it).
3616
+ if (partType !== 'reasoning' && !isSubagent)
3617
+ this.endReasoning(key, session);
3618
+ switch (partType) {
3619
+ case 'text':
3620
+ if (isSubagent) {
3621
+ this.handleSubagentTextPart(key, session, delta, field);
3622
+ break;
3623
+ }
3624
+ this.handleTextDelta(key, session, delta, field, messageId);
3625
+ break;
3626
+ case 'tool':
3627
+ this.handleToolPart(key, session, part, isSubagent);
3628
+ break;
3629
+ case 'reasoning':
3630
+ // Child chain-of-thought is never rendered in ANY mode (locked
3631
+ // decision — `getSubagentPartAction` returns 'ignore' for reasoning):
3632
+ // it must not feed the parent's thinking channel.
3633
+ if (isSubagent)
3634
+ break;
3635
+ this.handleReasoningPart(key, session, part, delta, field);
3636
+ break;
3637
+ case 'step-start':
3638
+ case 'step-finish':
3639
+ // Silently skip step markers — tool parts provide better status info
3640
+ break;
3641
+ default:
3642
+ // Log unknown part types for future debugging
3643
+ if (part?.type) {
3644
+ console.log(`[OpenCode] Unhandled part type: ${part.type}`);
3645
+ }
3646
+ break;
3647
+ }
3648
+ }
3649
+ /**
3650
+ * @description Handle text delta — accumulate and emit as 'output'. Text of the
3651
+ * compaction summary message marks the accumulated response as that summary.
3652
+ */
3653
+ handleTextDelta(key, session, delta, field, messageId) {
3654
+ // For message.part.delta: only process text field deltas
3655
+ if (field && field !== 'text')
3656
+ return;
3657
+ const text = delta || '';
3658
+ if (!text)
3659
+ return;
3660
+ if (messageId !== undefined && messageId === session.compactionSummaryMessageId)
3661
+ session.isResponseCompactionSummary = true;
3662
+ session.currentResponseText += text;
3663
+ // Debounce: batch rapid SSE deltas before emitting
3664
+ if (session.outputTimer) {
3665
+ clearTimeout(session.outputTimer);
3666
+ }
3667
+ session.outputTimer = setTimeout(() => {
3668
+ session.outputTimer = null;
3669
+ this.emitResponseTail(key, session);
3670
+ }, sseOutputBatchMs);
3671
+ }
3672
+ /**
3673
+ * @description Emit only the part of `currentResponseText` not yet sent as
3674
+ * an `output` event, then advance `lastEmittedLength`. The bot does not
3675
+ * delta-render OpenCode output (`outputsDeltas` is unset), so each emit is a
3676
+ * raw tail of the in-flight response — possibly cut mid-word.
3677
+ *
3678
+ * Every tail except the FIRST of a response carries `isContinuation: true`
3679
+ * (a {@link OutboundHints}). The bot appends a continuation tail to the
3680
+ * same growing Telegram message instead of starting a new one, so the long
3681
+ * reply reads as one message rather than each edit replacing the previous
3682
+ * text. `lastEmittedLength === 0` means this is the first tail of a fresh
3683
+ * response (nothing emitted yet), so it is NOT a continuation. `flushOutput`
3684
+ * resets `lastEmittedLength` to 0 after the final tail, so the next response
3685
+ * again starts with a non-continuation first tail.
3686
+ */
3687
+ emitResponseTail(key, session, isFinal = false) {
3688
+ const tail = session.currentResponseText.slice(session.lastEmittedLength);
3689
+ if (!tail.trim())
3690
+ return;
3691
+ const isContinuation = session.lastEmittedLength > 0;
3692
+ // `isFinal` rides only the idle-triggered flush so the bot can skip the
3693
+ // possibly-429-stretched debounce for the turn's last frame.
3694
+ const meta = { isContinuation, isFinal };
3695
+ if (session.isResponseCompactionSummary)
3696
+ meta.isCompactionSummary = true;
3697
+ this.emit('output', key, tail, meta);
3698
+ session.lastEmittedLength = session.currentResponseText.length;
3699
+ }
3700
+ /**
3701
+ * @description Handle a SUB-AGENT (child session) text part per the
3702
+ * mode×kind matrix ({@link getSubagentPartAction}):
3703
+ *
3704
+ * - `compact` → never touch any accumulator; signal the dedicated
3705
+ * `subagentStatus` event instead (the bot owns a single self-updating
3706
+ * "🤖 sub-agent: <title> · m:ss" message + elapsed timer). This replaced
3707
+ * the old shared-status refresh, whose lost single-message identity between
3708
+ * sparse bursts re-`sendMessage`d a new message each time (the flood bug).
3709
+ * - `full` → accumulate into the SEPARATE child accumulator and flush via
3710
+ * the same debounce discipline as {@link handleTextDelta} — never through
3711
+ * `currentResponseText` (a child transcript in the parent accumulator
3712
+ * would corrupt the parent reply's continuation accounting).
3713
+ */
3714
+ handleSubagentTextPart(key, session, delta, field) {
3715
+ if (field && field !== 'text')
3716
+ return;
3717
+ const text = delta || '';
3718
+ if (!text)
3719
+ return;
3720
+ if ((0, subagentRender_1.getSubagentPartAction)(this.getSubagentMode(key), 'text') === 'status') {
3721
+ // Dedicated channel (NOT the shared transient status): the bot keeps ONE
3722
+ // self-updating message with a ticking elapsed counter, edited in place.
3723
+ this.emit('subagentStatus', key, { active: true, title: session.activeSubagentTitle });
3724
+ return;
3725
+ }
3726
+ session.childResponseText += text;
3727
+ if (session.childOutputTimer) {
3728
+ clearTimeout(session.childOutputTimer);
3729
+ }
3730
+ session.childOutputTimer = setTimeout(() => {
3731
+ session.childOutputTimer = null;
3732
+ this.emitChildResponseTail(key, session);
3733
+ }, sseOutputBatchMs);
3734
+ }
3735
+ /**
3736
+ * @description Emit the unsent tail of the CHILD (sub-agent) accumulator as
3737
+ * a marked `output` event (`meta.isSubagent`, `/subagent full` mode only).
3738
+ * Mirrors {@link emitResponseTail} over the dedicated child fields so the
3739
+ * parent's emit cursor is never advanced by child text. Carries NO
3740
+ * `isContinuation`: the bot renders every sub-agent chunk as its own marked
3741
+ * message outside the parent's edit-in-place chain (acceptable v1 — `full`
3742
+ * is opt-in). The empty-string guard doubles as runtime tolerance for test
3743
+ * fixtures that inject minimal session shapes.
3744
+ */
3745
+ emitChildResponseTail(key, session) {
3746
+ if (!session.childResponseText)
3747
+ return;
3748
+ const tail = session.childResponseText.slice(session.childLastEmittedLength);
3749
+ if (!tail.trim())
3750
+ return;
3751
+ const meta = { isSubagent: true };
3752
+ this.emit('output', key, tail, meta);
3753
+ session.childLastEmittedLength = session.childResponseText.length;
3754
+ }
3755
+ /**
3756
+ * @description Is this part the delegation (`task`) tool still in flight
3757
+ * (pending/running)? Drives both the delegation-title tracking and the
3758
+ * dedicated "🤖 Delegating: <title> …" activity status (S5) — once the
3759
+ * delegation completes/errors the generic ✅/❌ status forms take over.
3760
+ */
3761
+ checkIsDelegationInFlight(part, toolState) {
3762
+ return part.tool === delegationToolName
3763
+ && (toolState.status === 'pending' || toolState.status === 'running');
3764
+ }
3765
+ /**
3766
+ * @description Read the delegation's title off a `task` tool part's state:
3767
+ * the explicit `title` when present, else the prompt `description` from the
3768
+ * tool input. `null` when the part carries neither (the status builders fall
3769
+ * back to a localized generic label).
3770
+ */
3771
+ getDelegationTitle(toolState) {
3772
+ const inputDescription = typeof toolState.input?.description === 'string' ? toolState.input.description : null;
3773
+ return toolState.title || inputDescription;
3774
+ }
3775
+ /**
3776
+ * @description Record / clear the CURRENT delegation's title from the
3777
+ * PARENT's `task` tool part: stored while the delegation is pending/running
3778
+ * (feeds the dedicated sub-agent status message; S5's "Delegating" activity
3779
+ * status reuses this field), cleared once it completes/errors. Only ever
3780
+ * called for the parent's own parts — a child's nested `task` must not
3781
+ * overwrite the parent-level title.
3782
+ *
3783
+ * The title is STICKY (D2 fix): while in-flight we only OVERWRITE it with a
3784
+ * non-null title — a beat where the `task` part momentarily lacks
3785
+ * title/description leaves the last known title intact, instead of clobbering
3786
+ * it to `null` mid-run (which made the line read "sub-agent: sub-agent").
3787
+ * It is cleared to `null` only on the terminal (completed/error) part.
3788
+ */
3789
+ trackDelegationTitle(session, part, toolState) {
3790
+ if (part.tool !== delegationToolName)
3791
+ return;
3792
+ if (this.checkIsDelegationInFlight(part, toolState)) {
3793
+ const title = this.getDelegationTitle(toolState);
3794
+ if (title)
3795
+ session.activeSubagentTitle = title;
3796
+ return;
3797
+ }
3798
+ session.activeSubagentTitle = null;
3799
+ }
3800
+ /**
3801
+ * @description Handle tool part — format and emit as 'status'.
3802
+ */
3803
+ handleToolPart(key, session, part, isSubagent = false) {
3804
+ if (!part)
3805
+ return;
3806
+ const toolName = part.tool || 'tool';
3807
+ const state = part.state;
3808
+ if (!state)
3809
+ return;
3810
+ // Whether THIS part is the PARENT's own in-flight delegation whose
3811
+ // "working" indicator is owned by the dedicated `subagentStatus` message
3812
+ // (minimal/short) — in that case the shared-status "Delegating…" emit below
3813
+ // must be SUPPRESSED so the two lines don't compete. Full mode keeps the
3814
+ // shared-status behaviour (the streamed child transcript is the indicator).
3815
+ let suppressSharedDelegatingStatus = false;
3816
+ if (isSubagent) {
3817
+ // Child tool part: `ignore` in compact (a generic 🔧 status would
3818
+ // overwrite the sub-agent status line); transient `status` in full.
3819
+ // Child toolResult BODIES are suppressed in both modes (the return at
3820
+ // the bottom never runs `maybeEmitToolResult` for a child): the
3821
+ // parent's `task` output already carries the child's final result.
3822
+ if ((0, subagentRender_1.getSubagentPartAction)(this.getSubagentMode(key), 'tool') === 'ignore')
3823
+ return;
3824
+ }
3825
+ else {
3826
+ this.trackDelegationTitle(session, part, state);
3827
+ // Parent's own `task` part drives the dedicated sub-agent status message
3828
+ // (minimal/short modes): open/refresh while in flight, close on
3829
+ // completed/error. Full mode keeps the old shared "Delegating…" status.
3830
+ if (part.tool === delegationToolName && this.getSubagentMode(key) !== 'full') {
3831
+ if (this.checkIsDelegationInFlight(part, state)) {
3832
+ this.emit('subagentStatus', key, {
3833
+ active: true,
3834
+ title: this.getDelegationTitle(state) ?? session.activeSubagentTitle,
3835
+ });
3836
+ suppressSharedDelegatingStatus = true;
3837
+ }
3838
+ else if (state.status === 'completed' || state.status === 'error') {
3839
+ this.emit('subagentStatus', key, { active: false, title: null });
3840
+ }
3841
+ }
3842
+ }
3843
+ let statusText;
3844
+ if (this.checkIsDelegationInFlight(part, state)) {
3845
+ // An in-flight delegation mirrors the terminal's "~ Delegating…" line
3846
+ // instead of the generic 🔄/🔧 forms (S5) — the parent-side counterpart
3847
+ // of the compact "🤖 sub-agent: …" status, same style. The title comes
3848
+ // off THIS part's state, so a child's own nested `task` (full mode)
3849
+ // renders its own title, never the parent's tracked one. In minimal/short
3850
+ // the dedicated `subagentStatus` message already owns this indicator, so
3851
+ // skip the competing shared-status emit.
3852
+ if (suppressSharedDelegatingStatus) {
3853
+ if (!isSubagent)
3854
+ this.maybeEmitToolResult(key, session, part);
3855
+ return;
3856
+ }
3857
+ statusText = this.tl(key, () => (0, subagentRender_1.buildDelegatingStatusText)(this.getDelegationTitle(state)));
3858
+ }
3859
+ else {
3860
+ switch (state.status) {
3861
+ case 'pending':
3862
+ statusText = `🔄 ${toolName}...`;
3863
+ break;
3864
+ case 'running':
3865
+ statusText = `🔧 ${state.title || toolName}...`;
3866
+ break;
3867
+ case 'completed':
3868
+ statusText = `✅ ${state.title || toolName}`;
3869
+ break;
3870
+ case 'error':
3871
+ statusText = `❌ ${toolName}: ${state.error || 'failed'}`;
3872
+ break;
3873
+ default:
3874
+ statusText = `🔧 ${toolName}`;
3875
+ break;
3876
+ }
3877
+ }
3878
+ this.emitStatus(key, session, statusText);
3879
+ if (!isSubagent)
3880
+ this.maybeEmitToolResult(key, session, part);
3881
+ }
3882
+ /**
3883
+ * @description Emit the dedicated MODE-AGNOSTIC `toolResult` event once a
3884
+ * tool part reaches `completed` with a non-empty output (S3). The bot
3885
+ * resolves the per-thread tool-results mode (`minimal` drops it there) — the
3886
+ * adapter emits every result exactly once, guarded by
3887
+ * {@link OpenCodeSession.emittedToolResultPartIds} against the re-sent part
3888
+ * shapes. The output is kept strictly OUT of `currentResponseText` so it can
3889
+ * never pollute the answer or its continuation accounting.
3890
+ */
3891
+ maybeEmitToolResult(key, session, part) {
3892
+ const toolState = part.state;
3893
+ if (toolState?.status !== 'completed')
3894
+ return;
3895
+ if (typeof toolState.output !== 'string' || !toolState.output.trim())
3896
+ return;
3897
+ // A part id is required for the double-emit guard. Only full
3898
+ // `message.part.updated` part objects reach handleToolPart, and those
3899
+ // always carry an id — an id-less shape is safer dropped than duplicated.
3900
+ if (!part.id || session.emittedToolResultPartIds.has(part.id))
3901
+ return;
3902
+ session.emittedToolResultPartIds.add(part.id);
3903
+ const payload = { tool: part.tool || 'tool', output: toolState.output };
3904
+ if (toolState.title)
3905
+ payload.title = toolState.title;
3906
+ this.emit('toolResult', key, payload);
3907
+ }
3908
+ /** Debounce delay (ms) for live `thinking` emits — the reasoning delta
3909
+ * stream is chatty, so live frames are coalesced like text deltas. */
3910
+ static reasoningLiveEmitMs = 400;
3911
+ /**
3912
+ * @description Handle a reasoning (chain-of-thought) part.
3913
+ *
3914
+ * Reasoning text is accumulated into {@link OpenCodeSession.reasoningText}
3915
+ * — kept STRICTLY separate from `currentResponseText` so it never leaks into
3916
+ * the answer — and surfaced through the DEDICATED `thinking` event (not the
3917
+ * generic `status` coalescer), so the thinking indicator can persist
3918
+ * independently of tool status. The adapter stays MODE-AGNOSTIC: it emits the
3919
+ * raw accumulated text + phase, and the bot applies the per-thread mode.
3920
+ *
3921
+ * On the FIRST reasoning delta of a response we record `reasoningStartedAt`
3922
+ * (drives the "thought for {N}s" duration on the eventual `done` emit). Live
3923
+ * emits are debounced.
3924
+ */
3925
+ handleReasoningPart(key, session, part, delta, field) {
3926
+ // For message.part.delta only the reasoning `text` field carries content.
3927
+ if (field && field !== 'text')
3928
+ return;
3929
+ const text = delta || part?.text || '';
3930
+ if (!text)
3931
+ return;
3932
+ if (session.reasoningStartedAt === null) {
3933
+ session.reasoningStartedAt = Date.now();
3934
+ }
3935
+ session.reasoningText += text;
3936
+ // Debounce the live emit — newest accumulated text always wins.
3937
+ if (session.reasoningTimer)
3938
+ clearTimeout(session.reasoningTimer);
3939
+ session.reasoningTimer = setTimeout(() => {
3940
+ session.reasoningTimer = null;
3941
+ this.emitThinking(key, session, 'live');
3942
+ }, OpenCodeAdapter.reasoningLiveEmitMs);
3943
+ }
3944
+ /**
3945
+ * @description Emit the `thinking` event for the current phase. `live` carries
3946
+ * the accumulated reasoning text so far; `done` adds the elapsed `durationMs`.
3947
+ */
3948
+ emitThinking(key, session, phase) {
3949
+ const payload = { phase, text: session.reasoningText };
3950
+ if (phase === 'done' && session.reasoningStartedAt !== null) {
3951
+ payload.durationMs = Date.now() - session.reasoningStartedAt;
3952
+ }
3953
+ this.emit('thinking', key, payload);
3954
+ }
3955
+ /**
3956
+ * @description End the current response's reasoning stream, if one is active.
3957
+ * Flushes any pending live frame, emits the `thinking` `done` event (so the
3958
+ * bot can collapse / persist per mode), and resets the per-response reasoning
3959
+ * accumulator. Idempotent — a no-op when no reasoning is in flight, so it can
3960
+ * be called from every "reasoning end" signal (first non-reasoning part,
3961
+ * `message.updated` finish, `session.idle`) without double-emitting.
3962
+ */
3963
+ endReasoning(key, session) {
3964
+ if (session.reasoningStartedAt === null)
3965
+ return;
3966
+ if (session.reasoningTimer) {
3967
+ clearTimeout(session.reasoningTimer);
3968
+ session.reasoningTimer = null;
3969
+ }
3970
+ this.emitThinking(key, session, 'done');
3971
+ session.reasoningText = '';
3972
+ session.reasoningStartedAt = null;
3973
+ }
3974
+ /** Debounce delay (ms) for status updates to avoid Telegram rate limits */
3975
+ static statusDebounceMs = 400;
3976
+ /**
3977
+ * @description Debounced emit of 'status' event.
3978
+ * Rapid status updates (e.g. multiple tool state changes) are batched —
3979
+ * only the latest status text is emitted after a quiet period.
3980
+ */
3981
+ emitStatus(key, session, text) {
3982
+ session.pendingStatus = text;
3983
+ if (session.statusDebounceTimer) {
3984
+ clearTimeout(session.statusDebounceTimer);
3985
+ }
3986
+ session.statusDebounceTimer = setTimeout(() => {
3987
+ session.statusDebounceTimer = null;
3988
+ if (session.pendingStatus) {
3989
+ this.emit('status', key, session.pendingStatus);
3990
+ session.pendingStatus = null;
3991
+ }
3992
+ }, OpenCodeAdapter.statusDebounceMs);
3993
+ }
3994
+ /**
3995
+ * @description Handle message completion.
3996
+ * Event properties: { info: OpenCodeMessageInfo }
3997
+ */
3998
+ handleMessageUpdate(key, properties) {
3999
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
4000
+ if (!session?.isActive)
4001
+ return;
4002
+ const info = properties.info;
4003
+ if (!info)
4004
+ return;
4005
+ const isParentAssistantMessage = info.role === 'assistant' && (!info.sessionID || info.sessionID === session.sessionId);
4006
+ const modelLabel = info.modelID
4007
+ ? (info.providerID ? `${info.providerID}/${info.modelID}` : info.modelID)
4008
+ : null;
4009
+ const selectedModelLabel = session.modelOverride
4010
+ ? `${session.modelOverride.providerID}/${session.modelOverride.modelID}`
4011
+ : null;
4012
+ const isCompleteConflictingModel = Boolean(selectedModelLabel !== null
4013
+ && info.providerID
4014
+ && info.modelID
4015
+ && modelLabel !== selectedModelLabel);
4016
+ const isAbortedRetryMessage = isParentAssistantMessage
4017
+ && session.isAwaitingModelAfterProviderRetryAbort
4018
+ && ((info.error !== undefined && checkIsOpenCodeAbortError(getOpenCodeErrorMessage(info.error)))
4019
+ || isCompleteConflictingModel);
4020
+ if (isAbortedRetryMessage) {
4021
+ // The event is stale for this turn, but newer than any reattach history.
4022
+ // Record that ordering without retaining the aborted runtime tuple.
4023
+ session.parentAssistantObservationVersion += 1;
4024
+ console.log(`[OpenCode] Ignoring model from aborted provider retry: ${modelLabel ?? 'unknown'}`);
4025
+ return;
4026
+ }
4027
+ // An assistant message (own or sub-agent child) means the turn genuinely
4028
+ // started — the SOLE wedged-turn activity signal. Must be assistant-only:
4029
+ // `prompt_async` also emits message/part events for the ECHOED USER PROMPT,
4030
+ // and counting those (the old unconditional set in handlePartUpdate) marked
4031
+ // every wedge as "active" and masked it (live miss 2026-08-16).
4032
+ if (info.role === 'assistant')
4033
+ session.sawTurnActivity = true;
4034
+ // The summary message is stored (and announced here) before its text streams.
4035
+ if (isParentAssistantMessage && info.summary === true && info.id)
4036
+ session.compactionSummaryMessageId = info.id;
4037
+ if (info.role === 'user' && (!info.sessionID || info.sessionID === session.sessionId)) {
4038
+ this.noteUserMessageSeen(session, info.id);
4039
+ }
4040
+ if (isParentAssistantMessage) {
4041
+ // The first non-stale parent assistant event marks the replacement turn's
4042
+ // boundary, even when it lacks the optional runtime token tuple.
4043
+ session.isAwaitingModelAfterProviderRetryAbort = false;
4044
+ this.clearProviderRetryReplacementBoundary(session);
4045
+ session.isAwaitingProviderRetryAbortIdle = false;
4046
+ const contextUsedTokens = getOpenCodeContextUsedTokens(info.tokens);
4047
+ if (typeof info.id === 'string'
4048
+ && info.id.trim()
4049
+ && contextUsedTokens !== null
4050
+ && !checkIsOpenCodePendingContextPlaceholder(info.finish, contextUsedTokens)) {
4051
+ session.parentAssistantObservationVersion += 1;
4052
+ const model = getOpenCodeRuntimeModel(info.providerID, info.modelID);
4053
+ session.latestParentRuntimeContext = {
4054
+ messageId: info.id,
4055
+ ...(model ? { model } : {}),
4056
+ contextUsedTokens,
4057
+ };
4058
+ }
4059
+ }
4060
+ // Track and show model info on first assistant message (or after model change)
4061
+ if (info.role === 'assistant' && modelLabel !== null) {
4062
+ session.currentModelLabel = modelLabel;
4063
+ if (!session.isModelInfoShown) {
4064
+ session.isModelInfoShown = true;
4065
+ console.log(`[OpenCode] Using model: ${modelLabel}`);
4066
+ this.emit('output', key, `Model: ${modelLabel}`);
4067
+ }
4068
+ }
4069
+ // When assistant message completes (has finish reason), flush output
4070
+ if (info.finish && info.role === 'assistant') {
4071
+ // A CHILD (sub-agent) assistant message finishing must NOT close the
4072
+ // dedicated sub-agent status — the parent's delegation is still in flight.
4073
+ // Only the PARENT's own finishing message ends the delegation defensively.
4074
+ const isParentMessage = !info.sessionID || info.sessionID === session.sessionId;
4075
+ if (isParentMessage) {
4076
+ // Anchor the seen-watermark on the PARENT turn's final assistant message
4077
+ // id (a child's id must never become the watermark).
4078
+ if (info.id)
4079
+ session.lastMessageId = info.id;
4080
+ this.closeSubagentStatusOnParentTurnEnd(key);
4081
+ // S7: advance the persisted watermark on EACH parent-message `finish`,
4082
+ // not only at turn-end idle. The bot relays every completed assistant
4083
+ // message live (`flushOutput` below), so tracking each finished parent id
4084
+ // keeps `missedCount` at what was genuinely unseen — a mid-turn restart
4085
+ // after these messages already landed re-shows nothing (the false
4086
+ // "missed N" fix, live 2026-07-04). The `session.idle` advance stays as
4087
+ // the final safety net (a dropped `finish`); child messages never reach
4088
+ // here (the `isParentMessage` guard).
4089
+ this.advanceSeenWatermark(key, { sessionId: session.sessionId, opencodeMessageId: session.lastMessageId });
4090
+ }
4091
+ this.flushOutput(key);
4092
+ }
4093
+ // Surface errors to the user — except a bot-issued abort ("Aborted"), which
4094
+ // the aborted turn's assistant message also carries in `info.error`. It is
4095
+ // swallowed for the same reason as the `session.error` twin above (the
4096
+ // question-cancel / `/esc` / provider-retry interrupt is ours, not a fault).
4097
+ if (info.error) {
4098
+ const errorMsg = getOpenCodeErrorMessage(info.error);
4099
+ if (checkIsOpenCodeAbortError(errorMsg)) {
4100
+ console.log(`[OpenCode] Swallowed bot-issued abort (message.error "Aborted") for ${(0, sessionKey_1.keyToString)(key)}`);
4101
+ }
4102
+ else {
4103
+ console.error(`[OpenCode] Message error:`, errorMsg);
4104
+ this.emit('output', key, `Error: ${errorMsg}`);
4105
+ }
4106
+ }
4107
+ }
4108
+ /**
4109
+ * @description Handle session becoming idle (AI done processing).
4110
+ * Flush any remaining accumulated output.
4111
+ */
4112
+ handleSessionIdle(key, properties) {
4113
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
4114
+ if (!session?.isActive)
4115
+ return;
4116
+ const sessionId = properties.sessionID;
4117
+ if (sessionId && !(0, openCodeSessionRouting_1.checkIsEventForSession)(sessionId, session.sessionId, this.sessionLineage))
4118
+ return;
4119
+ const isOwnIdle = !sessionId || sessionId === session.sessionId;
4120
+ if (isOwnIdle &&
4121
+ // A turn we are actively waiting on owns its idle: the abort guard may
4122
+ // still be armed when the replacement bound expired without the aborted
4123
+ // retry's idle ever arriving, and the recovery prompt that follows would
4124
+ // otherwise have ITS idle swallowed — hanging the topic one prompt later
4125
+ // and stalling the escalation. During the legitimate swallow window this
4126
+ // flag is false by construction (a retry replacement defers arming it to
4127
+ // its own `busy`, which clears the guards as it arms).
4128
+ !session.awaitingTurnResponse &&
4129
+ (session.isAwaitingProviderRetryAbortIdle || session.isAwaitingProviderRetryReplacementStart)) {
4130
+ // The abort completes the old retry turn asynchronously. Its idle must not
4131
+ // settle or recover the replacement prompt that was posted after the abort.
4132
+ // Swallowing is bounded on both flags: this one is a one-shot cleared right
4133
+ // here, and the replacement boundary is released by its own `busy` or, if
4134
+ // that never comes, by `providerRetryReplacementStartTimer`. Neither can
4135
+ // latch idle handling off for the rest of the session.
4136
+ session.isAwaitingProviderRetryAbortIdle = false;
4137
+ console.log(`[OpenCode] Ignoring idle from aborted provider retry`);
4138
+ return;
4139
+ }
4140
+ // Snapshot the wedged-turn guards BEFORE the own-idle clears below reset
4141
+ // them: a legitimate compaction cycle and a still-pending provider retry
4142
+ // both idle without assistant text and must NOT be flagged as wedged.
4143
+ // `Boolean(...)` (not `!== null`) so a reattached session whose field is
4144
+ // `undefined` — never set by the resume path — reads as "no retry" instead
4145
+ // of silently suppressing the wedge notice (live miss 2026-08-16).
4146
+ const wasCompacting = session.isCompacting;
4147
+ const hadPendingProviderRetry = Boolean(session.providerRetrySignature);
4148
+ // Idle = busy/idle transition to idle; also a defensive clear if a
4149
+ // `session.status` idle was missed. Own idle also ends any compaction
4150
+ // (the `.ended` / `session.compacted` event may be lost) — without this the
4151
+ // latched flag would force every later prompt down the queue path.
4152
+ if (isOwnIdle) {
4153
+ session.isCompacting = false;
4154
+ session.providerRetrySignature = null;
4155
+ }
4156
+ applyOpenCodeStatusEvent(session, session.sessionId, sessionId ?? null, false, this.checkIsVerifiedDescendant(sessionId ?? null, session.sessionId));
4157
+ // The PARENT going idle ends the turn → defensively close the sub-agent
4158
+ // status. A CHILD (sub-agent) idle (routed here via lineage) must NOT close
4159
+ // it — the delegation is still in flight, only the child finished a step.
4160
+ if (!sessionId || sessionId === session.sessionId) {
4161
+ this.closeSubagentStatusOnParentTurnEnd(key);
4162
+ }
4163
+ console.log(`[OpenCode] Session idle`);
4164
+ // The turn just ended: mark this flush final so the bot delivers the last
4165
+ // frame promptly instead of letting it sit out a stretched 429 debounce.
4166
+ this.flushOutput(key, true);
4167
+ // Advance the seen-watermark only on the PARENT's own idle — a sub-agent
4168
+ // child idle (routed here via lineage) leaves the parent turn in flight.
4169
+ // Anchored on the last completed parent assistant message id (captured in
4170
+ // handleMessageUpdate); a missing anchor is skipped (recap falls back).
4171
+ // S7: this is now the SAFETY NET — the primary advance runs per parent
4172
+ // `finish` in handleMessageUpdate, so a mid-turn restart already sees the
4173
+ // relayed tail; idle still fires in case a `finish` was dropped.
4174
+ if (isOwnIdle) {
4175
+ this.advanceSeenWatermark(key, { sessionId: session.sessionId, opencodeMessageId: session.lastMessageId });
4176
+ }
4177
+ // Wedged-turn detection: a delivered prompt that produced NO assistant
4178
+ // activity before idling means OpenCode accepted it but never ran a turn
4179
+ // (a bloated / stuck session — live 2026-08-15/16). Emit `noResponse` so the
4180
+ // bot can auto-recover (fresh session + replay); the session is
4181
+ // unrecoverable in place, so a notice alone leaves the topic dead. Resolved
4182
+ // once per prompt: the pending flag is cleared on this own idle regardless.
4183
+ if (isOwnIdle && session.awaitingTurnResponse) {
4184
+ const isWedged = (0, openCodeTurnActivity_1.checkIsWedgedTurn)({
4185
+ awaitingResponse: true,
4186
+ sawActivity: session.sawTurnActivity,
4187
+ wasCompacting,
4188
+ hadPendingProviderRetry,
4189
+ });
4190
+ session.awaitingTurnResponse = false;
4191
+ if (isWedged)
4192
+ this.reportNoTurnResponse(key, session, 'wedgedIdle');
4193
+ }
4194
+ }
4195
+ /**
4196
+ * @description Track live busy/idle/retry from `session.status`. The own
4197
+ * session's status drives {@link OpenCodeSession.isBusy}; provider-managed
4198
+ * retry remains busy and is surfaced once, because OpenCode otherwise accepts
4199
+ * later prompts into an unread queue with no user-visible explanation. A
4200
+ * lineage-verified child
4201
+ * (sub-agent) status maintains {@link OpenCodeSession.busyChildSessionIds}
4202
+ * so a new prompt never aborts a running sub-agent. A foreign non-descendant
4203
+ * session's busy=true (dir-fallback-routed wedged sibling) is ignored —
4204
+ * see {@link applyOpenCodeStatusEvent}.
4205
+ */
4206
+ handleSessionStatus(key, eventSessionId, properties) {
4207
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
4208
+ if (!session?.isActive)
4209
+ return;
4210
+ const status = properties.status;
4211
+ const isProviderRetry = status?.type === 'retry';
4212
+ const wasForeignBusyIgnored = applyOpenCodeStatusEvent(session, session.sessionId, eventSessionId, status?.type === 'busy' || isProviderRetry, this.checkIsVerifiedDescendant(eventSessionId, session.sessionId));
4213
+ if (wasForeignBusyIgnored && eventSessionId) {
4214
+ this.logForeignBusyIgnoredOncePerWindow(eventSessionId, session.sessionId);
4215
+ }
4216
+ const isOwnSession = !eventSessionId || eventSessionId === session.sessionId;
4217
+ if (isOwnSession
4218
+ && isProviderRetry
4219
+ && (session.providerRetryAbortPromise
4220
+ || (session.isAwaitingProviderRetryReplacementStart
4221
+ && (session.isAwaitingProviderRetryAbortIdle || session.isAwaitingModelAfterProviderRetryAbort)))) {
4222
+ // A retry status that races the abort belongs to the cancelled turn. It
4223
+ // must not restore the retry guard for the prompt we are about to send.
4224
+ console.log(`[OpenCode] Ignoring retry status from aborted provider retry`);
4225
+ return;
4226
+ }
4227
+ if (!isOwnSession)
4228
+ return;
4229
+ if (status?.type === 'busy' && session.isAwaitingProviderRetryReplacementStart) {
4230
+ // `busy` is the observable boundary for the replacement request. Older
4231
+ // retry termination events can no longer consume this new turn's idle, and
4232
+ // the bound on this wait is released with the boundary it guarded.
4233
+ this.clearProviderRetryReplacementBoundary(session);
4234
+ session.isAwaitingProviderRetryAbortIdle = false;
4235
+ session.awaitingTurnResponse = true;
4236
+ session.sawTurnActivity = false;
4237
+ }
4238
+ if (!isProviderRetry) {
4239
+ if (!session.providerRetryAbortPromise) {
4240
+ session.providerRetrySignature = null;
4241
+ }
4242
+ return;
4243
+ }
4244
+ const retryAttempt = typeof status.attempt === 'number' ? status.attempt : 1;
4245
+ const retryAt = typeof status.next === 'number' ? status.next : Date.now();
4246
+ const retryMessage = typeof status.message === 'string' ? status.message : '';
4247
+ const retrySignature = `${retryAttempt}:${retryAt}:${retryMessage}`;
4248
+ if (session.providerRetrySignature === retrySignature)
4249
+ return;
4250
+ session.providerRetrySignature = retrySignature;
4251
+ const retryMinutes = Math.max(1, Math.ceil((retryAt - Date.now()) / providerRetryMsPerMinute));
4252
+ console.warn(`[OpenCode] Provider retry attempt=${retryAttempt} in=${retryMinutes}m: ${retryMessage}`);
4253
+ this.emit('output', key, this.tl(key, () => (0, i18n_1.t)('apiRetry.transientNotice', { minutes: retryMinutes, attempt: retryAttempt })), { isComplete: true });
4254
+ }
4255
+ /**
4256
+ * @description Strict-descendant verification for busy tracking: `true` only
4257
+ * when `eventSessionId` walks up the lineage map to `ownSessionId` — never
4258
+ * for the own id itself or a foreign sibling. Distinct from
4259
+ * {@link checkIsEventForSession} (own-or-descendant), which routing uses.
4260
+ */
4261
+ checkIsVerifiedDescendant(eventSessionId, ownSessionId) {
4262
+ return (eventSessionId !== null &&
4263
+ (0, openCodeSessionRouting_1.getLineageDepthToAncestor)(eventSessionId, ownSessionId, this.sessionLineage) !== null);
4264
+ }
4265
+ /**
4266
+ * @description Diag-log one "busy-ignored" line for a foreign non-descendant
4267
+ * busy=true that busy tracking refused to record, throttled per session id
4268
+ * like {@link logSseDropOncePerWindow} — a wedged sibling re-emits
4269
+ * `session.status` every ~30 s and would otherwise flood the diag log.
4270
+ */
4271
+ logForeignBusyIgnoredOncePerWindow(eventSessionId, ownSessionId) {
4272
+ const shouldLog = (0, openCodeSessionRouting_1.checkShouldLogDrop)(this.sseDropLogThrottle, foreignBusyIgnoredLogType, eventSessionId, Date.now(), sseDropLogThrottleMs, maxSseDropThrottleEntries);
4273
+ if (shouldLog) {
4274
+ (0, diagLog_1.appendDiagLog)(`sse busy-ignored foreign es=${eventSessionId} own=${ownSessionId}`);
4275
+ }
4276
+ }
4277
+ /**
4278
+ * @description Flip the compaction flag from `session.next.compaction.*` /
4279
+ * `session.compacted` events. While set, a new prompt queues instead of
4280
+ * aborting (an abort would discard the in-progress summary).
4281
+ */
4282
+ setCompacting(key, value) {
4283
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
4284
+ if (!session?.isActive)
4285
+ return;
4286
+ if (session.isCompacting !== value)
4287
+ console.log(`[OpenCode] compacting=${value}`);
4288
+ session.isCompacting = value;
4289
+ }
4290
+ handleSessionError(key, eventSessionId, properties) {
4291
+ const errorMsg = getOpenCodeErrorMessage(properties.error);
4292
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
4293
+ const isOwnSession = !eventSessionId || (session !== undefined && eventSessionId === session.sessionId);
4294
+ // A bare "Aborted" error is ALWAYS a bot-issued abort (question-cancel SIGINT
4295
+ // / `/esc` / provider-retry interrupt) — never surface it as "OpenCode error:
4296
+ // Aborted" (the reported third bogus message on the question-cancel path).
4297
+ // The turn DID end, so still close the sub-agent status for the own session.
4298
+ if (checkIsOpenCodeAbortError(errorMsg)) {
4299
+ console.log(`[OpenCode] Swallowed bot-issued abort (session.error "Aborted") for ${(0, sessionKey_1.keyToString)(key)}`);
4300
+ if (session?.isActive && isOwnSession) {
4301
+ // OpenCode may emit the terminal idle after this error. Keep its
4302
+ // one-shot shield armed until idle or the replacement's busy boundary.
4303
+ this.closeSubagentStatusOnParentTurnEnd(key);
4304
+ }
4305
+ return;
4306
+ }
4307
+ console.error(`[OpenCode] Session error:`, errorMsg);
4308
+ this.emit('output', key, `OpenCode error: ${errorMsg}`);
4309
+ // A PARENT-session error aborts the current turn/generation, so the
4310
+ // delegation the dedicated sub-agent status describes is no longer running:
4311
+ // close it defensively (mirrors the parent-idle / parent-finish closes) so
4312
+ // an error that never reaches a clean idle can't leave the "working" frame
4313
+ // and its self-re-arming tick wedged, flooding the topic (live 2026-08-03).
4314
+ // A CHILD (sub-agent) error must NOT close it — the parent delegation is
4315
+ // still in flight (guarded the same way as `handleSessionIdle`). If an
4316
+ // auto-retry re-delegates, a fresh `subagentStatus{active:true}` re-opens it.
4317
+ if (session?.isActive && isOwnSession) {
4318
+ this.closeSubagentStatusOnParentTurnEnd(key);
4319
+ }
4320
+ // Provider-side API error at the proxy boundary → emit `apiError`. transient
4321
+ // / usageLimit arm the auto-retry (the session stays active after
4322
+ // `session.error`, so the kick reuses it); `auth` (logged out / bad
4323
+ // credentials) is SURFACED as a pinned notice by the bot, never retried.
4324
+ // Unrecognised errors classify to `null` and emit nothing.
4325
+ //
4326
+ // Classified from the RAW payload, not from `errorMsg`: the surfaced text
4327
+ // collapses an unrecognised shape to a constant that carries no marker to
4328
+ // classify. The raw text stops here — the classifier returns only a class.
4329
+ const apiError = (0, apiErrorRetry_1.classifyAgentApiError)(getOpenCodeErrorClassificationText(properties.error), Date.now());
4330
+ if (apiError) {
4331
+ this.emit('apiError', key, apiError);
4332
+ }
4333
+ }
4334
+ handlePermissionAsked(_key, properties, directory) {
4335
+ console.log(`[OpenCode] Permission requested:`, JSON.stringify(properties));
4336
+ // Auto-approve all permissions (headless mode). Symmetry with the
4337
+ // claude adapter, which always passes `--dangerously-skip-permissions`
4338
+ // (plan D44, §10.2). Per-thread opt-out (S1) was deliberately rejected
4339
+ // — if it ever comes back, this is where it'd hook in.
4340
+ const requestId = (properties.requestID || properties.id);
4341
+ if (requestId) {
4342
+ // Permission state is instance-local, like questions: an approve sent
4343
+ // without the owning `?directory=` 404s silently and the agent hangs
4344
+ // on the permission forever.
4345
+ this.apiRequest('POST', buildDirectoryScopedPath(`/permission/${requestId}/reply`, directory), {
4346
+ reply: 'always',
4347
+ }).catch((e) => {
4348
+ console.error(`[OpenCode] Failed to reply to permission:`, e);
4349
+ });
4350
+ }
4351
+ }
4352
+ /**
4353
+ * @description Handle question.asked events from OpenCode.
4354
+ * Stores the pending question and emits a 'question' event so the bot
4355
+ * can display it to the user with interactive buttons.
4356
+ *
4357
+ * Event properties: { id, sessionID, questions: QuestionInfo[], tool? }
4358
+ */
4359
+ handleQuestionAsked(key, properties, directory) {
4360
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
4361
+ if (!session?.isActive)
4362
+ return;
4363
+ const requestId = (properties.requestID || properties.id);
4364
+ const questions = properties.questions;
4365
+ console.log(`[OpenCode] Question asked (${requestId}, instance=${directory ?? 'default'}):`, JSON.stringify(properties).slice(0, 500));
4366
+ if (!requestId || !questions || questions.length === 0) {
4367
+ // No valid question — reply empty to unblock
4368
+ if (requestId) {
4369
+ this.apiRequest('POST', buildDirectoryScopedPath(`/question/${requestId}/reply`, directory), {
4370
+ answers: [['']],
4371
+ }).catch((e) => console.error(`[OpenCode] Failed to reply to question:`, e));
4372
+ }
4373
+ return;
4374
+ }
4375
+ // Store pending question so we can reply later when user answers.
4376
+ // `directory` rides along: the reply must target the instance that owns
4377
+ // the request, not the serve-cwd default (QuestionNotFoundError bug —
4378
+ // sessions live in whatever instance was current at their creation).
4379
+ session.pendingQuestion = { requestId, questions, directory };
4380
+ // Emit question event for the bot to display to user
4381
+ this.emit('question', key, session.pendingQuestion);
4382
+ }
4383
+ /**
4384
+ * @description Re-surface a question that was still open on the server when
4385
+ * the bot lost track of it — the reattach/resume path rebuilds the session
4386
+ * with `pendingQuestion: null`, so after ANY bot restart an unanswered
4387
+ * question would otherwise be forgotten and the topic hangs forever (the
4388
+ * break-out that turns a user message into the ANSWER never fires, and a
4389
+ * fresh prompt just queues behind the still-blocked turn).
4390
+ *
4391
+ * `GET /question?directory=<workDir>` returns the instance's live open
4392
+ * questions; we pick the entry owned by this session, rebuild
4393
+ * `session.pendingQuestion`, and emit `question` so the existing bot handler
4394
+ * re-posts the option buttons and repopulates (and persists) its pending map.
4395
+ * Reply id is the entry's top-level `id` (`que_…`), resolved by the pure
4396
+ * {@link getOpenQuestionForSession} helper.
4397
+ *
4398
+ * Best-effort: a read/parse failure is logged and swallowed — it must never
4399
+ * throw out of reattach. Only meaningful on the resume/reattach path; a
4400
+ * brand-new session (startSession) has no history and thus no open question,
4401
+ * so it is deliberately NOT called there.
4402
+ */
4403
+ async restoreOpenQuestion(key, sessionId, workDir) {
4404
+ try {
4405
+ const response = await this.apiRequest('GET', buildDirectoryScopedPath('/question', workDir));
4406
+ const restored = (0, openCodeOpenQuestion_1.getOpenQuestionForSession)(response, sessionId, workDir);
4407
+ if (!restored)
4408
+ return;
4409
+ // The session may have been stopped/replaced between the GET and now.
4410
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
4411
+ if (!session?.isActive || session.sessionId !== sessionId)
4412
+ return;
4413
+ session.pendingQuestion = restored;
4414
+ console.log(`[OpenCode] Restored open question ${restored.requestId} for ${(0, sessionKey_1.keyToString)(key)} on reattach`);
4415
+ this.emit('question', key, restored);
4416
+ }
4417
+ catch (e) {
4418
+ console.warn(`[OpenCode] restore open question failed:`, e instanceof Error ? e.message : e);
4419
+ }
4420
+ }
4421
+ /**
4422
+ * @description Backstop predicate (G2): is this session's turn wedged behind
4423
+ * an open interactive question? The authoritative signal is the server's own
4424
+ * `GET /question?directory=<workDir>` returning an entry for this session —
4425
+ * a turn that is merely streaming text / running a normal tool / has a live
4426
+ * sub-agent has NO open question, so it is never reported wedged (no
4427
+ * regression: an arriving prompt still queues-and-is-picked-up for a healthy
4428
+ * busy turn). The bot consults this only when a fresh prompt would otherwise
4429
+ * queue behind a busy session with no known pending question; on `true` it
4430
+ * aborts the dead turn before forwarding instead of queueing forever.
4431
+ *
4432
+ * Best-effort: a read/parse failure returns `false` (don't abort on doubt).
4433
+ */
4434
+ async checkIsWedgedOnQuestion(key) {
4435
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
4436
+ if (!session?.isActive)
4437
+ return false;
4438
+ try {
4439
+ const response = await this.apiRequest('GET', buildDirectoryScopedPath('/question', session.workDir));
4440
+ return (0, openCodeOpenQuestion_1.getOpenQuestionForSession)(response, session.sessionId, session.workDir) !== null;
4441
+ }
4442
+ catch (e) {
4443
+ console.warn(`[OpenCode] wedged-question check failed:`, e instanceof Error ? e.message : e);
4444
+ return false;
4445
+ }
4446
+ }
4447
+ /**
4448
+ * @description Reply to a pending question with user-selected answers.
4449
+ * Called by the bot when the user clicks an inline button or types a custom answer.
4450
+ */
4451
+ answerQuestion(key, answers) {
4452
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
4453
+ if (!session?.isActive || !session.pendingQuestion)
4454
+ return;
4455
+ const { requestId, directory } = session.pendingQuestion;
4456
+ session.pendingQuestion = null;
4457
+ // Audit S15 / #41: surface failures via `error` so the bot's
4458
+ // handleAgentError shows them in the thread, not just in console.
4459
+ this.apiRequest('POST', buildDirectoryScopedPath(`/question/${requestId}/reply`, directory), {
4460
+ answers,
4461
+ }).catch((e) => {
4462
+ console.error(`[OpenCode] Failed to reply to question:`, e);
4463
+ this.emit('error', key, e instanceof Error ? e : new Error(String(e)));
4464
+ });
4465
+ }
4466
+ /**
4467
+ * @description Reject a pending question server-side WITHOUT answering it —
4468
+ * called when the user ABANDONS the question (sends a fresh prompt instead of
4469
+ * answering, or the session is torn down while it is pending). Mirrors
4470
+ * {@link answerQuestion} but POSTs to `/question/:id/reject` (empty body)
4471
+ * instead of `/reply`. Without it the question stays "open" in the server's
4472
+ * registry and {@link restoreOpenQuestion} re-surfaces it on every reattach.
4473
+ * No-op if no active session / no pending question.
4474
+ */
4475
+ rejectQuestion(key) {
4476
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
4477
+ if (!session?.isActive || !session.pendingQuestion)
4478
+ return;
4479
+ const { requestId, directory } = session.pendingQuestion;
4480
+ session.pendingQuestion = null;
4481
+ // Same failure surfacing as answerQuestion: route errors through `error` so
4482
+ // the bot's handleAgentError shows them in the thread, not just console.
4483
+ this.apiRequest('POST', buildDirectoryScopedPath(`/question/${requestId}/reject`, directory), {}).catch((e) => {
4484
+ console.error(`[OpenCode] Failed to reject question:`, e);
4485
+ this.emit('error', key, e instanceof Error ? e : new Error(String(e)));
4486
+ });
4487
+ }
4488
+ flushOutput(key, isFinal = false) {
4489
+ const session = this.sessions.get((0, sessionKey_1.keyToString)(key));
4490
+ if (!session)
4491
+ return;
4492
+ if (session.outputTimer) {
4493
+ clearTimeout(session.outputTimer);
4494
+ session.outputTimer = null;
4495
+ }
4496
+ // Flush any pending status before flushing text output
4497
+ if (session.statusDebounceTimer) {
4498
+ clearTimeout(session.statusDebounceTimer);
4499
+ session.statusDebounceTimer = null;
4500
+ }
4501
+ if (session.pendingStatus) {
4502
+ this.emit('status', key, session.pendingStatus);
4503
+ session.pendingStatus = null;
4504
+ }
4505
+ // Reasoning end signals #2/#3: the turn flush fires from both the
4506
+ // `message.updated` finish and `session.idle`. Idempotent — a no-op if a
4507
+ // non-reasoning part already ended reasoning (signal #1).
4508
+ this.endReasoning(key, session);
4509
+ // Flush the CHILD (sub-agent) accumulator first (it streamed earlier than
4510
+ // the parent's closing text): clear its debounce and emit any unsent
4511
+ // marked tail before the reset below would drop it. The child idle event
4512
+ // routes here too (lineage), so a finished sub-agent flushes promptly.
4513
+ if (session.childOutputTimer) {
4514
+ clearTimeout(session.childOutputTimer);
4515
+ session.childOutputTimer = null;
4516
+ }
4517
+ this.emitChildResponseTail(key, session);
4518
+ this.emitResponseTail(key, session, isFinal);
4519
+ session.currentResponseText = '';
4520
+ session.lastEmittedLength = 0;
4521
+ session.isResponseCompactionSummary = false;
4522
+ session.childResponseText = '';
4523
+ session.childLastEmittedLength = 0;
4524
+ session.partTypes.clear();
4525
+ }
4526
+ /**
4527
+ * @description Defensive close of the dedicated sub-agent status when the
4528
+ * PARENT's own turn ends (its assistant message finishes, or its session goes
4529
+ * idle) so a dangling "working" indicator can't outlive the turn if the
4530
+ * terminal `task` part was missed. Strictly parent-only: a CHILD (sub-agent)
4531
+ * message finishing / going idle must NOT close it — the delegation is still
4532
+ * in flight (its child events route here via lineage). The bot no-ops the
4533
+ * close when nothing is open.
4534
+ */
4535
+ closeSubagentStatusOnParentTurnEnd(key) {
4536
+ this.emit('subagentStatus', key, { active: false, title: null });
4537
+ }
4538
+ }
4539
+ exports.OpenCodeAdapter = OpenCodeAdapter;
4540
+ //# sourceMappingURL=openCodeAdapter.js.map