toolroll 0.5.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 (626) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1113 -0
  3. package/THIRD_PARTY_NOTICES.md +449 -0
  4. package/dist/accent-colors.d.ts +48 -0
  5. package/dist/accent-colors.js +122 -0
  6. package/dist/action-ledger.d.ts +26 -0
  7. package/dist/action-ledger.js +75 -0
  8. package/dist/agent-fence.d.ts +42 -0
  9. package/dist/agent-fence.js +183 -0
  10. package/dist/agentconfig.d.ts +216 -0
  11. package/dist/agentconfig.js +454 -0
  12. package/dist/api-tokens.d.ts +16 -0
  13. package/dist/api-tokens.js +27 -0
  14. package/dist/approval-policy.d.ts +69 -0
  15. package/dist/approval-policy.js +137 -0
  16. package/dist/approval-rules-ui.d.ts +25 -0
  17. package/dist/approval-rules-ui.js +39 -0
  18. package/dist/assignment-adapters.d.ts +180 -0
  19. package/dist/assignment-adapters.js +239 -0
  20. package/dist/assignment-brief.d.ts +71 -0
  21. package/dist/assignment-brief.js +129 -0
  22. package/dist/assignment-delivery.d.ts +56 -0
  23. package/dist/assignment-delivery.js +160 -0
  24. package/dist/assignment-presentation.d.ts +26 -0
  25. package/dist/assignment-presentation.js +80 -0
  26. package/dist/assignment-status.d.ts +62 -0
  27. package/dist/assignment-status.js +152 -0
  28. package/dist/assignment-ui.d.ts +71 -0
  29. package/dist/assignment-ui.js +103 -0
  30. package/dist/assignment.d.ts +222 -0
  31. package/dist/assignment.js +399 -0
  32. package/dist/attest.d.ts +56 -0
  33. package/dist/attest.js +153 -0
  34. package/dist/backend.d.ts +97 -0
  35. package/dist/backend.js +166 -0
  36. package/dist/backup-ui.d.ts +22 -0
  37. package/dist/backup-ui.js +59 -0
  38. package/dist/backup.d.ts +84 -0
  39. package/dist/backup.js +421 -0
  40. package/dist/beads.d.ts +34 -0
  41. package/dist/beads.js +135 -0
  42. package/dist/bin.d.ts +2 -0
  43. package/dist/bin.js +22 -0
  44. package/dist/board.d.ts +150 -0
  45. package/dist/board.js +210 -0
  46. package/dist/boot-identity.d.ts +63 -0
  47. package/dist/boot-identity.js +99 -0
  48. package/dist/browser/THIRD_PARTY_NOTICES.txt +2295 -0
  49. package/dist/browser/workspace.css +4 -0
  50. package/dist/browser/workspace.js +225 -0
  51. package/dist/browser-crew.d.ts +11 -0
  52. package/dist/browser-crew.js +50 -0
  53. package/dist/browser-shell.d.ts +8 -0
  54. package/dist/browser-shell.js +60 -0
  55. package/dist/browser-workspace.d.ts +780 -0
  56. package/dist/browser-workspace.js +47 -0
  57. package/dist/budget-alerts.d.ts +19 -0
  58. package/dist/budget-alerts.js +46 -0
  59. package/dist/builder.d.ts +370 -0
  60. package/dist/builder.js +3262 -0
  61. package/dist/capscan.d.ts +35 -0
  62. package/dist/capscan.js +113 -0
  63. package/dist/chat-acceptance.d.ts +71 -0
  64. package/dist/chat-acceptance.js +120 -0
  65. package/dist/chat-actions.d.ts +265 -0
  66. package/dist/chat-actions.js +1273 -0
  67. package/dist/chat-channel.d.ts +109 -0
  68. package/dist/chat-channel.js +652 -0
  69. package/dist/chat-continuity.d.ts +32 -0
  70. package/dist/chat-continuity.js +317 -0
  71. package/dist/chat-controls.d.ts +113 -0
  72. package/dist/chat-controls.js +53 -0
  73. package/dist/chat-delivery-state.d.ts +159 -0
  74. package/dist/chat-delivery-state.js +309 -0
  75. package/dist/chat-delivery.d.ts +38 -0
  76. package/dist/chat-delivery.js +693 -0
  77. package/dist/chat-display.d.ts +1 -0
  78. package/dist/chat-display.js +18 -0
  79. package/dist/chat-evidence.d.ts +123 -0
  80. package/dist/chat-evidence.js +207 -0
  81. package/dist/chat-flow.d.ts +44 -0
  82. package/dist/chat-flow.js +215 -0
  83. package/dist/chat-inbox.d.ts +57 -0
  84. package/dist/chat-inbox.js +123 -0
  85. package/dist/chat-polish.d.ts +16 -0
  86. package/dist/chat-polish.js +135 -0
  87. package/dist/chat-review.d.ts +46 -0
  88. package/dist/chat-review.js +98 -0
  89. package/dist/chat-rooms.d.ts +123 -0
  90. package/dist/chat-rooms.js +216 -0
  91. package/dist/chat-task-actions.d.ts +44 -0
  92. package/dist/chat-task-actions.js +79 -0
  93. package/dist/check-progress.d.ts +51 -0
  94. package/dist/check-progress.js +290 -0
  95. package/dist/child-database.d.ts +12 -0
  96. package/dist/child-database.js +30 -0
  97. package/dist/claim.d.ts +688 -0
  98. package/dist/claim.js +1740 -0
  99. package/dist/cli.d.ts +137 -0
  100. package/dist/cli.js +1461 -0
  101. package/dist/codex-limits.d.ts +15 -0
  102. package/dist/codex-limits.js +145 -0
  103. package/dist/coding-context.d.ts +55 -0
  104. package/dist/coding-context.js +92 -0
  105. package/dist/coding-handoff.d.ts +57 -0
  106. package/dist/coding-handoff.js +277 -0
  107. package/dist/coding-provider.d.ts +72 -0
  108. package/dist/coding-provider.js +424 -0
  109. package/dist/coding-shipping-ui.d.ts +4 -0
  110. package/dist/coding-shipping-ui.js +12 -0
  111. package/dist/coding-types.d.ts +62 -0
  112. package/dist/coding-types.js +1 -0
  113. package/dist/coding-ui.d.ts +17 -0
  114. package/dist/coding-ui.js +364 -0
  115. package/dist/coding-update.d.ts +15 -0
  116. package/dist/coding-update.js +149 -0
  117. package/dist/coding-workspace.d.ts +124 -0
  118. package/dist/coding-workspace.js +768 -0
  119. package/dist/container-state.d.ts +5 -0
  120. package/dist/container-state.js +62 -0
  121. package/dist/containment.d.ts +197 -0
  122. package/dist/containment.js +559 -0
  123. package/dist/contest.d.ts +318 -0
  124. package/dist/contest.js +753 -0
  125. package/dist/control-setup.d.ts +35 -0
  126. package/dist/control-setup.js +40 -0
  127. package/dist/control-ui.d.ts +29 -0
  128. package/dist/control-ui.js +53 -0
  129. package/dist/controller-service.d.ts +1 -0
  130. package/dist/controller-service.js +19 -0
  131. package/dist/controller-supervisor.d.ts +30 -0
  132. package/dist/controller-supervisor.js +104 -0
  133. package/dist/converse.d.ts +304 -0
  134. package/dist/converse.js +849 -0
  135. package/dist/coordinator-proposals.d.ts +29 -0
  136. package/dist/coordinator-proposals.js +137 -0
  137. package/dist/coordinator.d.ts +186 -0
  138. package/dist/coordinator.js +447 -0
  139. package/dist/credentials-ui.d.ts +25 -0
  140. package/dist/credentials-ui.js +48 -0
  141. package/dist/daemon.d.ts +209 -0
  142. package/dist/daemon.js +604 -0
  143. package/dist/decision.d.ts +120 -0
  144. package/dist/decision.js +388 -0
  145. package/dist/demo.d.ts +55 -0
  146. package/dist/demo.js +992 -0
  147. package/dist/desktop-access.d.ts +28 -0
  148. package/dist/desktop-access.js +88 -0
  149. package/dist/desktop-bundle.d.ts +16 -0
  150. package/dist/desktop-bundle.js +74 -0
  151. package/dist/desktop-host.d.ts +54 -0
  152. package/dist/desktop-host.js +508 -0
  153. package/dist/desktop-update-gate.d.ts +12 -0
  154. package/dist/desktop-update-gate.js +91 -0
  155. package/dist/desktop-update-recovery.d.ts +14 -0
  156. package/dist/desktop-update-recovery.js +243 -0
  157. package/dist/desktop-update.d.ts +110 -0
  158. package/dist/desktop-update.js +740 -0
  159. package/dist/discord-api.d.ts +20 -0
  160. package/dist/discord-api.js +138 -0
  161. package/dist/discord-chat.d.ts +16 -0
  162. package/dist/discord-chat.js +381 -0
  163. package/dist/discord-settings.d.ts +7 -0
  164. package/dist/discord-settings.js +75 -0
  165. package/dist/discord.d.ts +10 -0
  166. package/dist/discord.js +169 -0
  167. package/dist/discover.d.ts +75 -0
  168. package/dist/discover.js +150 -0
  169. package/dist/dispatch.d.ts +100 -0
  170. package/dist/dispatch.js +311 -0
  171. package/dist/dispose.d.ts +161 -0
  172. package/dist/dispose.js +803 -0
  173. package/dist/email-settings.d.ts +34 -0
  174. package/dist/email-settings.js +50 -0
  175. package/dist/envelope.d.ts +47 -0
  176. package/dist/envelope.js +105 -0
  177. package/dist/evidence-pack.d.ts +185 -0
  178. package/dist/evidence-pack.js +258 -0
  179. package/dist/evidence.d.ts +345 -0
  180. package/dist/evidence.js +782 -0
  181. package/dist/exec.d.ts +285 -0
  182. package/dist/exec.js +1841 -0
  183. package/dist/exhaustion.d.ts +85 -0
  184. package/dist/exhaustion.js +141 -0
  185. package/dist/export-ui.d.ts +4 -0
  186. package/dist/export-ui.js +17 -0
  187. package/dist/export.d.ts +48 -0
  188. package/dist/export.js +305 -0
  189. package/dist/flow-actions.d.ts +77 -0
  190. package/dist/flow-actions.js +203 -0
  191. package/dist/flow-code.d.ts +50 -0
  192. package/dist/flow-code.js +159 -0
  193. package/dist/flow-draft.d.ts +38 -0
  194. package/dist/flow-draft.js +80 -0
  195. package/dist/flow-engine.d.ts +66 -0
  196. package/dist/flow-engine.js +416 -0
  197. package/dist/flow-insights.d.ts +95 -0
  198. package/dist/flow-insights.js +149 -0
  199. package/dist/flow-live.d.ts +24 -0
  200. package/dist/flow-live.js +150 -0
  201. package/dist/flow-people.d.ts +24 -0
  202. package/dist/flow-people.js +91 -0
  203. package/dist/flow-replies.d.ts +48 -0
  204. package/dist/flow-replies.js +113 -0
  205. package/dist/flow-scripts.d.ts +38 -0
  206. package/dist/flow-scripts.js +73 -0
  207. package/dist/flow-secrets.d.ts +13 -0
  208. package/dist/flow-secrets.js +45 -0
  209. package/dist/flow-sort.d.ts +82 -0
  210. package/dist/flow-sort.js +153 -0
  211. package/dist/flow-steps.d.ts +40 -0
  212. package/dist/flow-steps.js +408 -0
  213. package/dist/flow-triggers.d.ts +244 -0
  214. package/dist/flow-triggers.js +959 -0
  215. package/dist/flows-ui.d.ts +20 -0
  216. package/dist/flows-ui.js +175 -0
  217. package/dist/flows.d.ts +229 -0
  218. package/dist/flows.js +968 -0
  219. package/dist/fonts.d.ts +19 -0
  220. package/dist/fonts.js +19 -0
  221. package/dist/gaps.d.ts +35 -0
  222. package/dist/gaps.js +101 -0
  223. package/dist/gate-failure.d.ts +40 -0
  224. package/dist/gate-failure.js +66 -0
  225. package/dist/git.d.ts +54 -0
  226. package/dist/git.js +94 -0
  227. package/dist/google-mail.d.ts +58 -0
  228. package/dist/google-mail.js +151 -0
  229. package/dist/grant.d.ts +136 -0
  230. package/dist/grant.js +238 -0
  231. package/dist/graph.d.ts +164 -0
  232. package/dist/graph.js +383 -0
  233. package/dist/guides.d.ts +22 -0
  234. package/dist/guides.js +363 -0
  235. package/dist/held.d.ts +150 -0
  236. package/dist/held.js +799 -0
  237. package/dist/invoke.d.ts +112 -0
  238. package/dist/invoke.js +780 -0
  239. package/dist/issues.d.ts +41 -0
  240. package/dist/issues.js +131 -0
  241. package/dist/job-object-helper.ps1 +252 -0
  242. package/dist/jsonl-discriminants.d.ts +11 -0
  243. package/dist/jsonl-discriminants.js +93 -0
  244. package/dist/keys.d.ts +104 -0
  245. package/dist/keys.js +229 -0
  246. package/dist/kits-ui.d.ts +17 -0
  247. package/dist/kits-ui.js +46 -0
  248. package/dist/kits.d.ts +86 -0
  249. package/dist/kits.js +215 -0
  250. package/dist/knowledge-cli.d.ts +101 -0
  251. package/dist/knowledge-cli.js +92 -0
  252. package/dist/knowledge-ui.d.ts +24 -0
  253. package/dist/knowledge-ui.js +40 -0
  254. package/dist/lead-context.d.ts +8 -0
  255. package/dist/lead-context.js +56 -0
  256. package/dist/lead-follow.d.ts +22 -0
  257. package/dist/lead-follow.js +167 -0
  258. package/dist/lead-status.d.ts +75 -0
  259. package/dist/lead-status.js +285 -0
  260. package/dist/ledger-chain.d.ts +46 -0
  261. package/dist/ledger-chain.js +187 -0
  262. package/dist/ledger-csv.d.ts +4 -0
  263. package/dist/ledger-csv.js +11 -0
  264. package/dist/ledger-view.d.ts +24 -0
  265. package/dist/ledger-view.js +58 -0
  266. package/dist/limits-ui.d.ts +26 -0
  267. package/dist/limits-ui.js +67 -0
  268. package/dist/link.d.ts +72 -0
  269. package/dist/link.js +217 -0
  270. package/dist/live.d.ts +96 -0
  271. package/dist/live.js +366 -0
  272. package/dist/liveness.d.ts +30 -0
  273. package/dist/liveness.js +42 -0
  274. package/dist/log.d.ts +7 -0
  275. package/dist/log.js +25 -0
  276. package/dist/mailbox.d.ts +57 -0
  277. package/dist/mailbox.js +150 -0
  278. package/dist/maintenance.d.ts +11 -0
  279. package/dist/maintenance.js +35 -0
  280. package/dist/mate-cli.d.ts +52 -0
  281. package/dist/mate-cli.js +345 -0
  282. package/dist/mate-contract.d.ts +10 -0
  283. package/dist/mate-contract.js +30 -0
  284. package/dist/mate-doors.d.ts +69 -0
  285. package/dist/mate-doors.js +548 -0
  286. package/dist/mate-progress.d.ts +29 -0
  287. package/dist/mate-progress.js +121 -0
  288. package/dist/mate-tools.d.ts +94 -0
  289. package/dist/mate-tools.js +1878 -0
  290. package/dist/mate.d.ts +92 -0
  291. package/dist/mate.js +435 -0
  292. package/dist/mcp-connect.d.ts +103 -0
  293. package/dist/mcp-connect.js +252 -0
  294. package/dist/mcp.d.ts +27 -0
  295. package/dist/mcp.js +651 -0
  296. package/dist/memory-cli.d.ts +466 -0
  297. package/dist/memory-cli.js +111 -0
  298. package/dist/memory-pass.d.ts +160 -0
  299. package/dist/memory-pass.js +409 -0
  300. package/dist/metrics.d.ts +3 -0
  301. package/dist/metrics.js +56 -0
  302. package/dist/mobile-viewport.d.ts +3 -0
  303. package/dist/mobile-viewport.js +37 -0
  304. package/dist/model-catalog.d.ts +118 -0
  305. package/dist/model-catalog.js +376 -0
  306. package/dist/models-cli.d.ts +102 -0
  307. package/dist/models-cli.js +66 -0
  308. package/dist/models-ui.d.ts +34 -0
  309. package/dist/models-ui.js +68 -0
  310. package/dist/modes.d.ts +80 -0
  311. package/dist/modes.js +182 -0
  312. package/dist/monitoring-settings.d.ts +44 -0
  313. package/dist/monitoring-settings.js +173 -0
  314. package/dist/monitoring-ui.d.ts +20 -0
  315. package/dist/monitoring-ui.js +51 -0
  316. package/dist/monitoring.d.ts +34 -0
  317. package/dist/monitoring.js +221 -0
  318. package/dist/names.d.ts +67 -0
  319. package/dist/names.js +102 -0
  320. package/dist/observations.d.ts +40 -0
  321. package/dist/observations.js +211 -0
  322. package/dist/oidc.d.ts +71 -0
  323. package/dist/oidc.js +182 -0
  324. package/dist/onboard.d.ts +108 -0
  325. package/dist/onboard.js +325 -0
  326. package/dist/openrouter-models.d.ts +30 -0
  327. package/dist/openrouter-models.js +120 -0
  328. package/dist/operate.d.ts +118 -0
  329. package/dist/operate.js +11321 -0
  330. package/dist/peek-cli.d.ts +97 -0
  331. package/dist/peek-cli.js +215 -0
  332. package/dist/peek.d.ts +134 -0
  333. package/dist/peek.js +578 -0
  334. package/dist/phase-routing.d.ts +307 -0
  335. package/dist/phase-routing.js +658 -0
  336. package/dist/plan-auto.d.ts +14 -0
  337. package/dist/plan-auto.js +92 -0
  338. package/dist/plan.d.ts +186 -0
  339. package/dist/plan.js +401 -0
  340. package/dist/planner-source.d.ts +251 -0
  341. package/dist/planner-source.js +460 -0
  342. package/dist/planner.d.ts +75 -0
  343. package/dist/planner.js +992 -0
  344. package/dist/policy-ui.d.ts +20 -0
  345. package/dist/policy-ui.js +41 -0
  346. package/dist/policy.d.ts +111 -0
  347. package/dist/policy.js +231 -0
  348. package/dist/prepared-evidence.d.ts +16 -0
  349. package/dist/prepared-evidence.js +96 -0
  350. package/dist/pricing.d.ts +45 -0
  351. package/dist/pricing.js +77 -0
  352. package/dist/principal.d.ts +42 -0
  353. package/dist/principal.js +82 -0
  354. package/dist/probe.d.ts +46 -0
  355. package/dist/probe.js +79 -0
  356. package/dist/process-custody.d.ts +17 -0
  357. package/dist/process-custody.js +91 -0
  358. package/dist/process-liveness.d.ts +10 -0
  359. package/dist/process-liveness.js +81 -0
  360. package/dist/process-recovery-anchor.d.ts +75 -0
  361. package/dist/process-recovery-anchor.js +188 -0
  362. package/dist/process-recovery-coalition.d.ts +25 -0
  363. package/dist/process-recovery-coalition.js +105 -0
  364. package/dist/process-recovery-eligibility.d.ts +178 -0
  365. package/dist/process-recovery-eligibility.js +170 -0
  366. package/dist/process-recovery-native.d.ts +172 -0
  367. package/dist/process-recovery-native.js +592 -0
  368. package/dist/process-recovery-provenance.d.ts +163 -0
  369. package/dist/process-recovery-provenance.js +292 -0
  370. package/dist/process-recovery-services.d.ts +56 -0
  371. package/dist/process-recovery-services.js +191 -0
  372. package/dist/process-recovery-settlement.d.ts +32 -0
  373. package/dist/process-recovery-settlement.js +132 -0
  374. package/dist/process-recovery.d.ts +41 -0
  375. package/dist/process-recovery.js +91 -0
  376. package/dist/process-tree.d.ts +41 -0
  377. package/dist/process-tree.js +264 -0
  378. package/dist/project-access.d.ts +14 -0
  379. package/dist/project-access.js +34 -0
  380. package/dist/project-cli.d.ts +18 -0
  381. package/dist/project-cli.js +104 -0
  382. package/dist/project-delete-ui.d.ts +22 -0
  383. package/dist/project-delete-ui.js +48 -0
  384. package/dist/project-delete.d.ts +55 -0
  385. package/dist/project-delete.js +413 -0
  386. package/dist/project-knowledge.d.ts +111 -0
  387. package/dist/project-knowledge.js +241 -0
  388. package/dist/project-learning.d.ts +100 -0
  389. package/dist/project-learning.js +438 -0
  390. package/dist/project-memory.d.ts +81 -0
  391. package/dist/project-memory.js +264 -0
  392. package/dist/project-skills.d.ts +152 -0
  393. package/dist/project-skills.js +660 -0
  394. package/dist/project-tools.d.ts +219 -0
  395. package/dist/project-tools.js +796 -0
  396. package/dist/project.d.ts +61 -0
  397. package/dist/project.js +125 -0
  398. package/dist/prompt.d.ts +22 -0
  399. package/dist/prompt.js +72 -0
  400. package/dist/proof.d.ts +513 -0
  401. package/dist/proof.js +1140 -0
  402. package/dist/proposal.d.ts +82 -0
  403. package/dist/proposal.js +210 -0
  404. package/dist/provider-connection.d.ts +22 -0
  405. package/dist/provider-connection.js +98 -0
  406. package/dist/provider-limits.d.ts +35 -0
  407. package/dist/provider-limits.js +95 -0
  408. package/dist/provider.d.ts +279 -0
  409. package/dist/provider.js +935 -0
  410. package/dist/publish.d.ts +107 -0
  411. package/dist/publish.js +650 -0
  412. package/dist/pulls.d.ts +118 -0
  413. package/dist/pulls.js +240 -0
  414. package/dist/push.d.ts +95 -0
  415. package/dist/push.js +353 -0
  416. package/dist/quality.d.ts +8 -0
  417. package/dist/quality.js +6 -0
  418. package/dist/recipe-ui.d.ts +18 -0
  419. package/dist/recipe-ui.js +152 -0
  420. package/dist/recipes.d.ts +81 -0
  421. package/dist/recipes.js +332 -0
  422. package/dist/remote.d.ts +67 -0
  423. package/dist/remote.js +122 -0
  424. package/dist/render.d.ts +64 -0
  425. package/dist/render.js +587 -0
  426. package/dist/report-summary.d.ts +13 -0
  427. package/dist/report-summary.js +15 -0
  428. package/dist/repos.d.ts +65 -0
  429. package/dist/repos.js +277 -0
  430. package/dist/repository-context-ui.d.ts +2 -0
  431. package/dist/repository-context-ui.js +10 -0
  432. package/dist/repository-context.d.ts +67 -0
  433. package/dist/repository-context.js +376 -0
  434. package/dist/restart-certification.d.ts +134 -0
  435. package/dist/restart-certification.js +226 -0
  436. package/dist/result-actions.d.ts +28 -0
  437. package/dist/result-actions.js +270 -0
  438. package/dist/result-completion.d.ts +18 -0
  439. package/dist/result-completion.js +66 -0
  440. package/dist/result-review.d.ts +213 -0
  441. package/dist/result-review.js +430 -0
  442. package/dist/retention-ui.d.ts +12 -0
  443. package/dist/retention-ui.js +39 -0
  444. package/dist/retention.d.ts +72 -0
  445. package/dist/retention.js +288 -0
  446. package/dist/review-context.d.ts +243 -0
  447. package/dist/review-context.js +1043 -0
  448. package/dist/review-evidence.d.ts +33 -0
  449. package/dist/review-evidence.js +56 -0
  450. package/dist/reviewer.d.ts +97 -0
  451. package/dist/reviewer.js +202 -0
  452. package/dist/routine.d.ts +228 -0
  453. package/dist/routine.js +764 -0
  454. package/dist/runner.d.ts +266 -0
  455. package/dist/runner.js +399 -0
  456. package/dist/scan.d.ts +32 -0
  457. package/dist/scan.js +92 -0
  458. package/dist/scope.d.ts +695 -0
  459. package/dist/scope.js +1176 -0
  460. package/dist/scout-report.d.ts +42 -0
  461. package/dist/scout-report.js +108 -0
  462. package/dist/scout.d.ts +69 -0
  463. package/dist/scout.js +351 -0
  464. package/dist/serve.d.ts +313 -0
  465. package/dist/serve.js +21776 -0
  466. package/dist/session-brief.d.ts +8 -0
  467. package/dist/session-brief.js +24 -0
  468. package/dist/session-cli.d.ts +13 -0
  469. package/dist/session-cli.js +284 -0
  470. package/dist/session-contract.d.ts +183 -0
  471. package/dist/session-contract.js +122 -0
  472. package/dist/session-http.d.ts +19 -0
  473. package/dist/session-http.js +154 -0
  474. package/dist/session-server.d.ts +12 -0
  475. package/dist/session-server.js +37 -0
  476. package/dist/session-service.d.ts +22 -0
  477. package/dist/session-service.js +157 -0
  478. package/dist/setup-guide.d.ts +39 -0
  479. package/dist/setup-guide.js +93 -0
  480. package/dist/sign-in-guard.d.ts +45 -0
  481. package/dist/sign-in-guard.js +74 -0
  482. package/dist/skills-ui.d.ts +11 -0
  483. package/dist/skills-ui.js +58 -0
  484. package/dist/skills.d.ts +97 -0
  485. package/dist/skills.js +276 -0
  486. package/dist/slack-api.d.ts +55 -0
  487. package/dist/slack-api.js +229 -0
  488. package/dist/slack-chat.d.ts +28 -0
  489. package/dist/slack-chat.js +413 -0
  490. package/dist/slack-settings.d.ts +7 -0
  491. package/dist/slack-settings.js +75 -0
  492. package/dist/slack-state.d.ts +13 -0
  493. package/dist/slack-state.js +9 -0
  494. package/dist/slack.d.ts +13 -0
  495. package/dist/slack.js +167 -0
  496. package/dist/spend-ui.d.ts +28 -0
  497. package/dist/spend-ui.js +95 -0
  498. package/dist/spend.d.ts +142 -0
  499. package/dist/spend.js +313 -0
  500. package/dist/sqlite-runtime.d.ts +3 -0
  501. package/dist/sqlite-runtime.js +6 -0
  502. package/dist/sso-settings.d.ts +33 -0
  503. package/dist/sso-settings.js +103 -0
  504. package/dist/sso-ui.d.ts +22 -0
  505. package/dist/sso-ui.js +39 -0
  506. package/dist/storage.d.ts +15 -0
  507. package/dist/storage.js +84 -0
  508. package/dist/store.d.ts +7754 -0
  509. package/dist/store.js +23279 -0
  510. package/dist/structured-output.d.ts +58 -0
  511. package/dist/structured-output.js +103 -0
  512. package/dist/style-asset.d.ts +7 -0
  513. package/dist/style-asset.js +41 -0
  514. package/dist/subscription-chat.d.ts +46 -0
  515. package/dist/subscription-chat.js +275 -0
  516. package/dist/summary.d.ts +33 -0
  517. package/dist/summary.js +71 -0
  518. package/dist/supervisor.mjs +297 -0
  519. package/dist/surface.d.ts +63 -0
  520. package/dist/surface.js +352 -0
  521. package/dist/sync.d.ts +94 -0
  522. package/dist/sync.js +279 -0
  523. package/dist/task-composer.d.ts +13 -0
  524. package/dist/task-composer.js +52 -0
  525. package/dist/task-control.d.ts +165 -0
  526. package/dist/task-control.js +214 -0
  527. package/dist/task-outcome-cli.d.ts +10 -0
  528. package/dist/task-outcome-cli.js +92 -0
  529. package/dist/task-text.d.ts +30 -0
  530. package/dist/task-text.js +41 -0
  531. package/dist/team-cli.d.ts +15 -0
  532. package/dist/team-cli.js +618 -0
  533. package/dist/team-contract.d.ts +92 -0
  534. package/dist/team-contract.js +1 -0
  535. package/dist/team-http.d.ts +17 -0
  536. package/dist/team-http.js +144 -0
  537. package/dist/team-leads.d.ts +81 -0
  538. package/dist/team-leads.js +565 -0
  539. package/dist/team-runtime.d.ts +27 -0
  540. package/dist/team-runtime.js +264 -0
  541. package/dist/team-ui.d.ts +3 -0
  542. package/dist/team-ui.js +9 -0
  543. package/dist/team-updates.d.ts +8 -0
  544. package/dist/team-updates.js +57 -0
  545. package/dist/teammate-admin.d.ts +53 -0
  546. package/dist/teammate-admin.js +111 -0
  547. package/dist/teammate-desk.d.ts +60 -0
  548. package/dist/teammate-desk.js +154 -0
  549. package/dist/teammate-memory.d.ts +64 -0
  550. package/dist/teammate-memory.js +196 -0
  551. package/dist/teammate-question.d.ts +59 -0
  552. package/dist/teammate-question.js +175 -0
  553. package/dist/teammate-tools.d.ts +108 -0
  554. package/dist/teammate-tools.js +271 -0
  555. package/dist/teammate-week.d.ts +68 -0
  556. package/dist/teammate-week.js +132 -0
  557. package/dist/teammate-work.d.ts +76 -0
  558. package/dist/teammate-work.js +280 -0
  559. package/dist/teammates-ui.d.ts +20 -0
  560. package/dist/teammates-ui.js +166 -0
  561. package/dist/teammates.d.ts +180 -0
  562. package/dist/teammates.js +253 -0
  563. package/dist/teams-api.d.ts +33 -0
  564. package/dist/teams-api.js +204 -0
  565. package/dist/teams-chat.d.ts +26 -0
  566. package/dist/teams-chat.js +215 -0
  567. package/dist/teams-settings.d.ts +12 -0
  568. package/dist/teams-settings.js +57 -0
  569. package/dist/teams.d.ts +21 -0
  570. package/dist/teams.js +124 -0
  571. package/dist/telegram-flow.d.ts +49 -0
  572. package/dist/telegram-flow.js +110 -0
  573. package/dist/telegram-mate.d.ts +137 -0
  574. package/dist/telegram-mate.js +707 -0
  575. package/dist/telegram-progress.d.ts +14 -0
  576. package/dist/telegram-progress.js +129 -0
  577. package/dist/telegram-settings.d.ts +9 -0
  578. package/dist/telegram-settings.js +36 -0
  579. package/dist/telegram-status.d.ts +75 -0
  580. package/dist/telegram-status.js +265 -0
  581. package/dist/telegram-team.d.ts +85 -0
  582. package/dist/telegram-team.js +359 -0
  583. package/dist/telegram.d.ts +230 -0
  584. package/dist/telegram.js +1931 -0
  585. package/dist/templates.d.ts +65 -0
  586. package/dist/templates.js +118 -0
  587. package/dist/tool-launcher.d.ts +1 -0
  588. package/dist/tool-launcher.js +101 -0
  589. package/dist/tools-ui.d.ts +30 -0
  590. package/dist/tools-ui.js +40 -0
  591. package/dist/transitions-recipes.d.ts +1 -0
  592. package/dist/transitions-recipes.js +203 -0
  593. package/dist/tree-proof.d.ts +34 -0
  594. package/dist/tree-proof.js +58 -0
  595. package/dist/verification-evidence.d.ts +44 -0
  596. package/dist/verification-evidence.js +238 -0
  597. package/dist/version.d.ts +2 -0
  598. package/dist/version.js +10 -0
  599. package/dist/webhooks.d.ts +91 -0
  600. package/dist/webhooks.js +283 -0
  601. package/dist/work-index.d.ts +68 -0
  602. package/dist/work-index.js +384 -0
  603. package/dist/work-summary.d.ts +57 -0
  604. package/dist/work-summary.js +116 -0
  605. package/dist/workspace-motion.d.ts +4 -0
  606. package/dist/workspace-motion.js +133 -0
  607. package/dist/workspace-revision.d.ts +19 -0
  608. package/dist/workspace-revision.js +92 -0
  609. package/dist/workspace-ui.d.ts +204 -0
  610. package/dist/workspace-ui.js +411 -0
  611. package/dist/worktree-notices.d.ts +13 -0
  612. package/dist/worktree-notices.js +17 -0
  613. package/dist/worktree.d.ts +249 -0
  614. package/dist/worktree.js +720 -0
  615. package/package.json +122 -0
  616. package/scripts/canary-assertions.mjs +42 -0
  617. package/scripts/crash-canary.mjs +253 -0
  618. package/scripts/fixtures/crash-process.mjs +74 -0
  619. package/scripts/fixtures/pilot-scenarios.mjs +85 -0
  620. package/scripts/fixtures/restart-service.mjs +27 -0
  621. package/scripts/launchd-certification.mjs +143 -0
  622. package/scripts/pilot.mjs +121 -0
  623. package/scripts/proof-preflight.mjs +140 -0
  624. package/scripts/provider-canary.mjs +379 -0
  625. package/scripts/recovery-canary.mjs +135 -0
  626. package/scripts/restart-certification.mjs +98 -0
@@ -0,0 +1,1931 @@
1
+ /**
2
+ * The Telegram bridge: decisions out, answers back, zero tokens spent.
3
+ *
4
+ * This is channel plumbing — no LLM is anywhere in this path, and everything
5
+ * it renders is deterministic. The security model, in one breath: a chat is
6
+ * not a person, so pairing is a local authenticated act that binds one
7
+ * private chat AND one immutable user id to one approver generation; a
8
+ * button is not a command, so callback_data carries only an opaque one-time
9
+ * token whose meaning lives in this database where a stolen bot token
10
+ * cannot read it; and an answer lands in the same transaction that proves
11
+ * the binding is still live and consumes the token — or it does not land.
12
+ *
13
+ * What a stolen bot token CAN do is stated rather than wished away: read
14
+ * the decision text this installation chose to send through Telegram,
15
+ * repaint the bot's keyboards with deceptive labels, and race our poll for
16
+ * updates. It cannot mint an action token, answer as the operator, or make
17
+ * `answered_by` say anything the pairing did not authorize. Rotating the
18
+ * BotFather token plus `bridge telegram unpair` is the recovery, and both
19
+ * are one command.
20
+ */
21
+ import { applyTelegramQuestionReply, applyTelegramQuestionTap, openQuestionOf, telegramQuestionButtons } from "./teammate-question.js";
22
+ import { messageTeammate } from "./teammate-desk.js";
23
+ import { acceptanceEvidenceText } from "./chat-acceptance.js";
24
+ import { verifyApproverStanding } from "./principal.js";
25
+ import { resultImageFileName, resultTaskLabel, verifyResultImage } from "./chat-evidence.js";
26
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
27
+ import { chmodSync, readFileSync, rmSync, writeFileSync } from "node:fs";
28
+ import { join } from "node:path";
29
+ import { validateNote } from "./decision.js";
30
+ import { isLifecycleNotification, isTelegramProgressNotification, TELEGRAM_HOLD_REASONS } from "./store.js";
31
+ import { telegramProgressCard } from "./telegram-progress.js";
32
+ import { applyTeamInbound, deliverTeamChats, teamCommand } from "./telegram-team.js";
33
+ import { applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowDecisionAt } from "./telegram-flow.js";
34
+ import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
35
+ import { focusContextFor, taskInCeiling } from "./chat-channel.js";
36
+ import { phoneCommand, phoneStatus, phoneTaskView, PHONE_CONSOLE_FOOTER, PHONE_HELP, notificationIdentity, phoneTaskChoices, resolvePhoneTask, phoneFocusText, phoneTaskListText, phoneText, PHONE_NO_MATCH, PHONE_BACK_TO_LEAD } from "./telegram-status.js";
37
+ import { MATE_MESSAGE_MAX_CHARS } from "./mate.js";
38
+ import { envValue } from "./names.js";
39
+ import { applyProposalTap, processTelegramConversations, phoneLinkButton, replyContextFor, telegramConversationRepos, telegramRequestId, tooLongText, whichTaskText, } from "./telegram-mate.js";
40
+ /** The environment name — and therefore the name the builder strips from agents (both names; see names.ts). */
41
+ export { TELEGRAM_TOKEN_ENV as TOKEN_ENV, TELEGRAM_TOKEN_ENVS as TOKEN_ENVS } from "./names.js";
42
+ /** BotFather's shape: numeric bot id, colon, secret. */
43
+ const TOKEN_SHAPE = /^(\d+):[A-Za-z0-9_-]{20,}$/;
44
+ export const PAIRING_TTL_MS = 10 * 60_000;
45
+ export const CONFIRM_TTL_MS = 10 * 60_000;
46
+ export const BRIDGE_LEASE_MS = 2 * 60_000;
47
+ export const DELIVERY_CLAIM_MS = 2 * 60_000;
48
+ /** Telegram's own message ceiling, with room for our part headers. */
49
+ const PART_CAP = 3_900;
50
+ /** Pages of getUpdates one pass will read before reporting a backlog. */
51
+ const PAGE_BUDGET = 10;
52
+ // ---- pushed updates (v98) ----------------------------------------------------
53
+ /** Where Telegram pushes this bot's updates: under the public hooks address (Tailscale Funnel covers /hooks). */
54
+ export const TELEGRAM_HOOK_PATH = "/hooks/telegram";
55
+ const HOOK_SECRET_FILE = "telegram-hook-secret";
56
+ /** The secret Telegram sends with every push (its header), kept beside the database; made once when asked to. */
57
+ export function telegramHookSecret(dir, make = false) {
58
+ const file = join(dir, HOOK_SECRET_FILE);
59
+ try {
60
+ const saved = readFileSync(file, "utf8").trim();
61
+ if (/^[A-Za-z0-9_-]{32,256}$/.test(saved))
62
+ return saved;
63
+ }
64
+ catch { /* none yet */ }
65
+ if (!make)
66
+ return null;
67
+ const made = randomBytes(32).toString("base64url");
68
+ writeFileSync(file, made, { mode: 0o600 });
69
+ chmodSync(file, 0o600);
70
+ return made;
71
+ }
72
+ /** The address Telegram pushes to: the public hooks address (https only) and /hooks/telegram; null without one. */
73
+ export function telegramPushUrl(hooksBase) {
74
+ if (hooksBase === null)
75
+ return null;
76
+ try {
77
+ const url = new URL(hooksBase);
78
+ return url.protocol === "https:" ? `${hooksBase.replace(/\/+$/, "")}${TELEGRAM_HOOK_PATH}` : null;
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ /** A push is Telegram's when its secret header matches ours (compared in constant time). */
85
+ export function pushedByTelegram(header, secret) {
86
+ if (secret === null || typeof header !== "string")
87
+ return false;
88
+ const given = Buffer.from(header), wanted = Buffer.from(secret);
89
+ return given.length === wanted.length && timingSafeEqual(given, wanted);
90
+ }
91
+ /** Keep one pushed update for the bridge: a JSON object with a positive integer update_id. */
92
+ export function keepPushedUpdate(store, botId, body, now) {
93
+ let update;
94
+ try {
95
+ update = JSON.parse(body.toString("utf8"));
96
+ }
97
+ catch {
98
+ return { ok: false };
99
+ }
100
+ const id = update?.update_id;
101
+ if (update === null || typeof update !== "object" || typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0)
102
+ return { ok: false };
103
+ return { ok: true, kept: store.queueTelegramUpdate(botId, id, JSON.stringify(update), now) };
104
+ }
105
+ /**
106
+ * Environment wins, the credential file beside the database otherwise.
107
+ * The file is how the CLI and the web settings card set it (0600, owner
108
+ * only); the env var is how people who already run keychain tooling keep
109
+ * it out of files entirely.
110
+ */
111
+ export function loadBotToken(env, file) {
112
+ const fromEnv = envValue(env, "TELEGRAM_TOKEN");
113
+ if (fromEnv !== undefined && fromEnv.trim() !== "") {
114
+ const parsed = TOKEN_SHAPE.exec(fromEnv.trim());
115
+ return parsed === null ? null : { token: fromEnv.trim(), botId: parsed[1], source: "env" };
116
+ }
117
+ let raw;
118
+ try {
119
+ raw = readFileSync(file, "utf8").trim();
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ const parsed = TOKEN_SHAPE.exec(raw);
125
+ return parsed === null ? null : { token: raw, botId: parsed[1], source: "file" };
126
+ }
127
+ /** Write the credential file, owner-only. Refuses a string that is not a bot token. */
128
+ export function saveBotToken(file, token) {
129
+ if (!TOKEN_SHAPE.test(token.trim())) {
130
+ return {
131
+ ok: false,
132
+ message: "that does not look like a bot token (expected <digits>:<secret>, from @BotFather)",
133
+ };
134
+ }
135
+ writeFileSync(file, `${token.trim()}\n`, { mode: 0o600 });
136
+ // writeFileSync applies the mode only on creation; an existing file keeps
137
+ // whatever it had, so the permission is asserted rather than assumed.
138
+ chmodSync(file, 0o600);
139
+ return { ok: true };
140
+ }
141
+ export function clearBotToken(file) {
142
+ try {
143
+ rmSync(file);
144
+ return true;
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ }
150
+ /** The last four characters are enough to recognize a token without holding it. */
151
+ export function redactToken(token) {
152
+ return `…${token.slice(-4)}`;
153
+ }
154
+ /** Scrub a token out of any text on its way to a log, an error, or a row. */
155
+ export function scrub(text, token) {
156
+ return token === "" ? text : text.split(token).join(redactToken(token));
157
+ }
158
+ export function createTransport(token, timeoutMs = 30_000) {
159
+ return async (method, params, signal, upload) => {
160
+ const controller = new AbortController();
161
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
162
+ const onAbort = () => controller.abort();
163
+ if (signal !== undefined) {
164
+ if (signal.aborted)
165
+ controller.abort();
166
+ else
167
+ signal.addEventListener("abort", onAbort, { once: true });
168
+ }
169
+ try {
170
+ // A JSON call stays byte for byte what it was. A multipart call lets
171
+ // fetch mint the boundary and the content-type: scalars ride as
172
+ // fields, objects (reply_parameters, reply_markup) as their JSON, and
173
+ // the verified bytes as one Blob under the file name given.
174
+ const request = upload === undefined
175
+ ? { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(params), signal: controller.signal }
176
+ : { method: "POST", body: multipartOf(params, upload), signal: controller.signal };
177
+ const response = await fetch(`https://api.telegram.org/bot${token}/${method}`, request);
178
+ const body = (await response.json());
179
+ // A missing or malformed acknowledgement does not prove a send failed.
180
+ // Only Telegram's explicit rejection is a definite failed delivery.
181
+ if (body === null || typeof body !== "object" || Array.isArray(body) || typeof body.ok !== "boolean" || (body.ok && !response.ok)) {
182
+ return { ok: false, description: "Telegram returned an invalid acknowledgement", uncertain: true };
183
+ }
184
+ return {
185
+ ok: body.ok === true,
186
+ result: body.result,
187
+ ...(body.parameters === undefined ? {} : { parameters: body.parameters }),
188
+ // Whatever Telegram said, the token must not be in what we keep.
189
+ ...(typeof body.description !== "string" ? {} : { description: scrub(body.description, token) }),
190
+ };
191
+ }
192
+ catch (error) {
193
+ return {
194
+ ok: false,
195
+ description: scrub(error instanceof Error ? error.message : String(error), token),
196
+ uncertain: true,
197
+ };
198
+ }
199
+ finally {
200
+ clearTimeout(timer);
201
+ signal?.removeEventListener("abort", onAbort);
202
+ }
203
+ };
204
+ }
205
+ /** The multipart body: every param a field (objects as JSON), the file last. Exported for the adapter's own test only. */
206
+ export function multipartOf(params, upload) {
207
+ const form = new FormData();
208
+ for (const [key, value] of Object.entries(params)) {
209
+ if (value === undefined || value === null)
210
+ continue;
211
+ form.append(key, typeof value === "object" ? JSON.stringify(value) : String(value));
212
+ }
213
+ form.append(upload.field, new Blob([new Uint8Array(upload.bytes)], { type: upload.contentType }), upload.fileName);
214
+ return form;
215
+ }
216
+ // ---- pairing ---------------------------------------------------------------
217
+ export function hashPairingCode(code) {
218
+ return createHash("sha256").update(code, "utf8").digest("hex");
219
+ }
220
+ /** 128 bits, hex — pasteable, and not guessable inside any code's lifetime. */
221
+ export function mintPairingCode() {
222
+ return randomBytes(16).toString("hex");
223
+ }
224
+ /**
225
+ * One bridge pass: claim and send what the outbox holds, then read one
226
+ * budgeted window of updates and apply each in its own transaction. Cron
227
+ * calls this; running it twice concurrently loses the lease race and does
228
+ * nothing, which is the design working.
229
+ */
230
+ export async function bridgePass(store, options) {
231
+ const clock = options.clock ?? (() => new Date());
232
+ const owner = options.owner ?? randomBytes(8).toString("hex");
233
+ const { botId, transport } = options;
234
+ const lease = store.acquireBridgeLease(botId, owner, BRIDGE_LEASE_MS, clock());
235
+ if (!lease.ok) {
236
+ return {
237
+ ok: false,
238
+ reason: "bridge-busy",
239
+ message: `another bridge holds the poll until ${lease.until} — one poller per bot, or taps get eaten`,
240
+ };
241
+ }
242
+ const report = { sent: 0, answered: 0, paired: 0, ignored: 0, backlog: false, problems: [] };
243
+ try {
244
+ if (options.deliver !== false) {
245
+ await deliverOutbox(store, botId, transport, owner, clock, report, options.readProjects, options.canDeliver, options.conversation?.phoneOrigin, options.conversation?.evidenceRoot);
246
+ await deliverTeam(store, botId, transport, clock, report, options.readProjects, options.canDeliver, options.conversation?.phoneOrigin);
247
+ }
248
+ // v98: updates Telegram pushed to the console apply first; asking for more only works while it isn't pushing.
249
+ const pushed = await drainInbox({ store, botId, transport, clock, report, readProjects: options.readProjects, conversation: options.conversation, projects: null }, owner, lease.generation);
250
+ if (pushed > 0)
251
+ report.pushed = pushed;
252
+ await drainUpdates(store, botId, transport, owner, lease.generation, lease.cursor, clock, report, 0, undefined, options.readProjects, options.conversation);
253
+ if (options.conversation !== undefined && options.readProjects !== undefined) {
254
+ // The queued turns, outside any transaction. A model turn can outlive
255
+ // the poll lease, so the lease is renewed under the same owner while
256
+ // they run — the follower's own fenced renewal, reused.
257
+ const renew = setInterval(() => { store.acquireBridgeLease(botId, owner, BRIDGE_LEASE_MS, clock()); }, 30_000);
258
+ renew.unref?.();
259
+ try {
260
+ await processConversations(store, botId, transport, owner, clock, report, options.readProjects, options.conversation);
261
+ }
262
+ finally {
263
+ clearInterval(renew);
264
+ }
265
+ }
266
+ }
267
+ finally {
268
+ // Handed back so the next cron firing is not told busy for the rest of
269
+ // this pass's TTL. A crash skips this and the lease expires instead —
270
+ // which is exactly what the TTL is for.
271
+ store.releaseBridgeLease(botId, owner, clock());
272
+ }
273
+ return { ok: true, report };
274
+ }
275
+ // ---- the follower ----------------------------------------------------------
276
+ /**
277
+ * The longest long poll the follower may ask for. `createTransport`'s HTTP
278
+ * timeout is 30s and must outlive the poll window, or the client would abort
279
+ * a poll Telegram is still honestly holding open.
280
+ */
281
+ export const MAX_POLL_SECONDS = 25;
282
+ /** Reconnect backoff: starts here, doubles per consecutive failure, capped. */
283
+ const FOLLOW_BACKOFF_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
284
+ /** A cycle that returned instantly with nothing is padded to this — a scripted or broken server must not spin the loop hot. */
285
+ const FOLLOW_IDLE_FLOOR_MS = 1_000;
286
+ /** v98: how often the bridge checks Telegram still pushes to us; how long pushes may keep failing before it asks
287
+ * for updates itself; and how long it asks before trying pushes again. */
288
+ const PUSH_CHECK_MS = 60_000, PUSH_GIVE_UP_MS = 5 * 60_000, PUSH_RETRY_MS = 15 * 60_000, PUSH_IDLE_MS = 2_000;
289
+ /**
290
+ * The follower (§M4): one actor that holds the poll lease and stays on the
291
+ * wire, so an answer tapped on a phone reaches the store in seconds, not at
292
+ * the next cron firing. `bridge telegram --follow` runs it standalone;
293
+ * watch embeds the same actor — the poll lease guarantees only one is
294
+ * live, and a cron pass overlapping it simply loses the lease race.
295
+ *
296
+ * Each cycle re-acquires the lease under the same owner — that is the
297
+ * fenced renewal: same generation while held, and if the lease lapsed
298
+ * mid-poll (a stall longer than the TTL), the re-acquire takes the next
299
+ * generation and the cursor rides it, so nothing this follower stamped
300
+ * with the old generation can move state afterwards. Transport failures
301
+ * back off exponentially and are counted, not hidden; cancellation aborts
302
+ * the in-flight long poll instead of waiting it out.
303
+ */
304
+ /** What Telegram lists when the person taps "/". */
305
+ export const TELEGRAM_COMMANDS = [
306
+ { command: "tasks", description: "Pick a task to talk about" },
307
+ { command: "status", description: "Recent work across your projects" },
308
+ { command: "lead", description: "Back to the lead" },
309
+ { command: "help", description: "What you can do here" },
310
+ ];
311
+ export async function followBridge(store, options) {
312
+ const clock = options.clock ?? (() => new Date());
313
+ const owner = options.owner ?? `follow-${randomBytes(8).toString("hex")}`;
314
+ const { botId, transport, signal } = options;
315
+ const pollSeconds = Math.max(1, Math.min(options.pollSeconds ?? MAX_POLL_SECONDS, MAX_POLL_SECONDS));
316
+ const wait = options.sleep ??
317
+ ((ms) => new Promise(resolve => {
318
+ const timer = setTimeout(finish, ms);
319
+ function finish() {
320
+ clearTimeout(timer);
321
+ signal.removeEventListener("abort", finish);
322
+ resolve();
323
+ }
324
+ signal.addEventListener("abort", finish, { once: true });
325
+ }));
326
+ const total = { cycles: 0, sent: 0, answered: 0, paired: 0, ignored: 0, problems: [] };
327
+ let commandsListed = false;
328
+ let failures = 0;
329
+ // Queued turns run BESIDE the poll, never inside a cycle: a long model
330
+ // turn must not stall the long poll, and each cycle's lease re-acquire is
331
+ // the renewal that keeps this the only live poller meanwhile. One
332
+ // processor at a time; its counts land on the totals when it finishes.
333
+ let inFlight = null;
334
+ const kick = () => {
335
+ if (inFlight !== null || options.conversation === undefined || options.readProjects === undefined)
336
+ return;
337
+ const report = { sent: 0, answered: 0, paired: 0, ignored: 0, backlog: false, problems: [] };
338
+ inFlight = processConversations(store, botId, transport, owner, clock, report, options.readProjects, options.conversation, signal)
339
+ .catch(error => { report.problems.push(`telegram chat: ${error instanceof Error ? error.message : String(error)}`); })
340
+ .finally(() => {
341
+ inFlight = null;
342
+ if ((report.chatAnswered ?? 0) > 0)
343
+ total.chatAnswered = (total.chatAnswered ?? 0) + (report.chatAnswered ?? 0);
344
+ if ((report.chatRefused ?? 0) > 0)
345
+ total.chatRefused = (total.chatRefused ?? 0) + (report.chatRefused ?? 0);
346
+ total.problems.push(...report.problems);
347
+ if ((report.chatAnswered ?? 0) > 0 || (report.chatRefused ?? 0) > 0 || report.problems.length > 0)
348
+ options.onCycle?.(report);
349
+ });
350
+ };
351
+ // v98: pushed updates. With a public address, Telegram pushes each update to
352
+ // /hooks/telegram and the console keeps it for us; while it does, nobody can
353
+ // ask Telegram for this bot's updates — no other program can take them. Every
354
+ // minute we check the address is still ours (a framework starting up elsewhere
355
+ // may delete it); if pushes keep failing we ask for updates ourselves for a
356
+ // while, then try pushes again.
357
+ const push = options.push ?? null;
358
+ const pushCheckMs = options.pushCheckMs ?? PUSH_CHECK_MS;
359
+ let pushMode = push === null ? "poll" : "push";
360
+ let pushRegistered = false, pushCheckedAt = -Infinity, pushFailingSince = null, pollUntil = 0, pollNoted = false;
361
+ const pushing = () => push !== null && pushMode === "push" && pushRegistered;
362
+ const fallBack = (report, problem) => {
363
+ pushMode = "poll";
364
+ pushRegistered = false;
365
+ pushFailingSince = null;
366
+ pollUntil = clock().getTime() + PUSH_RETRY_MS;
367
+ report.problems.push(`${problem}. Asking Telegram for updates directly for now.`);
368
+ store.setTelegramPush(botId, { url: null, problem }, clock());
369
+ };
370
+ const managePush = async (report) => {
371
+ if (push === null) {
372
+ if (!pollNoted) {
373
+ store.setTelegramPush(botId, { url: null, problem: null }, clock());
374
+ pollNoted = true;
375
+ }
376
+ return;
377
+ }
378
+ const now = clock().getTime();
379
+ if (pushMode === "poll") {
380
+ if (now < pollUntil)
381
+ return;
382
+ pushMode = "push";
383
+ pushRegistered = false;
384
+ }
385
+ if (pushRegistered && now - pushCheckedAt < pushCheckMs)
386
+ return;
387
+ pushCheckedAt = now;
388
+ const info = await transport("getWebhookInfo", {}, signal);
389
+ if (!info.ok) {
390
+ report.problems.push(`getWebhookInfo: ${info.description ?? "failed"}`);
391
+ return;
392
+ }
393
+ const hook = (info.result ?? {});
394
+ if (hook.url !== push.url) {
395
+ if (pushRegistered)
396
+ report.problems.push(`Telegram stopped pushing to ${push.url} (${hook.url ? "another address was set" : "something switched this bot back to being asked for updates"}); set it again`);
397
+ const set = await transport("setWebhook", { url: push.url, secret_token: push.secret, allowed_updates: ["message", "callback_query"], max_connections: 4 }, signal);
398
+ if (!set.ok) {
399
+ fallBack(report, `Telegram refused the push address ${push.url}: ${set.description ?? "failed"}`);
400
+ return;
401
+ }
402
+ pushRegistered = true;
403
+ pushFailingSince = null;
404
+ store.setTelegramPush(botId, { url: push.url, problem: null }, clock());
405
+ return;
406
+ }
407
+ pushRegistered = true;
408
+ // Telegram couldn't deliver lately and updates are waiting: the address isn't reachable.
409
+ const failing = (hook.pending_update_count ?? 0) > 0 && typeof hook.last_error_date === "number" && now / 1000 - hook.last_error_date < 180;
410
+ if (!failing) {
411
+ pushFailingSince = null;
412
+ store.setTelegramPush(botId, { url: push.url, problem: null }, clock());
413
+ return;
414
+ }
415
+ pushFailingSince ??= now;
416
+ store.setTelegramPush(botId, { url: push.url, problem: `Telegram can't reach it: ${hook.last_error_message ?? "no answer"}` }, clock());
417
+ if (now - pushFailingSince < PUSH_GIVE_UP_MS)
418
+ return;
419
+ await transport("deleteWebhook", { drop_pending_updates: false }, signal);
420
+ fallBack(report, `Telegram couldn't reach ${push.url} (${hook.last_error_message ?? "no answer"})`);
421
+ };
422
+ try {
423
+ while (!signal.aborted) {
424
+ const lease = store.acquireBridgeLease(botId, owner, BRIDGE_LEASE_MS, clock());
425
+ if (!lease.ok) {
426
+ // A cron pass (or a rival follower) holds the poll. Not an error —
427
+ // wait our turn and try again.
428
+ await wait(FOLLOW_BACKOFF_MS[Math.min(failures, FOLLOW_BACKOFF_MS.length - 1)]);
429
+ failures = Math.min(failures + 1, FOLLOW_BACKOFF_MS.length - 1);
430
+ continue;
431
+ }
432
+ // The "/" menu in Telegram, once, by the poller that holds the lease:
433
+ // the picker first. Best effort; typed commands work either way.
434
+ if (!commandsListed && options.conversation !== undefined) {
435
+ commandsListed = true;
436
+ try {
437
+ await transport("setMyCommands", { commands: TELEGRAM_COMMANDS });
438
+ }
439
+ catch { /* typed commands still work */ }
440
+ }
441
+ const startedAt = Date.now();
442
+ const report = { sent: 0, answered: 0, paired: 0, ignored: 0, backlog: false, problems: [] };
443
+ await managePush(report);
444
+ if (options.deliver !== false) {
445
+ await deliverOutbox(store, botId, transport, owner, clock, report, options.readProjects, options.canDeliver, options.conversation?.phoneOrigin, options.conversation?.evidenceRoot);
446
+ await deliverTeam(store, botId, transport, clock, report, options.readProjects, options.canDeliver, options.conversation?.phoneOrigin);
447
+ }
448
+ // Pushed updates first (some may wait from before a fall back), then — only while
449
+ // Telegram isn't pushing — ask for more.
450
+ const pushed = await drainInbox({ store, botId, transport, clock, report, readProjects: options.readProjects, conversation: options.conversation, projects: null }, owner, lease.generation);
451
+ if (pushed > 0)
452
+ report.pushed = pushed;
453
+ if (!pushing()) {
454
+ await drainUpdates(store, botId, transport, owner, lease.generation, lease.cursor, clock, report, pollSeconds, signal, options.readProjects, options.conversation);
455
+ // A push address nobody here wants any more (the public address was removed): take it down, so asking works again.
456
+ if (report.webhookActive === true && push === null)
457
+ await transport("deleteWebhook", { drop_pending_updates: false }, signal);
458
+ }
459
+ kick();
460
+ total.cycles++;
461
+ total.sent += report.sent;
462
+ total.answered += report.answered;
463
+ total.paired += report.paired;
464
+ total.ignored += report.ignored;
465
+ if (report.statusReplies !== undefined)
466
+ total.statusReplies = (total.statusReplies ?? 0) + report.statusReplies;
467
+ if (report.chatQueued !== undefined)
468
+ total.chatQueued = (total.chatQueued ?? 0) + report.chatQueued;
469
+ if (report.chatRefused !== undefined)
470
+ total.chatRefused = (total.chatRefused ?? 0) + report.chatRefused;
471
+ if (report.chatConfirmed !== undefined)
472
+ total.chatConfirmed = (total.chatConfirmed ?? 0) + report.chatConfirmed;
473
+ total.problems.push(...report.problems);
474
+ if (report.sent > 0 || report.answered > 0 || report.paired > 0 || (report.statusReplies ?? 0) > 0 || (report.chatQueued ?? 0) > 0 || (report.chatRefused ?? 0) > 0 || (report.chatConfirmed ?? 0) > 0 || report.problems.length > 0) {
475
+ options.onCycle?.(report);
476
+ }
477
+ if (report.problems.length > 0 && report.sent === 0 && report.answered === 0) {
478
+ // The wire is down. Back off; the counter resets on the first clean cycle.
479
+ if (!signal.aborted) {
480
+ await wait(FOLLOW_BACKOFF_MS[Math.min(failures, FOLLOW_BACKOFF_MS.length - 1)]);
481
+ }
482
+ failures = Math.min(failures + 1, FOLLOW_BACKOFF_MS.length - 1);
483
+ continue;
484
+ }
485
+ failures = 0;
486
+ // A healthy cycle's wait IS the long poll. A cycle that came back
487
+ // instantly and empty (scripted transport, misbehaving server) gets
488
+ // padded so the loop cannot spin hot.
489
+ const took = Date.now() - startedAt;
490
+ // While Telegram pushes (v98) there is no long poll to wait on: a pushed update waits at most this long.
491
+ const floor = pushing() ? PUSH_IDLE_MS : FOLLOW_IDLE_FLOOR_MS;
492
+ if (!signal.aborted && report.sent === 0 && report.answered === 0 && took < floor) {
493
+ await wait(floor - took);
494
+ }
495
+ }
496
+ }
497
+ finally {
498
+ // A turn in flight finishes on its own bounds (the engine's wall clock);
499
+ // its reply is fenced on the claim and the channel like every other part.
500
+ if (inFlight !== null)
501
+ await inFlight;
502
+ store.releaseBridgeLease(botId, owner, clock());
503
+ }
504
+ return total;
505
+ }
506
+ /** The queued turns for one bot, counted onto the pass's report. */
507
+ async function processConversations(store, botId, transport, owner, clock, report, readProjects, conversation, signal) {
508
+ const chat = { answered: 0, refused: 0, problems: [] };
509
+ await processTelegramConversations({ store, botId, transport, owner, clock, readProjects, options: conversation, report: chat, ...(signal === undefined ? {} : { signal }) });
510
+ if (chat.answered > 0)
511
+ report.chatAnswered = (report.chatAnswered ?? 0) + chat.answered;
512
+ if (chat.refused > 0)
513
+ report.chatRefused = (report.chatRefused ?? 0) + chat.refused;
514
+ report.problems.push(...chat.problems);
515
+ }
516
+ /** The one button a plain fact may carry: its machine-minted console path
517
+ * under the trusted origin read now — the same road `/task` uses. The label
518
+ * names where the path goes; nothing in it comes from the fact's text. */
519
+ function factLinkLabel(path) {
520
+ if (/^\/review\?result=[^&]+&run=\d+&tab=checks$/.test(path))
521
+ return "Inspect result";
522
+ if (/^\/chat\?task=[^&]+&result=/.test(path))
523
+ return "Open result";
524
+ if (/^\/(?:chat\?task=|t\/)/.test(path))
525
+ return "Open task";
526
+ if (/^\/d\//.test(path))
527
+ return "Open decision";
528
+ return "Open console";
529
+ }
530
+ /** Team-conversation traffic to the chats that follow one — after the outbox, under the same delivery switch, never a problem to raise when nothing follows anything. */
531
+ async function deliverTeam(store, botId, transport, clock, report, readProjects, canDeliver, phoneOrigin) {
532
+ if (store.listTelegramTeamChats(botId).length === 0)
533
+ return;
534
+ try {
535
+ if (canDeliver !== undefined && !canDeliver())
536
+ return;
537
+ }
538
+ catch {
539
+ return;
540
+ }
541
+ let projects;
542
+ try {
543
+ projects = await readProjects?.() ?? [];
544
+ }
545
+ catch {
546
+ report.problems.push("team chats: current project access could not be read");
547
+ return;
548
+ }
549
+ const team = { sent: 0, problems: [] };
550
+ await deliverTeamChats(store, botId, transport, clock, team, projects, phoneOrigin, { ...(readProjects === undefined ? {} : { readProjects }), ...(canDeliver === undefined ? {} : { canDeliver }) });
551
+ report.sent += team.sent;
552
+ report.problems.push(...team.problems);
553
+ }
554
+ async function deliverOutbox(store, botId, transport, owner, clock, report, readProjects, canDeliver, phoneOrigin, evidenceRoot) {
555
+ const bindings = store.liveTelegramBindings(botId);
556
+ if (bindings.length === 0) {
557
+ // Routine progress facts are not a problem to fix: a first pairing
558
+ // starts from now and settles them as history. Anything else pending
559
+ // is named, once per pass, as before.
560
+ if (store.listNotifications("pending").some(row => !isLifecycleNotification(row)))
561
+ report.problems.push("outbox rows are pending but no chat is paired — `toolroll bridge telegram pair`");
562
+ return;
563
+ }
564
+ const digest = store.telegramDigest();
565
+ const now = clock();
566
+ const digestDue = digest.everyMs === null || digest.lastSentAt === null || now.getTime() >= new Date(digest.lastSentAt).getTime() + digest.everyMs;
567
+ // Every paired person is a destination of their own: each binding claims
568
+ // and settles its own rows under its own ceiling, in turn.
569
+ for (const binding of bindings)
570
+ await deliverOutboxTo(store, botId, binding, transport, owner, clock, report, digest, digestDue, readProjects, canDeliver, phoneOrigin, evidenceRoot);
571
+ }
572
+ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, report, digest, digestDue, readProjects, canDeliver, phoneOrigin, evidenceRoot) {
573
+ const now = clock();
574
+ const claimed = store.claimTelegramDeliveries(binding, owner, DELIVERY_CLAIM_MS, now, digestDue ? "all" : "urgent");
575
+ // Preserve ID order, flushing earlier routine facts before an urgent task
576
+ // update. An earlier failed/retrying row also fences later rows for that task.
577
+ const groups = [];
578
+ for (const row of claimed) {
579
+ const last = groups[groups.length - 1];
580
+ if (digest.everyMs !== null && !isUrgent(row) && last !== undefined && !isUrgent(last[0]))
581
+ last.push(row);
582
+ else
583
+ groups.push([row]);
584
+ }
585
+ for (const group of groups) {
586
+ let projects = [];
587
+ const readAccess = async () => {
588
+ try {
589
+ projects = await readProjects?.() ?? [];
590
+ }
591
+ catch {
592
+ return "Current Telegram delivery access could not be read";
593
+ }
594
+ return null;
595
+ };
596
+ const channelProblem = () => {
597
+ try {
598
+ if (canDeliver !== undefined && !canDeliver())
599
+ return TELEGRAM_HOLD_REASONS.disabled;
600
+ }
601
+ catch {
602
+ return "Current Telegram delivery access could not be read";
603
+ }
604
+ return null;
605
+ };
606
+ const retryAt = () => [store.telegramRetryAt(botId), new Date(clock().getTime() + 1_000).toISOString()].sort().at(-1);
607
+ // Partition before anything is sent: a row without live authority is
608
+ // retained with its reason, and the rows that ARE eligible go out now
609
+ // rather than waiting on it. Walking in ID order with the eligible set
610
+ // as the batch keeps a task's later facts behind its blocked earlier one.
611
+ const rows = [];
612
+ const blocked = [];
613
+ const preflight = (await readAccess()) ?? channelProblem();
614
+ for (const row of group) {
615
+ const problem = preflight ?? store.telegramDeliveryProblem(row, binding, owner, projects, clock(), [...rows.map(one => one.id), row.id]);
616
+ if (problem === null)
617
+ rows.push(row);
618
+ else
619
+ blocked.push({ row, error: problem });
620
+ }
621
+ store.transact(() => {
622
+ for (const { row, error } of blocked)
623
+ store.finalizeTelegramDelivery(row, binding, owner, { ok: false, error, retryAt: retryAt() }, clock());
624
+ });
625
+ for (const { row, error } of blocked)
626
+ report.problems.push(`notification ${row.id}: ${error}`);
627
+ if (rows.length === 0)
628
+ continue;
629
+ const ids = rows.map(row => row.id);
630
+ const fence = () => {
631
+ const channel = channelProblem();
632
+ if (channel !== null)
633
+ return channel;
634
+ for (const row of rows) {
635
+ const problem = store.telegramDeliveryProblem(row, binding, owner, projects, clock(), ids);
636
+ if (problem !== null)
637
+ return problem;
638
+ }
639
+ return null;
640
+ };
641
+ const sender = async (text, keyboard, messageRows = rows, entities) => {
642
+ // Finish every await before the synchronous fence and transport call.
643
+ const problem = (await readAccess()) ?? fence();
644
+ if (problem !== null)
645
+ return { ok: false, error: problem };
646
+ const sent = await send(transport, binding.chatId, text, keyboard, entities);
647
+ if (!sent.ok) {
648
+ if (sent.retryAfter !== undefined)
649
+ store.deferTelegram(botId, new Date(clock().getTime() + sent.retryAfter * 1_000).toISOString());
650
+ return sent;
651
+ }
652
+ if (sent.messageId === null)
653
+ return { ok: false, error: "Telegram returned no confirmed message identity" };
654
+ for (const row of messageRows)
655
+ store.recordTelegramMessage(row, binding, sent.messageId, clock());
656
+ // Keep the old message's history but never acknowledge a replaced pairing.
657
+ const after = (await readAccess()) ?? fence();
658
+ return after === null ? sent : { ok: false, error: after };
659
+ };
660
+ const imageSender = async (row) => {
661
+ const problem = (await readAccess()) ?? fence();
662
+ if (problem !== null)
663
+ return { ok: false, error: problem };
664
+ const match = /^life:acceptance-evidence:r(\d+)-a(\d+)-([a-f0-9]{64}):\d+$/.exec(row.dedupeKey);
665
+ if (match === null || row.taskId === null || row.run === null || store.getRun(Number(match[1]))?.parentRun !== row.run)
666
+ return { ok: false, error: "Screenshot notification does not match its recorded review" };
667
+ const family = store.taskFamilyOf(row.taskId, projects, false);
668
+ const current = family?.current.id === row.taskId && store.runsFor(row.taskRef).find(one => one.finishedAt !== null && ["builder", "repair", "scout"].includes(one.role))?.id === row.run;
669
+ const skip = store.proofAcceptance(row.run) !== null ? "Acceptance is already recorded; no further acceptance is needed." : !current ? "A newer result is current. Request its evidence before accepting." : null;
670
+ const verified = evidenceRoot === undefined ? { ok: false, problem: "the bridge cannot read evidence files" } : verifyResultImage(store, evidenceRoot, telegramConversationRepos(store, binding.approver, projects), { taskId: row.taskId, run: row.run, artifact: Number(match[2]), sha256: match[3] });
671
+ if (skip !== null)
672
+ return { ok: true, receipt: store.proofAcceptance(row.run) !== null ? "skipped:already-accepted" : "skipped:newer-result" };
673
+ if (!verified.ok) {
674
+ const sent = await sender(`A screenshot for result #${row.run} could not be sent: ${verified.problem}. Inspect the result before accepting.`);
675
+ return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
676
+ }
677
+ // No await between the access fence, file validation and upload.
678
+ let answer;
679
+ try {
680
+ answer = await transport("sendDocument", { chat_id: binding.chatId, caption: `${resultTaskLabel(row.taskId)} · result #${row.run} · screenshot for acceptance` }, undefined, {
681
+ field: "document", bytes: verified.bytes, contentType: verified.format === "png" ? "image/png" : "image/jpeg", fileName: resultImageFileName(row.taskId, row.run, Number(match[2]), verified.format),
682
+ });
683
+ }
684
+ catch {
685
+ return { ok: false, error: "Screenshot delivery is unconfirmed; retry may duplicate it" };
686
+ }
687
+ const messageId = answer.result?.message_id;
688
+ if (!answer.ok || !Number.isSafeInteger(messageId)) {
689
+ if (answer.parameters?.retry_after !== undefined)
690
+ store.deferTelegram(botId, new Date(clock().getTime() + answer.parameters.retry_after * 1000).toISOString());
691
+ return { ok: false, error: answer.uncertain || (answer.ok && messageId == null) ? "Screenshot delivery is unconfirmed; retry may duplicate it" : "Telegram did not accept the screenshot" };
692
+ }
693
+ store.recordTelegramMessage(row, binding, String(messageId), clock());
694
+ const after = (await readAccess()) ?? fence();
695
+ return after === null ? { ok: true, receipt: receiptFor(botId, binding.chatId, String(messageId)) } : { ok: false, error: after };
696
+ };
697
+ const batched = digest.everyMs !== null && !isUrgent(rows[0]);
698
+ const row = rows[0];
699
+ const progressRun = batched ? null : store.telegramProgressRun(row);
700
+ const progress = progressRun !== null && isTelegramProgressNotification(row);
701
+ const updateProgress = async (onlyExisting) => {
702
+ if (progressRun === null || row.taskId === null || row.project === null)
703
+ return null;
704
+ const problem = (await readAccess()) ?? fence();
705
+ if (problem !== null)
706
+ return { ok: false, error: problem };
707
+ const messageId = store.telegramProgressMessage(binding, progressRun);
708
+ if (messageId === null && onlyExisting)
709
+ return null;
710
+ const card = telegramProgressCard(store, store.getRun(progressRun.id), row.taskId, row.project, clock(), evidenceRoot);
711
+ let button = null;
712
+ try {
713
+ button = phoneLinkButton(phoneOrigin?.() ?? null, card.link);
714
+ }
715
+ catch { /* No trusted origin. */ }
716
+ const keyboard = button === null ? [] : [button];
717
+ if (messageId === null)
718
+ return sender(card.text, keyboard, rows, card.entities);
719
+ const edited = await editProgress(transport, binding.chatId, messageId, card.text, keyboard, card.entities);
720
+ if (!edited.ok) {
721
+ if (edited.retryAfter !== undefined)
722
+ store.deferTelegram(botId, new Date(clock().getTime() + edited.retryAfter * 1000).toISOString());
723
+ // Only Telegram's definitive missing/uneditable response permits a
724
+ // replacement. Timeouts, rate limits and uncertain edits retry in place.
725
+ if (!onlyExisting && edited.replace)
726
+ return sender(card.text, keyboard, rows, card.entities);
727
+ return edited;
728
+ }
729
+ if (!onlyExisting)
730
+ store.recordTelegramMessage(row, binding, messageId, clock());
731
+ const after = (await readAccess()) ?? fence();
732
+ return after === null ? edited : { ok: false, error: after };
733
+ };
734
+ // A failure or decision still gets its own alert. Refresh an existing
735
+ // card first so it does not keep saying the build is running.
736
+ if (!progress && progressRun !== null)
737
+ await updateProgress(true);
738
+ const updated = progress ? await updateProgress(false) : null;
739
+ const outcome = updated !== null ? updated.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, updated.messageId) } : updated
740
+ : rows[0].kind === "acceptance-evidence" ? await imageSender(rows[0]) : batched
741
+ ? await deliverDigest(botId, binding, sender, rows, digest.lastSentAt, clock)
742
+ : await deliverOne(store, botId, binding, sender, rows[0], clock, phoneOrigin, evidenceRoot, projects);
743
+ const finalProblem = outcome.ok ? (await readAccess()) ?? fence() : null;
744
+ const settled = finalProblem === null ? outcome : { ok: false, error: finalProblem };
745
+ // A skipped screenshot settles its row but nothing reached the phone.
746
+ const skipped = settled.ok && settled.receipt !== null && settled.receipt.startsWith("skipped:");
747
+ const finalized = store.transact(() => {
748
+ let count = 0;
749
+ for (const row of rows) {
750
+ const result = settled.ok ? settled : { ...settled, retryAt: retryAt() };
751
+ if (store.finalizeTelegramDelivery(row, binding, owner, result, clock()) && settled.ok)
752
+ count++;
753
+ }
754
+ if (batched && count === rows.length)
755
+ store.markTelegramDigestSent(clock());
756
+ return count;
757
+ });
758
+ if (!skipped)
759
+ report.sent += finalized;
760
+ if (batched && finalized === rows.length)
761
+ report.digests = (report.digests ?? 0) + 1;
762
+ if (!settled.ok)
763
+ report.problems.push(`${batched ? `digest of ${rows.length} notification(s)` : `notification ${rows[0].id}`}: ${settled.error}`);
764
+ else if (finalized !== rows.length)
765
+ report.problems.push("Telegram delivery claim expired before acknowledgement; retry may duplicate a message");
766
+ }
767
+ }
768
+ /** What pages singly whatever the cadence: a decision, or an attention-class fact. */
769
+ function isUrgent(notification) {
770
+ return /^decision:\d+$/.test(notification.dedupeKey) || notification.pushClass === "attention" || notification.kind === "acceptance-evidence" || notification.kind === "acceptance-ready";
771
+ }
772
+ /** The digest text: a header with the count and the window, then one
773
+ * fact per entry — its subject, then its body's first line, indented.
774
+ * Plain text, no buttons: nothing in a digest is tappable. */
775
+ export function digestText(rows, since, now) {
776
+ const window = since === null ? "" : ` since ${since.slice(0, 16).replace("T", " ")}`;
777
+ const lines = [`digest — ${rows.length} routine fact(s)${window} (as of ${now.toISOString().slice(0, 16).replace("T", " ")})`, ""];
778
+ for (const row of rows) {
779
+ lines.push(digestEntry(row));
780
+ }
781
+ return lines.join("\n");
782
+ }
783
+ function digestEntry(row) {
784
+ const first = row.body.split("\n").map(one => one.trim()).find(one => one !== "");
785
+ const body = first === undefined ? "" : `\n ${first.length > 200 ? `${first.slice(0, 200).replace(/[\uD800-\uDBFF]$/, "")}…` : first}`;
786
+ return `• ${notificationIdentity(row)}${row.subject}${body}`;
787
+ }
788
+ async function deliverDigest(botId, binding, sender, rows, since, clock) {
789
+ const text = digestText(rows, since, clock());
790
+ // Track the exact rows represented by each text part. A split row may bind
791
+ // several messages; a digest message may bind several rows.
792
+ let offset = text.indexOf("\n\n") + 2;
793
+ const spans = rows.map(row => {
794
+ const length = digestEntry(row).length;
795
+ const span = { row, start: offset, end: offset + length };
796
+ offset += length + 1;
797
+ return span;
798
+ });
799
+ let last = null;
800
+ let at = 0;
801
+ for (const part of split(text)) {
802
+ const related = spans.filter(span => span.start < at + part.length && span.end > at).map(span => span.row);
803
+ const sent = await sender(part, undefined, related);
804
+ if (!sent.ok)
805
+ return sent;
806
+ last = sent.messageId;
807
+ at += part.length;
808
+ }
809
+ return { ok: true, receipt: receiptFor(botId, binding.chatId, last) };
810
+ }
811
+ async function deliverOne(store, botId, binding, sender, notification, clock, phoneOrigin, evidenceRoot, projects = []) {
812
+ const decisionId = /^decision:(\d+)$/.exec(notification.dedupeKey);
813
+ const decision = decisionId === null ? null : store.getDecision(Number(decisionId[1]));
814
+ if (decision !== null && (notification.taskRef === null || store.getRun(decision.run)?.taskRef !== notification.taskRef || (notification.run !== null && notification.run !== decision.run))) {
815
+ return { ok: false, error: "Decision does not match notification provenance" };
816
+ }
817
+ if (decision === null || decision.state === "answered") {
818
+ // A plain fact, or a decision settled before the bridge got to it: the
819
+ // text is the message. Its one next-action button — the fact's
820
+ // machine-minted link under the trusted origin read now, never a token
821
+ // and never persisted — rides the LAST part only, exactly as `/task`'s
822
+ // does; with no trusted origin the words stand alone.
823
+ const alreadyAccepted = notification.kind === "acceptance-ready" && notification.run !== null && store.proofAcceptance(notification.run) !== null;
824
+ let body = alreadyAccepted ? "This result already has recorded human acceptance. No further acceptance is needed." : notification.body;
825
+ let current = true;
826
+ if (notification.kind === "acceptance-ready" && !alreadyAccepted && notification.taskId !== null && notification.run !== null) {
827
+ const principal = verifyApproverStanding(store, binding.approver, binding.approverGeneration, telegramConversationRepos(store, binding.approver, projects));
828
+ if (!principal.ok)
829
+ return { ok: false, error: "Current acceptance evidence access could not be verified" };
830
+ const packet = acceptanceEvidenceText(store, principal.who, evidenceRoot, notification.taskId, notification.run);
831
+ if (!packet.ok) {
832
+ body = `Acceptance evidence unavailable: ${packet.message}`;
833
+ current = false;
834
+ }
835
+ else if (!packet.isCurrent) {
836
+ body = "A newer result is current. Request its evidence before accepting.";
837
+ current = false;
838
+ }
839
+ else
840
+ body = `${packet.text}\n\n${notification.body}`;
841
+ }
842
+ const parts = split(`${notificationIdentity(notification)}${alreadyAccepted ? "Acceptance recorded" : notification.subject}\n\n${body}`);
843
+ let button = null;
844
+ if (notification.link !== null && !alreadyAccepted && current) {
845
+ try {
846
+ button = phoneLinkButton(phoneOrigin?.() ?? null, { label: factLinkLabel(notification.link), path: notification.link });
847
+ }
848
+ catch {
849
+ button = null;
850
+ }
851
+ }
852
+ // A flow card waiting on a decision (v86): Approve, Edit, Send back on the last part, for this visit only.
853
+ const visit = FLOW_DECIDE_KEY.exec(notification.dedupeKey);
854
+ const waiting = visit === null ? null : flowDecisionAt(store, Number(visit[1]), Number(visit[2]));
855
+ const flowKeys = waiting === null ? null : flowButtons(store, binding, waiting, clock());
856
+ // A teammate's question (v93): its options and "Answer in words", for the person it asks.
857
+ const asked = flowKeys === null ? openQuestionOf(store, notification.dedupeKey) : null;
858
+ const questionKeys = asked === null ? null : telegramQuestionButtons(store, binding, asked, clock());
859
+ const keys = flowKeys ?? questionKeys;
860
+ let last = null;
861
+ for (const [index, part] of parts.entries()) {
862
+ const final = index === parts.length - 1;
863
+ const keyboard = !final ? undefined : keys !== null ? [...keys.keyboard, ...(button === null ? [] : [button])] : button !== null ? [button] : undefined;
864
+ const sent = await sender(part, keyboard);
865
+ if (!sent.ok)
866
+ return { ok: false, error: sent.error };
867
+ last = sent.messageId;
868
+ }
869
+ if (flowKeys !== null && last !== null)
870
+ store.placeTelegramFlowActions(flowKeys.tokens, last);
871
+ if (questionKeys !== null && last !== null)
872
+ store.placeTelegramQuestionActions(questionKeys.tokens, last);
873
+ return { ok: true, receipt: receiptFor(botId, binding.chatId, last) };
874
+ }
875
+ // A decision. Every safety-bearing word goes out before anything tappable
876
+ // exists: recap, question, and every option's consequence, split across as
877
+ // many plain messages as they need — a button whose warning was truncated
878
+ // away is a trap, so the keyboard rides the LAST part only, and only if
879
+ // every earlier part arrived.
880
+ const lines = [
881
+ `${notificationIdentity(notification)}Decision needed`,
882
+ "",
883
+ decision.recap,
884
+ "",
885
+ `Q: ${decision.question}`,
886
+ "",
887
+ ...decision.options.flatMap(option => [
888
+ `[${option.id}] ${option.label}${option.id === decision.recommendation ? " (recommended)" : ""}${option.reversible ? "" : " — IRREVERSIBLE"}`,
889
+ ` ${option.consequence}`,
890
+ ]),
891
+ ...(decision.deadline === null ? [] : ["", `deadline: ${decision.deadline}`]),
892
+ ];
893
+ const parts = split(lines.join("\n"));
894
+ for (const part of parts.slice(0, -1)) {
895
+ const sent = await sender(part);
896
+ if (!sent.ok)
897
+ return { ok: false, error: sent.error };
898
+ // Every part is a message somebody may REPLY to with a note: each id
899
+ // routes to this decision, exactly (Codex free-text review, finding 1).
900
+ if (sent.messageId !== null) {
901
+ store.recordTelegramDecisionMessage(binding.id, binding.chatId, sent.messageId, decision.id, clock());
902
+ }
903
+ }
904
+ // The buttons: one opaque token per option, minted before the send so a
905
+ // tap can never arrive for a token that does not exist, placed onto the
906
+ // message afterwards so a tap on any OTHER message proves itself stale.
907
+ const tokens = decision.options.map(option => ({
908
+ option,
909
+ token: randomBytes(16).toString("hex"),
910
+ }));
911
+ for (const { option, token } of tokens) {
912
+ store.createTelegramAction({
913
+ token,
914
+ binding: binding.id,
915
+ decision: decision.id,
916
+ optionId: option.id,
917
+ phase: "choose",
918
+ chatId: binding.chatId,
919
+ }, clock());
920
+ }
921
+ const keyboard = tokens.map(({ option, token }) => [
922
+ {
923
+ text: `${option.label}${option.id === decision.recommendation ? " ✓" : ""}${option.reversible ? "" : " ⚠"}`,
924
+ callback_data: token,
925
+ },
926
+ ]);
927
+ const last = parts[parts.length - 1];
928
+ const sent = await sender(last, keyboard);
929
+ if (!sent.ok)
930
+ return { ok: false, error: sent.error };
931
+ if (sent.messageId !== null) {
932
+ store.placeTelegramActions(tokens.map(({ token }) => token), sent.messageId);
933
+ store.recordTelegramDecisionMessage(binding.id, binding.chatId, sent.messageId, decision.id, clock());
934
+ }
935
+ return { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) };
936
+ }
937
+ async function send(transport, chatId, text, keyboard, entities) {
938
+ // No markup parsing. Only machine-selected heading ranges may be bold;
939
+ // agent text remains literal and URLs never trigger link previews.
940
+ let answer;
941
+ try {
942
+ answer = await transport("sendMessage", {
943
+ chat_id: chatId,
944
+ text,
945
+ ...(entities === undefined ? {} : { entities }),
946
+ link_preview_options: { is_disabled: true },
947
+ ...(keyboard === undefined ? {} : { reply_markup: { inline_keyboard: keyboard } }),
948
+ });
949
+ }
950
+ catch {
951
+ return { ok: false, error: "Telegram transport failed; delivery may be uncertain" };
952
+ }
953
+ if (!answer.ok) {
954
+ const retry = answer.parameters?.retry_after;
955
+ return { ok: false, error: answer.description ?? "sendMessage failed", ...(typeof retry === "number" && Number.isFinite(retry) && retry > 0 ? { retryAfter: Math.ceil(retry) } : {}) };
956
+ }
957
+ const messageId = answer.result?.message_id;
958
+ return { ok: true, messageId: Number.isSafeInteger(messageId) && messageId > 0 ? String(messageId) : null };
959
+ }
960
+ async function editProgress(transport, chatId, messageId, text, keyboard, entities) {
961
+ let answer;
962
+ try {
963
+ answer = await transport("editMessageText", { chat_id: chatId, message_id: Number(messageId), text, ...(entities === undefined ? {} : { entities }),
964
+ link_preview_options: { is_disabled: true }, reply_markup: { inline_keyboard: keyboard } });
965
+ }
966
+ catch {
967
+ return { ok: false, error: "Telegram progress update is unconfirmed; it will retry in place" };
968
+ }
969
+ // The retry may be repainting the same bytes after an acknowledgement was
970
+ // lost. Telegram's explicit unchanged response confirms this target state.
971
+ if (!answer.ok && !answer.uncertain && /^Bad Request: message is not modified\b/i.test(answer.description ?? ""))
972
+ return { ok: true, messageId };
973
+ if (!answer.ok) {
974
+ const retry = answer.parameters?.retry_after;
975
+ return { ok: false, error: answer.description ?? "Telegram progress update failed",
976
+ ...(typeof retry === "number" && Number.isFinite(retry) && retry > 0 ? { retryAfter: Math.ceil(retry) } : {}),
977
+ replace: !answer.uncertain && /^Bad Request: message (?:to edit not found|can't be edited)$/i.test(answer.description ?? "") };
978
+ }
979
+ const confirmed = answer.result?.message_id;
980
+ return String(confirmed) === messageId ? { ok: true, messageId } : { ok: false, error: "Telegram did not confirm the progress message identity" };
981
+ }
982
+ function receiptFor(botId, chatId, messageId) {
983
+ return `telegram:${botId}:${chatId}:${messageId ?? "?"}`;
984
+ }
985
+ function split(text) {
986
+ if (text.length <= PART_CAP)
987
+ return [text];
988
+ const parts = [];
989
+ for (let at = 0; at < text.length;) {
990
+ let end = Math.min(at + PART_CAP, text.length);
991
+ if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1]))
992
+ end--;
993
+ parts.push(text.slice(at, end));
994
+ at = end;
995
+ }
996
+ return parts;
997
+ }
998
+ function taskOf(store, decision) {
999
+ const run = store.getRun(decision.run);
1000
+ return run === null ? "?" : store.externalIdFor(run.taskRef) ?? "?";
1001
+ }
1002
+ async function drainUpdates(store, botId, transport, owner, generation, cursor, clock, report, pollSeconds = 0, signal, readProjects, conversation) {
1003
+ const context = { store, botId, transport, clock, report, readProjects, conversation, projects: null };
1004
+ let offset = cursor + 1;
1005
+ for (let page = 0; page < PAGE_BUDGET; page++) {
1006
+ const answer = await transport("getUpdates", {
1007
+ offset,
1008
+ // Only the first page long-polls; a backlog drains at full speed.
1009
+ timeout: page === 0 ? pollSeconds : 0,
1010
+ allowed_updates: ["message", "callback_query"],
1011
+ }, signal);
1012
+ if (!answer.ok) {
1013
+ const said = answer.description ?? "failed";
1014
+ // v98: another program asked Telegram for this bot's updates, and Telegram ended our
1015
+ // request in its favour. Nothing is lost: an update is only gone once someone
1016
+ // confirms it, and the next request asks again. Not a problem to report.
1017
+ if (/terminated by other getUpdates/i.test(said)) {
1018
+ report.contention = (report.contention ?? 0) + 1;
1019
+ return;
1020
+ }
1021
+ // Telegram is pushing this bot's updates to a webhook: polling isn't how they arrive now.
1022
+ if (/webhook is active/i.test(said)) {
1023
+ report.webhookActive = true;
1024
+ return;
1025
+ }
1026
+ report.problems.push(`getUpdates: ${said}`);
1027
+ return;
1028
+ }
1029
+ const updates = answer.result ?? [];
1030
+ if (updates.length === 0)
1031
+ return;
1032
+ for (const update of updates) {
1033
+ await processUpdate(context, update, owner, generation);
1034
+ offset = update.update_id + 1;
1035
+ }
1036
+ }
1037
+ // The budget ran out with Telegram still holding pages: said, not hidden.
1038
+ report.backlog = true;
1039
+ }
1040
+ /**
1041
+ * One update, polled or pushed (v98): the projects it may read, applied in
1042
+ * its own transaction (once: applyUpdate refuses an update id it has seen),
1043
+ * its Telegram-side effects, and the cursor moved past it.
1044
+ */
1045
+ async function processUpdate(context, update, owner, generation) {
1046
+ // A proposal tap confirms as a principal minted against the CURRENT
1047
+ // enrolled ceiling, and the registry is a file read: it happens
1048
+ // before the update's transaction, and only once the envelope has
1049
+ // proved the exact paired sender and chat — a stranger reads nothing.
1050
+ context.projects = update.message !== undefined ? await enrolledForMessage(context, update) : await projectsForTap(context, update);
1051
+ const effects = applyUpdate(context, update);
1052
+ // Effects are Telegram-side conveniences — acks, edits, replies. They
1053
+ // retry-or-drop; they never decide whether the cursor moves, because
1054
+ // an unreachable edit must not make the bridge re-apply an answer.
1055
+ for (const effect of effects) {
1056
+ try {
1057
+ await effect();
1058
+ }
1059
+ catch {
1060
+ context.report.problems.push(`a telegram edit/ack failed for update ${update.update_id}`);
1061
+ }
1062
+ }
1063
+ context.store.advanceBridgeCursor(context.botId, owner, generation, update.update_id, context.clock());
1064
+ }
1065
+ /** v98: updates Telegram pushed to /hooks/telegram, applied in order through the same door as polled ones. */
1066
+ async function drainInbox(context, owner, generation) {
1067
+ let applied = 0;
1068
+ for (const queued of context.store.telegramInbox(context.botId, PAGE_BUDGET * 100)) {
1069
+ let update = null;
1070
+ try {
1071
+ update = JSON.parse(queued.payload);
1072
+ }
1073
+ catch {
1074
+ update = null;
1075
+ }
1076
+ if (update !== null && update.update_id === queued.updateId)
1077
+ await processUpdate(context, update, owner, generation);
1078
+ context.store.dropTelegramInbox(queued.updateId);
1079
+ applied++;
1080
+ }
1081
+ return applied;
1082
+ }
1083
+ /** The enrolled registry, read before a message is applied: the team layer
1084
+ * scopes leads and conversations to it (each person's own access is checked
1085
+ * inside the domain). Unreadable reads as null, and the personal paths stay
1086
+ * exactly as they were. */
1087
+ async function enrolledForMessage(context, update) {
1088
+ const message = update.message;
1089
+ if (message?.chat === undefined || message.from === undefined || message.text === undefined || context.readProjects === undefined)
1090
+ return null;
1091
+ // An untrusted envelope reads nothing: the sender must be paired, the
1092
+ // text plain and direct, and the chat either that person's own private
1093
+ // chat or a group that follows (or is being pointed at) a conversation.
1094
+ if (message.forward_origin !== undefined || message.forward_date !== undefined || message.via_bot !== undefined || message.sender_chat !== undefined || message.caption !== undefined)
1095
+ return null;
1096
+ const binding = context.store.liveTelegramBindingFor(context.botId, String(message.from.id));
1097
+ if (binding === null)
1098
+ return null;
1099
+ const chatId = String(message.chat.id);
1100
+ const isGroup = message.chat.type === "group" || message.chat.type === "supergroup";
1101
+ const trusted = isGroup
1102
+ ? context.store.telegramTeamChat(context.botId, chatId)?.kind === "group" || teamCommand(message.text) !== null || FLOW_WORDS.test(message.text.trim())
1103
+ : message.chat.type === "private" && chatId === binding.chatId;
1104
+ if (!trusted)
1105
+ return null;
1106
+ try {
1107
+ return await context.readProjects();
1108
+ }
1109
+ catch {
1110
+ return null;
1111
+ }
1112
+ }
1113
+ async function projectsForTap(context, update) {
1114
+ const callback = update.callback_query;
1115
+ // Flow decision buttons (v86) work with or without the lead's conversation on this phone.
1116
+ const flowTap = callback !== undefined && context.store.getTelegramFlowAction(callback.data ?? "") !== null;
1117
+ if (callback === undefined || context.readProjects === undefined || (context.conversation === undefined && !flowTap))
1118
+ return null;
1119
+ const binding = callback.from === undefined ? null : context.store.liveTelegramBindingFor(context.botId, String(callback.from.id));
1120
+ if (binding === null || callback.from === undefined ||
1121
+ callback.message?.chat === undefined ||
1122
+ // Proposal cards and task picks read the chat's project ceiling.
1123
+ (context.store.getTelegramProposalAction(callback.data ?? "") === null && context.store.getTelegramFlowAction(callback.data ?? "") === null && !(callback.data ?? "").startsWith("pick:")))
1124
+ return null;
1125
+ const chat = callback.message.chat;
1126
+ const chatId = String(chat.id);
1127
+ const trusted = chatId === binding.chatId || context.store.telegramTeamChat(context.botId, chatId)?.kind === "group";
1128
+ if (!trusted)
1129
+ return null;
1130
+ try {
1131
+ return telegramConversationRepos(context.store, binding.approver, await context.readProjects());
1132
+ }
1133
+ catch {
1134
+ return null;
1135
+ }
1136
+ }
1137
+ /**
1138
+ * Apply one update in one transaction; return the Telegram-side effects to
1139
+ * attempt afterwards. Everything suspicious lands in the same place:
1140
+ * `ignored`, silently — an unbound stranger learns nothing, including
1141
+ * whether there was anything to learn.
1142
+ */
1143
+ function applyUpdate(context, update) {
1144
+ const effects = [];
1145
+ context.store.transact(() => {
1146
+ if (!context.store.markTelegramUpdateApplied(update.update_id, "seen", context.clock())) {
1147
+ // Already applied by an earlier pass. The local mutation happened;
1148
+ // the edits were attempted then; nothing repeats.
1149
+ return;
1150
+ }
1151
+ if (update.message !== undefined) {
1152
+ applyMessage(context, update, effects);
1153
+ return;
1154
+ }
1155
+ if (update.callback_query !== undefined) {
1156
+ applyCallback(context, update, effects);
1157
+ return;
1158
+ }
1159
+ context.report.ignored++;
1160
+ });
1161
+ return effects;
1162
+ }
1163
+ /** A group message that connects the group to a flow, or that the group's flow takes as a card. False: not for an inbox. */
1164
+ function applyGroupInbox(context, message, effects) {
1165
+ const { store, botId, transport, clock } = context;
1166
+ const chatId = String(message.chat.id), text = message.text ?? "";
1167
+ const reply = (said, link) => effects.push(async () => {
1168
+ let button = null;
1169
+ if (link !== undefined) {
1170
+ try {
1171
+ button = phoneLinkButton(context.conversation?.phoneOrigin?.() ?? null, link);
1172
+ }
1173
+ catch {
1174
+ button = null;
1175
+ }
1176
+ }
1177
+ await transport("sendMessage", { chat_id: chatId, text: said, link_preview_options: { is_disabled: true }, reply_parameters: { message_id: message.message_id },
1178
+ ...(button === null ? {} : { reply_markup: { inline_keyboard: [button] } }) });
1179
+ });
1180
+ if (FLOW_WORDS.test(text.trim())) {
1181
+ const binding = store.liveTelegramBindingFor(botId, String(message.from.id));
1182
+ if (binding === null) {
1183
+ context.report.ignored++;
1184
+ return true;
1185
+ }
1186
+ const repos = context.projects === null ? [] : telegramConversationRepos(store, binding.approver, context.projects);
1187
+ reply(connectChannel(store, { app: "telegram", installation: botId, conversation: chatId, binding, text, repos, followsConversation: store.telegramTeamChat(botId, chatId) !== null }, clock()));
1188
+ return true;
1189
+ }
1190
+ const trigger = watchedChannel(store, "telegram", botId, chatId);
1191
+ if (trigger === null)
1192
+ return false;
1193
+ // Forwards, other bots and channel posts don't become cards: only people writing in the group.
1194
+ if (message.forward_origin !== undefined || message.forward_date !== undefined || message.via_bot !== undefined || message.sender_chat !== undefined) {
1195
+ context.report.ignored++;
1196
+ return true;
1197
+ }
1198
+ const ts = String(message.message_id), thread = message.reply_to_message === undefined ? ts : String(message.reply_to_message.message_id);
1199
+ const sender = message.from;
1200
+ const who = typeof sender.username === "string" ? `@${sender.username}` : typeof sender.first_name === "string" ? sender.first_name : "someone";
1201
+ const taken = takeChannelMessage(store, trigger, { app: "telegram", conversation: chatId, ts, thread, text, who }, clock());
1202
+ if (taken.said !== null)
1203
+ reply(taken.said, taken.link);
1204
+ return true;
1205
+ }
1206
+ function applyMessage(context, update, effects) {
1207
+ const { store, botId, transport, clock, report } = context;
1208
+ const message = update.message;
1209
+ const chat = message.chat;
1210
+ const from = message.from;
1211
+ const pair = /^\/pair\s+([0-9a-f]{32})\s*$/.exec(message.text ?? "");
1212
+ // A group as a flow's inbox (v89): "/flow 12" from a paired approver connects it; after that its messages are cards.
1213
+ if (pair === null && chat !== undefined && from !== undefined && (chat.type === "group" || chat.type === "supergroup") && typeof message.text === "string" && applyGroupInbox(context, message, effects))
1214
+ return;
1215
+ if (pair === null && chat !== undefined && from !== undefined) {
1216
+ // The team layer first: `/team` anywhere, everything in a followed
1217
+ // group, and a private chat that chose a conversation. Its replies ride
1218
+ // the same post-commit effects as every other answer.
1219
+ const consumed = applyTeamInbound({
1220
+ store, botId, now: clock(), report, projects: context.projects, phoneOrigin: context.conversation?.phoneOrigin, updateId: update.update_id, message,
1221
+ say: (chatId, text, keyboard) => {
1222
+ effects.push(async () => {
1223
+ await transport("sendMessage", { chat_id: chatId, text, link_preview_options: { is_disabled: true }, reply_parameters: { message_id: message.message_id },
1224
+ ...(keyboard === undefined ? {} : { reply_markup: { inline_keyboard: keyboard } }) });
1225
+ });
1226
+ },
1227
+ });
1228
+ if (consumed)
1229
+ return;
1230
+ // Replies to decisions retain their existing note meaning, even if the
1231
+ // note starts with a slash. New read commands are direct messages only.
1232
+ if (message.reply_to_message === undefined && applyPhoneRead(context, update, effects))
1233
+ return;
1234
+ // A reply to a decision message this bot sent is a note, exactly as
1235
+ // before. Everything else that is ordinary text talks to the shared
1236
+ // assistant when a conversation is configured; otherwise silence.
1237
+ const binding = store.liveTelegramBindingFor(botId, String(from.id));
1238
+ // A reply to an Edit or Send back prompt (v86): the new draft, or the note it goes back with.
1239
+ // A reply to a teammate's "Answer in words" prompt (v93): the answer.
1240
+ const questionPrompt = binding !== null && message.reply_to_message !== undefined && String(chat.id) === binding.chatId
1241
+ ? store.telegramQuestionPrompt(binding.chatId, String(message.reply_to_message.message_id), clock()) : null;
1242
+ if (binding !== null && questionPrompt !== null) {
1243
+ const said = applyTelegramQuestionReply(store, binding, questionPrompt, message.text ?? "", clock());
1244
+ if (said !== null)
1245
+ effects.push(async () => { await transport("sendMessage", { chat_id: binding.chatId, text: said, link_preview_options: { is_disabled: true }, reply_parameters: { message_id: message.message_id } }); });
1246
+ return;
1247
+ }
1248
+ const flowPrompt = binding !== null && message.reply_to_message !== undefined && String(chat.id) === binding.chatId
1249
+ ? store.telegramFlowPrompt(binding.chatId, String(message.reply_to_message.message_id), clock()) : null;
1250
+ if (binding !== null && flowPrompt !== null) {
1251
+ const repos = context.projects === null ? null : telegramConversationRepos(store, binding.approver, context.projects);
1252
+ for (const effect of applyFlowReply(store, binding, flowPrompt, message.text ?? "", repos, clock())) {
1253
+ if (effect.kind === "say")
1254
+ effects.push(async () => { await transport("sendMessage", { chat_id: binding.chatId, text: effect.text, link_preview_options: { is_disabled: true }, reply_parameters: { message_id: message.message_id } }); });
1255
+ else
1256
+ effects.push(async () => {
1257
+ const sent = await send(transport, binding.chatId, effect.text, effect.keyboard);
1258
+ if (sent.ok && sent.messageId !== null)
1259
+ store.placeTelegramFlowActions(effect.tokens, sent.messageId);
1260
+ });
1261
+ }
1262
+ return;
1263
+ }
1264
+ // A message to a teammate by name (v96), in the person's own chat: a card on its desk, and the answer comes back here.
1265
+ if (binding !== null && message.reply_to_message === undefined && message.chat?.type === "private" && String(chat.id) === binding.chatId && store.accountOf(binding.approver)?.role === "approver") {
1266
+ const repos = context.projects === null ? store.knownRepos().filter(repo => store.accountCanAccess(binding.approver, repo)) : telegramConversationRepos(store, binding.approver, context.projects);
1267
+ const handed = messageTeammate(store, { who: binding.approver, repos, via: "Telegram" }, message.text ?? "", clock());
1268
+ if (handed !== null) {
1269
+ effects.push(async () => {
1270
+ let button = null;
1271
+ if (handed.link !== undefined) {
1272
+ try {
1273
+ button = phoneLinkButton(context.conversation?.phoneOrigin?.() ?? null, handed.link);
1274
+ }
1275
+ catch {
1276
+ button = null;
1277
+ }
1278
+ }
1279
+ await transport("sendMessage", { chat_id: binding.chatId, text: handed.said, link_preview_options: { is_disabled: true }, reply_parameters: { message_id: message.message_id },
1280
+ ...(button === null ? {} : { reply_markup: { inline_keyboard: [button] } }) });
1281
+ });
1282
+ return;
1283
+ }
1284
+ }
1285
+ const repliedDecision = binding !== null && message.reply_to_message !== undefined
1286
+ ? store.decisionForTelegramMessage(binding.id, binding.chatId, String(message.reply_to_message.message_id))
1287
+ : null;
1288
+ if (repliedDecision === null && context.conversation !== undefined && context.readProjects !== undefined) {
1289
+ applyConversation(context, update, effects);
1290
+ return;
1291
+ }
1292
+ applyNote(context, update, effects);
1293
+ return;
1294
+ }
1295
+ // Only /pair, only in a private chat, only with the sender on the record.
1296
+ // A group is exactly where "the chat" and "the person" diverge, which is
1297
+ // why a group cannot pair at all.
1298
+ if (pair === null || chat === undefined || chat.type !== "private" || from === undefined) {
1299
+ report.ignored++;
1300
+ return;
1301
+ }
1302
+ const consumed = store.consumeTelegramPairing({
1303
+ codeHash: hashPairingCode(pair[1]),
1304
+ botId,
1305
+ chatId: String(chat.id),
1306
+ userId: String(from.id),
1307
+ updateId: update.update_id,
1308
+ }, clock());
1309
+ if (!consumed.ok) {
1310
+ // A wrong code gets the same silence as everything else wrong: replying
1311
+ // "no such code" to a guesser is an oracle.
1312
+ report.ignored++;
1313
+ return;
1314
+ }
1315
+ report.paired++;
1316
+ const chatId = String(chat.id);
1317
+ const approver = consumed.binding.approver;
1318
+ effects.push(async () => {
1319
+ // At-least-once across a crash-after-send window, by design: a repeated
1320
+ // "paired" line is annoying; a paired chat that never heard so is worse.
1321
+ await transport("sendMessage", {
1322
+ chat_id: chatId,
1323
+ text: `paired: this chat now answers as ${approver}\n\nSend /status to check recent work, /task <id> for one task, or /help for your options.`,
1324
+ link_preview_options: { is_disabled: true },
1325
+ });
1326
+ });
1327
+ }
1328
+ /**
1329
+ * An ordinary message from the paired person: persisted for the shared
1330
+ * assistant in THIS transaction — before the cursor moves — with the exact
1331
+ * binding, sender, update, message and the request identity the engine
1332
+ * receipts its turn under. Two things are answered here and now without a
1333
+ * model: text over the engine's bound (never truncated), and a reply to a
1334
+ * message that carried several tasks (never guessed). Everything hostile is
1335
+ * silence, as for every other inbound shape.
1336
+ */
1337
+ function applyConversation(context, update, effects) {
1338
+ const { store, botId, transport, clock, report } = context;
1339
+ const message = update.message;
1340
+ const chat = message.chat;
1341
+ const from = message.from;
1342
+ const binding = from === undefined ? null : store.liveTelegramBindingFor(botId, String(from.id));
1343
+ if (binding === null || store.accountOf(binding.approver)?.role !== "approver" ||
1344
+ chat === undefined || chat.type !== "private" || String(chat.id) !== binding.chatId ||
1345
+ from === undefined ||
1346
+ message.text === undefined ||
1347
+ message.forward_origin !== undefined || message.forward_date !== undefined ||
1348
+ message.via_bot !== undefined || message.sender_chat !== undefined || message.caption !== undefined) {
1349
+ report.ignored++;
1350
+ return;
1351
+ }
1352
+ const say = (text) => {
1353
+ effects.push(async () => {
1354
+ await transport("sendMessage", {
1355
+ chat_id: binding.chatId,
1356
+ text,
1357
+ reply_parameters: { message_id: message.message_id },
1358
+ link_preview_options: { is_disabled: true },
1359
+ });
1360
+ });
1361
+ };
1362
+ const text = message.text.trim();
1363
+ if (text === "") {
1364
+ report.ignored++;
1365
+ return;
1366
+ }
1367
+ if (text.length > MATE_MESSAGE_MAX_CHARS) {
1368
+ say(tooLongText(text.length));
1369
+ report.chatRefused = (report.chatRefused ?? 0) + 1;
1370
+ return;
1371
+ }
1372
+ // A reply binds to what the replied-to message carried: exactly one task
1373
+ // (and its run) pins the turn; several ask which; none is a plain turn.
1374
+ let context_ = null;
1375
+ let taskId = null;
1376
+ let sourceRun = null;
1377
+ const replyTo = message.reply_to_message === undefined ? null : String(message.reply_to_message.message_id);
1378
+ if (replyTo !== null) {
1379
+ const bindings = store.telegramMessageBindings(binding, replyTo).filter(one => one.taskId !== null);
1380
+ const tasks = [...new Set(bindings.map(one => one.taskId))];
1381
+ if (tasks.length > 1) {
1382
+ say(whichTaskText(tasks.map(id => store.getTask(id)?.title ?? id)));
1383
+ report.chatRefused = (report.chatRefused ?? 0) + 1;
1384
+ return;
1385
+ }
1386
+ if (tasks.length === 1) {
1387
+ taskId = tasks[0];
1388
+ sourceRun = bindings.find(one => one.run !== null)?.run ?? null;
1389
+ context_ = replyContextFor(taskId, sourceRun);
1390
+ }
1391
+ }
1392
+ // No reply target: the task this chat chose to talk about, if any. The
1393
+ // turn re-proves it; a task no longer in reach reads as a plain message.
1394
+ if (taskId === null) {
1395
+ const focused = store.chatFocus("telegram", binding.id);
1396
+ if (focused !== null) {
1397
+ taskId = focused;
1398
+ context_ = focusContextFor(focused);
1399
+ }
1400
+ }
1401
+ store.enqueueTelegramConversation({
1402
+ binding, updateId: update.update_id, messageId: String(message.message_id), replyTo,
1403
+ request: telegramRequestId(botId, binding.id, update.update_id), text, context: context_, taskId, sourceRun,
1404
+ }, clock());
1405
+ report.chatQueued = (report.chatQueued ?? 0) + 1;
1406
+ }
1407
+ /** The picker's buttons: one task per row (its title and where it stands),
1408
+ * carried by the task's numeric reference so the data fits Telegram's 64
1409
+ * bytes; a tap re-proves the task against the ceiling. */
1410
+ function pickKeyboard(store, choices, focused) {
1411
+ const rows = [];
1412
+ for (const one of choices) {
1413
+ const ref = store.lookupRef(one.id);
1414
+ if (ref !== null)
1415
+ rows.push([{ text: `${one.title} · ${one.label}`.slice(0, 60), callback_data: `pick:${ref.id}` }]);
1416
+ }
1417
+ if (focused)
1418
+ rows.push([{ text: "Back to the lead", callback_data: "pick:lead" }]);
1419
+ return rows;
1420
+ }
1421
+ /** The title of the task this Telegram chat chose to talk about, if any. */
1422
+ function focusedTitle(store, binding) {
1423
+ const id = store.chatFocus("telegram", binding.id);
1424
+ return id === null ? null : { id, title: phoneText(store.getTask(id)?.title ?? id, 64) };
1425
+ }
1426
+ function applyPhoneRead(context, update, effects) {
1427
+ const { store, botId, transport, clock, report } = context;
1428
+ const message = update.message;
1429
+ const command = phoneCommand(message.text ?? "");
1430
+ if (command === null)
1431
+ return false;
1432
+ const binding = message.from === undefined ? null : store.liveTelegramBindingFor(botId, String(message.from.id));
1433
+ if (binding === null || store.accountOf(binding.approver)?.role !== "approver" ||
1434
+ message.chat?.type !== "private" || String(message.chat.id) !== binding.chatId ||
1435
+ message.from === undefined ||
1436
+ message.forward_origin !== undefined || message.forward_date !== undefined ||
1437
+ message.via_bot !== undefined || message.sender_chat !== undefined || message.caption !== undefined) {
1438
+ report.ignored++;
1439
+ return true;
1440
+ }
1441
+ // Like decision-message edits, a read reply is best-effort after the update
1442
+ // was consumed. A crash or send failure cannot turn replay into a new action.
1443
+ effects.push(async () => {
1444
+ const stillPaired = () => {
1445
+ const live = store.liveTelegramBindingById(binding.id);
1446
+ return live !== null && live.approverGeneration === binding.approverGeneration && store.accountOf(binding.approver)?.role === "approver";
1447
+ };
1448
+ if (!stillPaired())
1449
+ return;
1450
+ let response = PHONE_HELP;
1451
+ // `/task` may carry ONE url button to the exact recorded task or result:
1452
+ // minted from the trusted origin read now, never persisted, never a token.
1453
+ let button = null;
1454
+ // `/tasks` and an ambiguous `/task <name>` offer tasks as buttons.
1455
+ let keyboard = null;
1456
+ // The one task (and saved result) a `/task` reply shows: a reply to it is about that task.
1457
+ let shown = null;
1458
+ if (command.kind === "lead") {
1459
+ store.setChatFocus("telegram", binding.id, null, clock());
1460
+ response = PHONE_BACK_TO_LEAD;
1461
+ }
1462
+ else if (command.kind !== "help") {
1463
+ try {
1464
+ // The registry, then — after the await — the pairing again and the
1465
+ // account's OWN ceiling over it: a project this approver was never
1466
+ // given, or lost since, is not read, named or linked from here.
1467
+ const registry = await context.readProjects?.() ?? [];
1468
+ if (!stillPaired())
1469
+ return;
1470
+ const repos = telegramConversationRepos(store, binding.approver, registry);
1471
+ const focused = focusedTitle(store, binding);
1472
+ if (command.kind === "status")
1473
+ response = phoneStatus(store, repos, clock(), focused?.title ?? null);
1474
+ else if (command.kind === "tasks") {
1475
+ const choices = phoneTaskChoices(store, repos, clock());
1476
+ response = choices.length === 0 ? phoneTaskListText([], null) : `${focused === null ? "" : `Talking about: ${focused.title}\n\n`}Pick a task to talk about:`;
1477
+ if (choices.length > 0)
1478
+ keyboard = pickKeyboard(store, choices, focused !== null);
1479
+ }
1480
+ else {
1481
+ const pick = resolvePhoneTask(store, repos, clock(), command.id);
1482
+ if (pick.kind === "many") {
1483
+ response = "Several tasks match. Pick one:";
1484
+ keyboard = pickKeyboard(store, pick.choices, false);
1485
+ }
1486
+ else if (pick.kind === "none")
1487
+ response = PHONE_NO_MATCH;
1488
+ else {
1489
+ store.setChatFocus("telegram", binding.id, pick.id, clock());
1490
+ const view = phoneTaskView(store, repos, pick.view, clock());
1491
+ shown = { task: pick.view, run: view.run };
1492
+ button = phoneLinkButton(context.conversation?.phoneOrigin?.() ?? null, view.link);
1493
+ // A destination with no trusted origin to carry it: the words say where instead.
1494
+ response = phoneFocusText(button === null && view.link !== null ? `${view.text}\n\n${PHONE_CONSOLE_FOOTER}` : view.text);
1495
+ }
1496
+ }
1497
+ }
1498
+ catch {
1499
+ // No registry paths, SQLite errors, credentials, or stale snapshots
1500
+ // leave on the failure road. A new request can try again.
1501
+ response = "I couldn't read the current project status. No tasks were changed. Try /status again; if it persists, check Toolroll on the computer.";
1502
+ report.problems.push("phone status could not read the current project records");
1503
+ }
1504
+ }
1505
+ if (!stillPaired())
1506
+ return;
1507
+ // Each view fits one message. Fail visibly if a future change violates
1508
+ // that contract, rather than cutting off the important next action.
1509
+ if (response.length > PART_CAP) {
1510
+ response = "This status is too large for one phone message. Open the console for the full view, or send /task <id> for one task.";
1511
+ report.problems.push("phone status exceeded its message bound");
1512
+ }
1513
+ const sent = await transport("sendMessage", {
1514
+ chat_id: binding.chatId,
1515
+ text: response,
1516
+ reply_parameters: { message_id: message.message_id },
1517
+ link_preview_options: { is_disabled: true },
1518
+ ...(keyboard !== null ? { reply_markup: { inline_keyboard: keyboard } } : button === null ? {} : { reply_markup: { inline_keyboard: [button] } }),
1519
+ });
1520
+ if (sent.ok)
1521
+ report.statusReplies = (report.statusReplies ?? 0) + 1;
1522
+ else
1523
+ report.problems.push(`phone status reply failed for update ${update.update_id}; send a new command to retry`);
1524
+ const sentId = sent.ok ? sent.result?.message_id : undefined;
1525
+ if (shown !== null && typeof sentId === "number" && Number.isSafeInteger(sentId) && sentId > 0) {
1526
+ // Best effort: without it a reply still reaches the chosen task through the focus.
1527
+ try {
1528
+ store.recordTelegramTaskMessage(binding, String(sentId), shown.task, shown.run, clock());
1529
+ }
1530
+ catch { /* the focus still holds */ }
1531
+ }
1532
+ });
1533
+ return true;
1534
+ }
1535
+ /**
1536
+ * A free-text note, accepted only as an AUTHENTICATED REPLY to a recorded
1537
+ * decision message (Codex free-text review, prescribed design): live
1538
+ * binding, private chat, exact chat AND user, a reply_to that maps to
1539
+ * exactly one decision this bot sent, the decision still unanswered, and
1540
+ * direct initial plain text — no forwards, media, captions, bots, or
1541
+ * channel identities. Everything else is silence: a reply naming what was
1542
+ * wrong is an oracle. Choice stays TAP-ONLY; prose never selects an option.
1543
+ */
1544
+ function applyNote(context, update, effects) {
1545
+ const { store, botId, transport, clock, report } = context;
1546
+ const message = update.message;
1547
+ const chat = message.chat;
1548
+ const from = message.from;
1549
+ const binding = from === undefined ? null : store.liveTelegramBindingFor(botId, String(from.id));
1550
+ const say = (text) => {
1551
+ effects.push(async () => {
1552
+ await transport("sendMessage", {
1553
+ chat_id: String(chat?.id ?? ""),
1554
+ text,
1555
+ reply_parameters: { message_id: message.message_id },
1556
+ link_preview_options: { is_disabled: true },
1557
+ });
1558
+ });
1559
+ };
1560
+ if (binding === null ||
1561
+ chat === undefined || chat.type !== "private" ||
1562
+ from === undefined ||
1563
+ String(chat.id) !== binding.chatId ||
1564
+ message.reply_to_message === undefined ||
1565
+ message.text === undefined ||
1566
+ message.forward_origin !== undefined ||
1567
+ message.forward_date !== undefined ||
1568
+ message.via_bot !== undefined ||
1569
+ message.sender_chat !== undefined ||
1570
+ message.caption !== undefined) {
1571
+ report.ignored++;
1572
+ return;
1573
+ }
1574
+ const decisionId = store.decisionForTelegramMessage(binding.id, binding.chatId, String(message.reply_to_message.message_id));
1575
+ if (decisionId === null) {
1576
+ // A reply to something that never carried a decision — including a
1577
+ // send whose record was lost: fail closed, never guess by recency.
1578
+ report.ignored++;
1579
+ return;
1580
+ }
1581
+ const decision = store.getDecision(decisionId);
1582
+ if (decision === null) {
1583
+ report.ignored++;
1584
+ return;
1585
+ }
1586
+ if (decision.state === "answered") {
1587
+ say(`already answered: ${decision.choice ?? "?"} — this note did not travel`);
1588
+ report.ignored++;
1589
+ return;
1590
+ }
1591
+ const valid = validateNote(message.text);
1592
+ if (!valid.ok) {
1593
+ say(`that note cannot travel: ${valid.problem}`);
1594
+ report.ignored++;
1595
+ return;
1596
+ }
1597
+ const saved = store.saveNoteDraft({
1598
+ binding: binding.id,
1599
+ decision: decision.id,
1600
+ updateId: update.update_id,
1601
+ messageId: String(message.message_id),
1602
+ replyTo: String(message.reply_to_message.message_id),
1603
+ note: valid.note,
1604
+ }, clock());
1605
+ if (!saved) {
1606
+ // An older or equal update raced in late: the newer note stands.
1607
+ report.ignored++;
1608
+ return;
1609
+ }
1610
+ // A new note voids any ARMED irreversible confirmation: what it showed
1611
+ // is no longer what would travel (Codex free-text review, finding 3).
1612
+ store.consumeTelegramChallenges(decision.id, clock());
1613
+ report.noted = (report.noted ?? 0) + 1;
1614
+ // The echo IS the ceremony: the exact captured text, line-prefixed, so a
1615
+ // later edit of the operator's own message cannot rewrite the audit.
1616
+ say([
1617
+ `noted for ${taskOf(store, decision)}:`,
1618
+ ...valid.note.split("\n").map((line) => `| ${line}`),
1619
+ "",
1620
+ "Tap an option on the decision to answer WITH this note. It expires in 10 minutes; a new reply replaces it.",
1621
+ ].join("\n"));
1622
+ }
1623
+ function applyCallback(context, update, effects) {
1624
+ const { store, botId, transport, clock, report } = context;
1625
+ const callback = update.callback_query;
1626
+ const from = callback.from;
1627
+ const message = callback.message;
1628
+ const token = callback.data ?? "";
1629
+ const ack = (text) => {
1630
+ effects.push(async () => {
1631
+ await transport("answerCallbackQuery", {
1632
+ callback_query_id: callback.id,
1633
+ ...(text === undefined ? {} : { text }),
1634
+ });
1635
+ });
1636
+ };
1637
+ const editText = (text, keyboard) => {
1638
+ if (message === undefined)
1639
+ return;
1640
+ const chatId = message.chat === undefined ? null : String(message.chat.id);
1641
+ const messageId = message.message_id;
1642
+ if (chatId === null)
1643
+ return;
1644
+ effects.push(async () => {
1645
+ await transport("editMessageText", {
1646
+ chat_id: chatId,
1647
+ message_id: messageId,
1648
+ text,
1649
+ link_preview_options: { is_disabled: true },
1650
+ ...(keyboard === undefined ? {} : { reply_markup: { inline_keyboard: keyboard } }),
1651
+ });
1652
+ });
1653
+ };
1654
+ // The person, the chat, and the message must all be the paired ones. A
1655
+ // callback with no accessible message (inline mode, too-old messages) is
1656
+ // out; so is a tap from anyone but the exact paired user id — usernames
1657
+ // change hands, immutable ids do not.
1658
+ const binding = from === undefined ? null : store.liveTelegramBindingFor(botId, String(from.id));
1659
+ const tapChat = message?.chat === undefined ? null : String(message.chat.id);
1660
+ const followedGroup = tapChat !== null && binding !== null && tapChat !== binding.chatId && store.telegramTeamChat(botId, tapChat)?.kind === "group";
1661
+ if (binding === null ||
1662
+ from === undefined ||
1663
+ message === undefined ||
1664
+ message.chat === undefined ||
1665
+ (tapChat !== binding.chatId && !followedGroup)) {
1666
+ report.ignored++;
1667
+ return;
1668
+ }
1669
+ // A task picked from /tasks: this private chat now talks about it (or,
1670
+ // "Back to the lead", about everything again). The task is re-proved
1671
+ // against the chat's ceiling at the tap.
1672
+ if (token.startsWith("pick:")) {
1673
+ if (tapChat !== binding.chatId) {
1674
+ report.ignored++;
1675
+ return;
1676
+ }
1677
+ if (token === "pick:lead") {
1678
+ store.setChatFocus("telegram", binding.id, null, clock());
1679
+ store.recordTelegramTaskMessage(binding, String(message.message_id), null, null, clock());
1680
+ ack("Back to the lead");
1681
+ editText(PHONE_BACK_TO_LEAD);
1682
+ return;
1683
+ }
1684
+ const refId = /^pick:([1-9][0-9]{0,14})$/.exec(token)?.[1];
1685
+ const taskId = refId === undefined ? null : store.externalIdFor(Number(refId));
1686
+ const repos = context.projects === null ? null : telegramConversationRepos(store, binding.approver, context.projects);
1687
+ if (taskId === null || repos === null || !taskInCeiling(store, taskId, repos)) {
1688
+ ack("That task isn't available here now.");
1689
+ return;
1690
+ }
1691
+ const root = store.taskFamilyOf(taskId, repos, false)?.root ?? null;
1692
+ const id = root?.id ?? taskId;
1693
+ const title = phoneText(root?.title ?? store.getTask(taskId)?.title ?? taskId, 64);
1694
+ store.setChatFocus("telegram", binding.id, id, clock());
1695
+ ack(`Talking about: ${title}`.slice(0, 190));
1696
+ const current = store.taskFamilyOf(taskId, repos, false)?.current.id ?? id;
1697
+ const view = phoneTaskView(store, repos, current, clock());
1698
+ store.recordTelegramTaskMessage(binding, String(message.message_id), current, view.run, clock());
1699
+ editText(phoneFocusText(view.text), [[{ text: "Back to the lead", callback_data: "pick:lead" }]]);
1700
+ return;
1701
+ }
1702
+ // A teammate's question (v93): an option answers it; "Answer in words" asks for a reply.
1703
+ const questionAction = store.getTelegramQuestionAction(token);
1704
+ if (questionAction !== null) {
1705
+ if (questionAction.binding !== binding.id || questionAction.chatId !== tapChat || (questionAction.messageId !== null && questionAction.messageId !== String(message.message_id))) {
1706
+ report.ignored++;
1707
+ return;
1708
+ }
1709
+ for (const effect of applyTelegramQuestionTap(store, binding, questionAction, { text: message.text ?? "" }, clock())) {
1710
+ if (effect.kind === "ack")
1711
+ ack(effect.text);
1712
+ else if (effect.kind === "edit")
1713
+ editText(effect.text);
1714
+ else
1715
+ effects.push(async () => {
1716
+ const answer = await transport("sendMessage", { chat_id: binding.chatId, text: effect.text, link_preview_options: { is_disabled: true },
1717
+ reply_parameters: { message_id: message.message_id }, reply_markup: { force_reply: true, input_field_placeholder: effect.placeholder } });
1718
+ const id = answer.result?.message_id;
1719
+ if (answer.ok && Number.isSafeInteger(id))
1720
+ store.recordTelegramQuestionPrompt({ chatId: binding.chatId, messageId: String(id), binding: binding.id, question: effect.question }, clock());
1721
+ });
1722
+ }
1723
+ return;
1724
+ }
1725
+ const flowAction = store.getTelegramFlowAction(token);
1726
+ if (flowAction !== null) {
1727
+ if (flowAction.binding !== binding.id || flowAction.chatId !== tapChat || (flowAction.messageId !== null && flowAction.messageId !== String(message.message_id))) {
1728
+ report.ignored++;
1729
+ return;
1730
+ }
1731
+ const repos = context.projects === null ? null : telegramConversationRepos(store, binding.approver, context.projects);
1732
+ const tapped = applyFlowTap(store, binding, flowAction, { chatId: binding.chatId, messageId: String(message.message_id), text: message.text ?? "" }, repos, clock());
1733
+ for (const effect of tapped) {
1734
+ if (effect.kind === "ack")
1735
+ ack(effect.text);
1736
+ else if (effect.kind === "edit")
1737
+ editText(effect.text);
1738
+ else
1739
+ effects.push(async () => {
1740
+ // A reply box: whatever they send back as a reply to this prompt is the new draft, or the note.
1741
+ const answer = await transport("sendMessage", { chat_id: binding.chatId, text: effect.text, link_preview_options: { is_disabled: true },
1742
+ reply_parameters: { message_id: message.message_id }, reply_markup: { force_reply: true, input_field_placeholder: effect.placeholder } });
1743
+ const id = answer.result?.message_id;
1744
+ if (answer.ok && Number.isSafeInteger(id))
1745
+ store.recordTelegramFlowPrompt({ ...effect.prompt, messageId: String(id) }, clock());
1746
+ });
1747
+ }
1748
+ return;
1749
+ }
1750
+ const action = store.getTelegramAction(token);
1751
+ if (action === null && context.conversation !== undefined && store.getTelegramProposalAction(token) !== null) {
1752
+ // A proposal card's button: the shared confirm door, inside this
1753
+ // update's transaction, with the stop's process signal deferred to
1754
+ // after its commit (the door's own ordering, preserved from here).
1755
+ const tapped = applyProposalTap(store, binding, token, message, context.projects, context.conversation, clock());
1756
+ for (const effect of tapped.effects) {
1757
+ if (effect.kind === "ack")
1758
+ ack(effect.text);
1759
+ else if (effect.kind === "edit")
1760
+ editText(effect.text, effect.keyboard);
1761
+ else
1762
+ effects.push(async () => { effect.run(); });
1763
+ }
1764
+ if (tapped.confirmed)
1765
+ report.chatConfirmed = (report.chatConfirmed ?? 0) + 1;
1766
+ if (tapped.ignored)
1767
+ report.ignored++;
1768
+ return;
1769
+ }
1770
+ if (action === null ||
1771
+ action.binding !== binding.id ||
1772
+ action.chatId !== binding.chatId ||
1773
+ (action.messageId !== null && action.messageId !== String(message.message_id))) {
1774
+ // Bound person, dead or foreign button: acknowledged, not acted on.
1775
+ ack("that button is stale — toolroll decide shows what still waits");
1776
+ report.ignored++;
1777
+ return;
1778
+ }
1779
+ const decision = store.getDecision(action.decision);
1780
+ if (decision === null) {
1781
+ ack("that decision no longer exists");
1782
+ return;
1783
+ }
1784
+ if (decision.state === "answered") {
1785
+ store.consumeTelegramAction(token, clock());
1786
+ ack(`already answered: ${decision.choice ?? "?"}`);
1787
+ editText(answeredText(store, decision));
1788
+ return;
1789
+ }
1790
+ if (action.phase === "choose") {
1791
+ const option = decision.options.find(one => one.id === action.optionId);
1792
+ if (option === undefined) {
1793
+ ack("that option no longer exists");
1794
+ return;
1795
+ }
1796
+ if (expiredDraftGuard(store, binding, decision.id, clock())) {
1797
+ // The token is NOT consumed: the same button answers on the next tap,
1798
+ // now that the operator knows the note is gone.
1799
+ ack("your note expired — tap again to answer without it, or reply with a fresh note first");
1800
+ return;
1801
+ }
1802
+ if (!store.consumeTelegramAction(token, clock())) {
1803
+ ack("that button was already used");
1804
+ return;
1805
+ }
1806
+ if (!option.reversible) {
1807
+ // The arm. Nothing is answered here: two fresh one-time tokens make a
1808
+ // real challenge — a stolen bot token can repaint a keyboard, but it
1809
+ // cannot mint a row in this table, so a tap on a forged "confirm"
1810
+ // lands in the stale-button branch above.
1811
+ const confirm = randomBytes(16).toString("hex");
1812
+ const cancel = randomBytes(16).toString("hex");
1813
+ const placedOn = String(message.message_id);
1814
+ // The confirmation binds the EXACT answer tuple: option AND the note
1815
+ // it displays (its digest; null when none). A note that changes,
1816
+ // expires, or is cancelled strands this challenge (finding 3).
1817
+ const draft = store.liveNoteDraft(binding.id, decision.id, clock());
1818
+ const digest = draft === null ? undefined : noteDigestOf(draft.note);
1819
+ if (draft !== null)
1820
+ store.setNoteDraftState(draft.id, "armed");
1821
+ store.createTelegramAction({ token: confirm, binding: binding.id, decision: decision.id, optionId: option.id, phase: "confirm", chatId: binding.chatId, messageId: placedOn, ttlMs: CONFIRM_TTL_MS, ...(digest === undefined ? {} : { noteDigest: digest }) }, clock());
1822
+ store.createTelegramAction({ token: cancel, binding: binding.id, decision: decision.id, optionId: option.id, phase: "cancel", chatId: binding.chatId, messageId: placedOn, ttlMs: CONFIRM_TTL_MS }, clock());
1823
+ ack("irreversible — confirm it");
1824
+ editText(`⚠ ${option.label} is IRREVERSIBLE.\n${option.consequence}\n${draft === null ? "" : `\nWith your note:\n${draft.note.split("\n").map(line => `| ${line}`).join("\n")}\n`}\nConfirm?`, [
1825
+ [{ text: `⚠ Yes, ${option.label}`, callback_data: confirm }],
1826
+ [{ text: "Cancel", callback_data: cancel }],
1827
+ ]);
1828
+ return;
1829
+ }
1830
+ answerNow(context, decision, option.id, binding, ack, editText);
1831
+ return;
1832
+ }
1833
+ if (action.phase === "confirm") {
1834
+ if (!store.consumeTelegramAction(token, clock())) {
1835
+ ack("that confirmation expired — start again from the option");
1836
+ return;
1837
+ }
1838
+ // Re-proved at the moment of commitment, not remembered from the arm.
1839
+ const option = decision.options.find(one => one.id === action.optionId);
1840
+ if (option === undefined) {
1841
+ ack("that option no longer exists");
1842
+ return;
1843
+ }
1844
+ // The tuple the challenge displayed must still be the tuple that
1845
+ // travels: the CURRENT live draft's digest (or none) must equal what
1846
+ // was armed. Anything else strands the yes (finding 3).
1847
+ const current = store.liveNoteDraft(binding.id, decision.id, clock());
1848
+ const currentDigest = current === null ? null : noteDigestOf(current.note);
1849
+ if ((action.noteDigest ?? null) !== currentDigest) {
1850
+ store.consumeTelegramChallenges(decision.id, clock());
1851
+ ack("the note changed since this confirmation — read it again and re-arm");
1852
+ return;
1853
+ }
1854
+ answerNow(context, decision, option.id, binding, ack, editText);
1855
+ return;
1856
+ }
1857
+ // cancel: consume it, kill its sibling confirm, discard the note draft
1858
+ // (cancel means cancelled — the note it displayed dies with it), and
1859
+ // restore the choices.
1860
+ store.consumeTelegramAction(token, clock());
1861
+ store.consumeTelegramChallenges(decision.id, clock());
1862
+ {
1863
+ const draft = store.liveNoteDraft(binding.id, decision.id, clock());
1864
+ if (draft !== null)
1865
+ store.setNoteDraftState(draft.id, "discarded");
1866
+ }
1867
+ const fresh = decision.options.map(option => ({ option, token: randomBytes(16).toString("hex") }));
1868
+ for (const { option, token: choose } of fresh) {
1869
+ store.createTelegramAction({ token: choose, binding: binding.id, decision: decision.id, optionId: option.id, phase: "choose", chatId: binding.chatId, messageId: String(message.message_id) }, clock());
1870
+ }
1871
+ ack("cancelled");
1872
+ editText(`Q: ${decision.question}`, fresh.map(({ option, token: choose }) => [
1873
+ {
1874
+ text: `${option.label}${option.id === decision.recommendation ? " ✓" : ""}${option.reversible ? "" : " ⚠"}`,
1875
+ callback_data: choose,
1876
+ },
1877
+ ]));
1878
+ }
1879
+ function answerNow(context, decision, choice, binding, ack, editText) {
1880
+ const { store, clock, report } = context;
1881
+ // The live draft is the note that travels — consumed WITH the answer in
1882
+ // the same transaction the whole update already holds; a CAS loss
1883
+ // discards it (Codex free-text review, finding 4: never choose-then-note).
1884
+ const draft = store.liveNoteDraft(binding.id, decision.id, clock());
1885
+ const answered = store.answerDecisionLocked({ id: decision.id, choice, by: binding.approver, via: "telegram", ...(draft === null ? {} : { note: draft.note }) }, clock());
1886
+ if (answered.ok) {
1887
+ if (draft !== null)
1888
+ store.setNoteDraftState(draft.id, "consumed");
1889
+ report.answered++;
1890
+ ack(`✓ ${choice}${draft === null ? "" : " — with your note"}`);
1891
+ editText(answeredText(store, answered.decision));
1892
+ return;
1893
+ }
1894
+ if (answered.reason === "already-answered") {
1895
+ if (draft !== null)
1896
+ store.setNoteDraftState(draft.id, "discarded");
1897
+ const settled = store.getDecision(decision.id);
1898
+ ack(`already answered: ${settled?.choice ?? "?"}${draft === null ? "" : " — your note did NOT travel"}`);
1899
+ if (settled !== null)
1900
+ editText(answeredText(store, settled));
1901
+ return;
1902
+ }
1903
+ ack(`could not answer: ${answered.reason}`);
1904
+ }
1905
+ function answeredText(store, decision) {
1906
+ return [
1907
+ `✓ ${taskOf(store, decision)} — answered: ${decision.choice ?? "?"}`,
1908
+ `by ${decision.answeredBy ?? "?"} via ${decision.answeredVia ?? "?"}`,
1909
+ // Line-prefixed, never inline: a multiline note must not be able to
1910
+ // draw fake status lines (Codex free-text review, finding 7).
1911
+ ...(decision.note === null ? [] : ["with note:", ...decision.note.split("\n").map(line => `| ${line}`)]),
1912
+ ].join("\n");
1913
+ }
1914
+ function noteDigestOf(note) {
1915
+ return createHash("sha256").update(note, "utf8").digest("hex").slice(0, 32);
1916
+ }
1917
+ /**
1918
+ * A pending draft that ALREADY EXPIRED must never silently drop: the tap
1919
+ * proceeds only after the operator is told (Codex free-text review, state
1920
+ * machine — "never silently answer without the expected note").
1921
+ */
1922
+ function expiredDraftGuard(store, binding, decisionId, now) {
1923
+ const expired = store.handle
1924
+ .prepare(`SELECT id FROM telegram_note_draft
1925
+ WHERE binding = ? AND decision = ? AND state IN ('pending','armed') AND expires_at <= ?`)
1926
+ .get(binding.id, decisionId, now.toISOString());
1927
+ if (expired === undefined)
1928
+ return false;
1929
+ store.setNoteDraftState(Number(expired["id"]), "discarded");
1930
+ return true;
1931
+ }