agent-relay-server 0.120.1 → 0.120.3

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 (181) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +3 -3
  3. package/public/assets/MessageBubble-Cqi3XsZ7.js +2 -0
  4. package/public/assets/{MessageBubble-jpshgh3K.js.map → MessageBubble-Cqi3XsZ7.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-CjVG-Jsq.js → PlanGraphPanel-D6Um05oW.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CjVG-Jsq.js.map → PlanGraphPanel-D6Um05oW.js.map} +1 -1
  7. package/public/assets/{activity-DLTFF8Dz.js → activity-B0LsQHEz.js} +2 -2
  8. package/public/assets/{activity-DLTFF8Dz.js.map → activity-B0LsQHEz.js.map} +1 -1
  9. package/public/assets/{agent-profiles-BcCLIrON.js → agent-profiles-O--pe5Ic.js} +2 -2
  10. package/public/assets/{agent-profiles-BcCLIrON.js.map → agent-profiles-O--pe5Ic.js.map} +1 -1
  11. package/public/assets/{agent-type-icon-BEz393ra.js → agent-type-icon-CeEoMouY.js} +2 -2
  12. package/public/assets/{agent-type-icon-BEz393ra.js.map → agent-type-icon-CeEoMouY.js.map} +1 -1
  13. package/public/assets/agents-Ii6XlVwR.js +2 -0
  14. package/public/assets/{agents-QemvuxKO.js.map → agents-Ii6XlVwR.js.map} +1 -1
  15. package/public/assets/{analytics-DMLoMwta.js → analytics-BCzdYWRT.js} +2 -2
  16. package/public/assets/{analytics-DMLoMwta.js.map → analytics-BCzdYWRT.js.map} +1 -1
  17. package/public/assets/{automation-DACSOffp.js → automation-Crr3Xzui.js} +2 -2
  18. package/public/assets/{automation-DACSOffp.js.map → automation-Crr3Xzui.js.map} +1 -1
  19. package/public/assets/{badge-BhUZ1cAb.js → badge-DCIT3Irj.js} +2 -2
  20. package/public/assets/{badge-BhUZ1cAb.js.map → badge-DCIT3Irj.js.map} +1 -1
  21. package/public/assets/{branch-state-badge-BYE2U_y4.js → branch-state-badge-Bhw2rloy.js} +2 -2
  22. package/public/assets/{branch-state-badge-BYE2U_y4.js.map → branch-state-badge-Bhw2rloy.js.map} +1 -1
  23. package/public/assets/{button-C-V4oij1.js → button-CzdTkRIf.js} +2 -2
  24. package/public/assets/{button-C-V4oij1.js.map → button-CzdTkRIf.js.map} +1 -1
  25. package/public/assets/{card-Br1JLjTH.js → card-C3BGuVC6.js} +2 -2
  26. package/public/assets/{card-Br1JLjTH.js.map → card-C3BGuVC6.js.map} +1 -1
  27. package/public/assets/{channels-3SlDQL3j.js → channels-CKs9rvsn.js} +2 -2
  28. package/public/assets/{channels-3SlDQL3j.js.map → channels-CKs9rvsn.js.map} +1 -1
  29. package/public/assets/chat-BCwEUj5R.js +2 -0
  30. package/public/assets/chat-BCwEUj5R.js.map +1 -0
  31. package/public/assets/{connectors-CrEW591Q.js → connectors-D_kQR_vw.js} +2 -2
  32. package/public/assets/{connectors-CrEW591Q.js.map → connectors-D_kQR_vw.js.map} +1 -1
  33. package/public/assets/{copy-button-BStzXgtd.js → copy-button-B3o1jpLu.js} +2 -2
  34. package/public/assets/{copy-button-BStzXgtd.js.map → copy-button-B3o1jpLu.js.map} +1 -1
  35. package/public/assets/{dist-ChDX_t12.js → dist-B-T7lg0a.js} +2 -2
  36. package/public/assets/{dist-ChDX_t12.js.map → dist-B-T7lg0a.js.map} +1 -1
  37. package/public/assets/{dist-DV83j9tm.js → dist-B3e4B9pg.js} +2 -2
  38. package/public/assets/{dist-DV83j9tm.js.map → dist-B3e4B9pg.js.map} +1 -1
  39. package/public/assets/{dist-y03IwOrd.js → dist-B6woQ9Tb.js} +2 -2
  40. package/public/assets/{dist-y03IwOrd.js.map → dist-B6woQ9Tb.js.map} +1 -1
  41. package/public/assets/{dist-CQ0Z-TDi.js → dist-B9G83DXv.js} +2 -2
  42. package/public/assets/{dist-CQ0Z-TDi.js.map → dist-B9G83DXv.js.map} +1 -1
  43. package/public/assets/{dist-Cs8X0P1d.js → dist-BIjlN-xB.js} +2 -2
  44. package/public/assets/{dist-Cs8X0P1d.js.map → dist-BIjlN-xB.js.map} +1 -1
  45. package/public/assets/{dist-DyNJNwpi.js → dist-BInckSJC.js} +2 -2
  46. package/public/assets/{dist-DyNJNwpi.js.map → dist-BInckSJC.js.map} +1 -1
  47. package/public/assets/{dist-CmFeEXMe.js → dist-BlzVGRhh.js} +2 -2
  48. package/public/assets/{dist-CmFeEXMe.js.map → dist-BlzVGRhh.js.map} +1 -1
  49. package/public/assets/{dist-CDZZxxJq.js → dist-C7aTlosb.js} +2 -2
  50. package/public/assets/{dist-CDZZxxJq.js.map → dist-C7aTlosb.js.map} +1 -1
  51. package/public/assets/{dist-D2jvo5oA.js → dist-DGfp5dLz.js} +2 -2
  52. package/public/assets/{dist-D2jvo5oA.js.map → dist-DGfp5dLz.js.map} +1 -1
  53. package/public/assets/{dist-mm7c7nBt.js → dist-DT-OV6sQ.js} +2 -2
  54. package/public/assets/{dist-mm7c7nBt.js.map → dist-DT-OV6sQ.js.map} +1 -1
  55. package/public/assets/{dist-CBMWGhWc.js → dist-OUPTEP6q.js} +2 -2
  56. package/public/assets/{dist-CBMWGhWc.js.map → dist-OUPTEP6q.js.map} +1 -1
  57. package/public/assets/{es2015-VnZ2kVIz.js → es2015-CnKXYKpf.js} +2 -2
  58. package/public/assets/{es2015-VnZ2kVIz.js.map → es2015-CnKXYKpf.js.map} +1 -1
  59. package/public/assets/formatted-body-impl-CSRGuMcM.js +3 -0
  60. package/public/assets/formatted-body-impl-CSRGuMcM.js.map +1 -0
  61. package/public/assets/formatted-body-q_qzt1L-.js +3 -0
  62. package/public/assets/{formatted-body-Dg9R3RD7.js.map → formatted-body-q_qzt1L-.js.map} +1 -1
  63. package/public/assets/{index-D_fNJnsI.js → index-Ceiwd02X.js} +10 -10
  64. package/public/assets/index-Ceiwd02X.js.map +1 -0
  65. package/public/assets/{input-CtLT7WXi.js → input-B6Q0NWCS.js} +2 -2
  66. package/public/assets/{input-CtLT7WXi.js.map → input-B6Q0NWCS.js.map} +1 -1
  67. package/public/assets/{insights-D48n2ojF.js → insights-DUhXqZTZ.js} +2 -2
  68. package/public/assets/{insights-D48n2ojF.js.map → insights-DUhXqZTZ.js.map} +1 -1
  69. package/public/assets/{integrations-B-bdliwU.js → integrations-lbg5CjtE.js} +2 -2
  70. package/public/assets/{integrations-B-bdliwU.js.map → integrations-lbg5CjtE.js.map} +1 -1
  71. package/public/assets/{lib-ByLQqcD1.js → lib-BHsWPMKB.js} +2 -2
  72. package/public/assets/{lib-ByLQqcD1.js.map → lib-BHsWPMKB.js.map} +1 -1
  73. package/public/assets/lucide-react-DuhlF2Sj.js +10 -0
  74. package/public/assets/lucide-react-DuhlF2Sj.js.map +1 -0
  75. package/public/assets/{maintenance-LQ_U3Nqy.js → maintenance-CQgneSVf.js} +2 -2
  76. package/public/assets/{maintenance-LQ_U3Nqy.js.map → maintenance-CQgneSVf.js.map} +1 -1
  77. package/public/assets/{managed-agents-Cfh8FvY8.js → managed-agents-DsnYf6fm.js} +4 -4
  78. package/public/assets/{managed-agents-Cfh8FvY8.js.map → managed-agents-DsnYf6fm.js.map} +1 -1
  79. package/public/assets/{markdown-preview-impl-BSa7zYx1.js → markdown-preview-impl-C1ZtGJp6.js} +2 -2
  80. package/public/assets/{markdown-preview-impl-BSa7zYx1.js.map → markdown-preview-impl-C1ZtGJp6.js.map} +1 -1
  81. package/public/assets/{memory-Dr1iYKkC.js → memory-SSLUTOI2.js} +2 -2
  82. package/public/assets/{memory-Dr1iYKkC.js.map → memory-SSLUTOI2.js.map} +1 -1
  83. package/public/assets/messages-BY26OP4K.js +2 -0
  84. package/public/assets/{messages-DRKulS-2.js.map → messages-BY26OP4K.js.map} +1 -1
  85. package/public/assets/{orchestrators-BguyeViG.js → orchestrators-OY7yPzVI.js} +2 -2
  86. package/public/assets/{orchestrators-BguyeViG.js.map → orchestrators-OY7yPzVI.js.map} +1 -1
  87. package/public/assets/overview-Dj0u1tPG.js +2 -0
  88. package/public/assets/{overview-Dbq2lr7Y.js.map → overview-Dj0u1tPG.js.map} +1 -1
  89. package/public/assets/{pairs-DD7i2scF.js → pairs-CrUR50ou.js} +2 -2
  90. package/public/assets/{pairs-DD7i2scF.js.map → pairs-CrUR50ou.js.map} +1 -1
  91. package/public/assets/{projects-DEn85LQb.js → projects-B_AtxHtH.js} +2 -2
  92. package/public/assets/{projects-DEn85LQb.js.map → projects-B_AtxHtH.js.map} +1 -1
  93. package/public/assets/{prompt-settings-DwgrPKY3.js → prompt-settings-CPtYSban.js} +2 -2
  94. package/public/assets/{prompt-settings-DwgrPKY3.js.map → prompt-settings-CPtYSban.js.map} +1 -1
  95. package/public/assets/{react-dom-cLkVFBsS.js → react-dom-DWBmcawP.js} +2 -2
  96. package/public/assets/{react-dom-cLkVFBsS.js.map → react-dom-DWBmcawP.js.map} +1 -1
  97. package/public/assets/{registry-DCR3xFpe.js → registry-BgUALWn5.js} +2 -2
  98. package/public/assets/{registry-DCR3xFpe.js.map → registry-BgUALWn5.js.map} +1 -1
  99. package/public/assets/security-5lHfGk7g.js +3 -0
  100. package/public/assets/security-5lHfGk7g.js.map +1 -0
  101. package/public/assets/{select-CpyCRBK3.js → select-Cgx4QSLj.js} +2 -2
  102. package/public/assets/{select-CpyCRBK3.js.map → select-Cgx4QSLj.js.map} +1 -1
  103. package/public/assets/settings-DFGgxyun.js +6 -0
  104. package/public/assets/{settings-DYNFGC2-.js.map → settings-DFGgxyun.js.map} +1 -1
  105. package/public/assets/store-BM4zM89B.js +9 -0
  106. package/public/assets/store-BM4zM89B.js.map +1 -0
  107. package/public/assets/{task-chip-kxk4eNm1.js → task-chip-DXfvXBDx.js} +2 -2
  108. package/public/assets/{task-chip-kxk4eNm1.js.map → task-chip-DXfvXBDx.js.map} +1 -1
  109. package/public/assets/{tasks-rdy9Nsbf.js → tasks-BvH5rluu.js} +2 -2
  110. package/public/assets/{tasks-rdy9Nsbf.js.map → tasks-BvH5rluu.js.map} +1 -1
  111. package/public/assets/{teams-DHloBagJ.js → teams-VZOIOdoE.js} +2 -2
  112. package/public/assets/{teams-DHloBagJ.js.map → teams-VZOIOdoE.js.map} +1 -1
  113. package/public/assets/{terminal-viewer-impl-DWTuNejR.js → terminal-viewer-impl-DYY3Wmoe.js} +2 -2
  114. package/public/assets/{terminal-viewer-impl-DWTuNejR.js.map → terminal-viewer-impl-DYY3Wmoe.js.map} +1 -1
  115. package/public/assets/types-uVOXgvMT.js.map +1 -1
  116. package/public/assets/{use-keyboard-viewport-B06XtRUZ.js → use-keyboard-viewport-CP109w9y.js} +2 -2
  117. package/public/assets/{use-keyboard-viewport-B06XtRUZ.js.map → use-keyboard-viewport-CP109w9y.js.map} +1 -1
  118. package/public/assets/{user-avatar-DuwCFrER.js → user-avatar-Dw6mzWhv.js} +2 -2
  119. package/public/assets/{user-avatar-DuwCFrER.js.map → user-avatar-Dw6mzWhv.js.map} +1 -1
  120. package/public/assets/{work-queue-CShT7nTC.js → work-queue-B6Xk7QNe.js} +2 -2
  121. package/public/assets/{work-queue-CShT7nTC.js.map → work-queue-B6Xk7QNe.js.map} +1 -1
  122. package/public/assets/{workspaces-DjQ6EdrZ.js → workspaces-CPIzAHAe.js} +2 -2
  123. package/public/assets/{workspaces-DjQ6EdrZ.js.map → workspaces-CPIzAHAe.js.map} +1 -1
  124. package/public/index.html +23 -23
  125. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  126. package/src/automations/reconcile.ts +69 -4
  127. package/src/branch-landed.ts +18 -11
  128. package/src/bus.ts +25 -18
  129. package/src/db/connection.ts +53 -1
  130. package/src/db/maintenance-worker.ts +37 -0
  131. package/src/db/message-reads.ts +86 -45
  132. package/src/db/migrations.ts +1 -0
  133. package/src/db/provider-quotas.ts +6 -7
  134. package/src/db/schema.ts +2 -1
  135. package/src/maintenance/jobs.ts +19 -13
  136. package/src/maintenance/scheduler.ts +8 -3
  137. package/src/maintenance/types.ts +5 -1
  138. package/src/mcp/index.ts +5 -5
  139. package/src/mcp/tools-workspace.ts +8 -3
  140. package/src/routes/orchestrator-proxy.ts +7 -6
  141. package/src/routes/sse.ts +2 -1
  142. package/src/routes/tasks.ts +29 -0
  143. package/src/routes/workspaces.ts +9 -2
  144. package/src/runtime-tokens.ts +4 -0
  145. package/src/security.ts +31 -7
  146. package/src/server.ts +40 -18
  147. package/src/services/git-process.ts +61 -0
  148. package/src/services/git-remote.ts +87 -7
  149. package/src/services/issue-lifecycle.ts +6 -6
  150. package/src/services/project.ts +47 -1
  151. package/src/services/send-message.ts +2 -4
  152. package/src/services/spawn-agent.ts +2 -2
  153. package/src/services/task-lifecycle.ts +6 -9
  154. package/src/settings/registry.ts +6 -4
  155. package/src/spawn-targets.ts +2 -2
  156. package/src/sse.ts +131 -46
  157. package/src/steward.ts +10 -6
  158. package/src/terminal-authz.ts +50 -0
  159. package/src/token-db.ts +3 -3
  160. package/src/validation.ts +1 -1
  161. package/src/workspace-actions.ts +46 -4
  162. package/src/workspace-auto-merge.ts +120 -13
  163. package/src/workspace-orphans.ts +1 -1
  164. package/src/workspace-probe.ts +19 -10
  165. package/public/assets/MessageBubble-jpshgh3K.js +0 -2
  166. package/public/assets/agents-QemvuxKO.js +0 -2
  167. package/public/assets/chat-xKXCafN9.js +0 -2
  168. package/public/assets/chat-xKXCafN9.js.map +0 -1
  169. package/public/assets/formatted-body-Dg9R3RD7.js +0 -3
  170. package/public/assets/formatted-body-impl-DlasTD8X.js +0 -3
  171. package/public/assets/formatted-body-impl-DlasTD8X.js.map +0 -1
  172. package/public/assets/index-D_fNJnsI.js.map +0 -1
  173. package/public/assets/lucide-react-Dc3ZwCBi.js +0 -9
  174. package/public/assets/lucide-react-Dc3ZwCBi.js.map +0 -1
  175. package/public/assets/messages-DRKulS-2.js +0 -2
  176. package/public/assets/overview-Dbq2lr7Y.js +0 -2
  177. package/public/assets/security-BH8kTdA-.js +0 -3
  178. package/public/assets/security-BH8kTdA-.js.map +0 -1
  179. package/public/assets/settings-DYNFGC2-.js +0 -6
  180. package/public/assets/store-BuuJUn70.js +0 -9
  181. package/public/assets/store-BuuJUn70.js.map +0 -1
package/public/index.html CHANGED
@@ -12,34 +12,34 @@
12
12
  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%2309090b'/%3E%3Ccircle cx='16' cy='16' r='4.5' fill='%2358a6ff'/%3E%3Ccircle cx='6' cy='8' r='2.5' fill='%233fb950'/%3E%3Ccircle cx='26' cy='8' r='2.5' fill='%233fb950'/%3E%3Ccircle cx='6' cy='24' r='2.5' fill='%233fb950'/%3E%3Ccircle cx='26' cy='24' r='2.5' fill='%233fb950'/%3E%3Cline x1='8' y1='9.5' x2='13' y2='14' stroke='%2330363d' stroke-width='1.5'/%3E%3Cline x1='24' y1='9.5' x2='19' y2='14' stroke='%2330363d' stroke-width='1.5'/%3E%3Cline x1='8' y1='22.5' x2='13' y2='18' stroke='%2330363d' stroke-width='1.5'/%3E%3Cline x1='24' y1='22.5' x2='19' y2='18' stroke='%2330363d' stroke-width='1.5'/%3E%3C/svg%3E">
13
13
  <link rel="manifest" href="manifest.webmanifest">
14
14
  <link rel="apple-touch-icon" href="icons/agent-relay-192.png">
15
- <script type="module" crossorigin src="./assets/index-D_fNJnsI.js"></script>
15
+ <script type="module" crossorigin src="./assets/index-Ceiwd02X.js"></script>
16
16
  <link rel="modulepreload" crossorigin href="./assets/chunk-CilyBKbf.js">
17
17
  <link rel="modulepreload" crossorigin href="./assets/preload-helper-C_JMBXql.js">
18
- <link rel="modulepreload" crossorigin href="./assets/lucide-react-Dc3ZwCBi.js">
19
- <link rel="modulepreload" crossorigin href="./assets/dist-ChDX_t12.js">
20
- <link rel="modulepreload" crossorigin href="./assets/dist-Cs8X0P1d.js">
21
- <link rel="modulepreload" crossorigin href="./assets/react-dom-cLkVFBsS.js">
22
- <link rel="modulepreload" crossorigin href="./assets/dist-CQ0Z-TDi.js">
23
- <link rel="modulepreload" crossorigin href="./assets/dist-mm7c7nBt.js">
24
- <link rel="modulepreload" crossorigin href="./assets/dist-CDZZxxJq.js">
25
- <link rel="modulepreload" crossorigin href="./assets/dist-DV83j9tm.js">
18
+ <link rel="modulepreload" crossorigin href="./assets/lucide-react-DuhlF2Sj.js">
19
+ <link rel="modulepreload" crossorigin href="./assets/dist-B-T7lg0a.js">
20
+ <link rel="modulepreload" crossorigin href="./assets/dist-BIjlN-xB.js">
21
+ <link rel="modulepreload" crossorigin href="./assets/react-dom-DWBmcawP.js">
22
+ <link rel="modulepreload" crossorigin href="./assets/dist-B9G83DXv.js">
23
+ <link rel="modulepreload" crossorigin href="./assets/dist-DT-OV6sQ.js">
24
+ <link rel="modulepreload" crossorigin href="./assets/dist-C7aTlosb.js">
25
+ <link rel="modulepreload" crossorigin href="./assets/dist-B3e4B9pg.js">
26
26
  <link rel="modulepreload" crossorigin href="./assets/dist-BiLuYU6G.js">
27
- <link rel="modulepreload" crossorigin href="./assets/dist-DyNJNwpi.js">
28
- <link rel="modulepreload" crossorigin href="./assets/dist-D2jvo5oA.js">
29
- <link rel="modulepreload" crossorigin href="./assets/es2015-VnZ2kVIz.js">
30
- <link rel="modulepreload" crossorigin href="./assets/dist-CmFeEXMe.js">
31
- <link rel="modulepreload" crossorigin href="./assets/dist-CBMWGhWc.js">
32
- <link rel="modulepreload" crossorigin href="./assets/dist-y03IwOrd.js">
27
+ <link rel="modulepreload" crossorigin href="./assets/dist-BInckSJC.js">
28
+ <link rel="modulepreload" crossorigin href="./assets/dist-DGfp5dLz.js">
29
+ <link rel="modulepreload" crossorigin href="./assets/es2015-CnKXYKpf.js">
30
+ <link rel="modulepreload" crossorigin href="./assets/dist-BlzVGRhh.js">
31
+ <link rel="modulepreload" crossorigin href="./assets/dist-OUPTEP6q.js">
32
+ <link rel="modulepreload" crossorigin href="./assets/dist-B6woQ9Tb.js">
33
33
  <link rel="modulepreload" crossorigin href="./assets/types-uVOXgvMT.js">
34
34
  <link rel="modulepreload" crossorigin href="./assets/display-exQDWum3.js">
35
- <link rel="modulepreload" crossorigin href="./assets/store-BuuJUn70.js">
36
- <link rel="modulepreload" crossorigin href="./assets/agent-type-icon-BEz393ra.js">
37
- <link rel="modulepreload" crossorigin href="./assets/button-C-V4oij1.js">
38
- <link rel="modulepreload" crossorigin href="./assets/copy-button-BStzXgtd.js">
39
- <link rel="modulepreload" crossorigin href="./assets/formatted-body-Dg9R3RD7.js">
40
- <link rel="modulepreload" crossorigin href="./assets/task-chip-kxk4eNm1.js">
41
- <link rel="modulepreload" crossorigin href="./assets/badge-BhUZ1cAb.js">
42
- <link rel="modulepreload" crossorigin href="./assets/input-CtLT7WXi.js">
35
+ <link rel="modulepreload" crossorigin href="./assets/store-BM4zM89B.js">
36
+ <link rel="modulepreload" crossorigin href="./assets/agent-type-icon-CeEoMouY.js">
37
+ <link rel="modulepreload" crossorigin href="./assets/button-CzdTkRIf.js">
38
+ <link rel="modulepreload" crossorigin href="./assets/copy-button-B3o1jpLu.js">
39
+ <link rel="modulepreload" crossorigin href="./assets/formatted-body-q_qzt1L-.js">
40
+ <link rel="modulepreload" crossorigin href="./assets/task-chip-DXfvXBDx.js">
41
+ <link rel="modulepreload" crossorigin href="./assets/badge-DCIT3Irj.js">
42
+ <link rel="modulepreload" crossorigin href="./assets/input-B6Q0NWCS.js">
43
43
  <link rel="modulepreload" crossorigin href="./assets/themes-DjSwmAZN.js">
44
44
  <link rel="stylesheet" crossorigin href="./assets/index-DcmG5gLR.css">
45
45
  </head>
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.120.1",
4
+ "version": "0.120.3",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -1,5 +1,5 @@
1
1
  import { createCommand } from "../commands-db";
2
- import { getAgent, getTask, listOrchestrators, sendMessage, updateTaskStatus } from "../db";
2
+ import { getAgent, getTask, listAgents, listOrchestrators, sendMessage, updateTaskStatus } from "../db";
3
3
  import { renderTemplate } from "../prompt-resolver";
4
4
  import { CLOSED_TASK_STATUS } from "./constants";
5
5
  import {
@@ -11,7 +11,7 @@ import {
11
11
  db,
12
12
  } from "./storage";
13
13
  import type { AutomationReconcileResult } from "./types";
14
- import type { AutomationRun, AutomationRunStatus, AutomationRuntimeBudget, Command, Message, Task } from "../types";
14
+ import type { AgentCard, AutomationRun, AutomationRunStatus, AutomationRuntimeBudget, Command, Message, Task } from "../types";
15
15
 
16
16
  export function reconcileAutomationRuns(now = Date.now()): AutomationReconcileResult[] {
17
17
  ensureAutomationTables();
@@ -41,6 +41,11 @@ export function reconcileAutomationRuns(now = Date.now()): AutomationReconcileRe
41
41
  if (!CLOSED_TASK_STATUS.has(task.status)) {
42
42
  const updated = getAutomationRun(run.id);
43
43
  if (!updated) continue;
44
+ const neverReadyOffline = maybeFailNeverReadyOfflineTarget(updated, task, now);
45
+ if (neverReadyOffline) {
46
+ results.push(neverReadyOffline);
47
+ continue;
48
+ }
44
49
  const timedOut = maybeTimeoutRuntimeBudget(updated, task, now);
45
50
  if (timedOut) {
46
51
  results.push(timedOut);
@@ -68,9 +73,8 @@ export function reconcileAutomationRuns(now = Date.now()): AutomationReconcileRe
68
73
  }
69
74
 
70
75
  function automationRunRuntimeMeta(run: AutomationRun, task: Task): Record<string, unknown> {
71
- const agentId = task.claimedBy ?? run.targetAgentId ?? run.spawnedAgentId;
76
+ const { agentId, agent } = resolveAutomationRunAgent(run, task);
72
77
  if (!agentId) return {};
73
- const agent = getAgent(agentId);
74
78
  const managed = listOrchestrators()
75
79
  .flatMap((orchestrator) => orchestrator.managedAgents.map((managedAgent) => ({ orchestrator, managedAgent })))
76
80
  .find(({ managedAgent }) => managedAgent.agentId === agentId);
@@ -103,6 +107,23 @@ function hasMetaChanges(current: Record<string, unknown>, patch: Record<string,
103
107
  return Object.entries(patch).some(([key, value]) => current[key] !== value);
104
108
  }
105
109
 
110
+ function resolveAutomationRunAgent(run: AutomationRun, task: Task): { agentId?: string; agent?: AgentCard } {
111
+ const explicitAgentId = task.claimedBy ?? run.targetAgentId ?? run.spawnedAgentId ?? stringMeta(run.meta, "runtimeAgentId");
112
+ if (explicitAgentId) return { agentId: explicitAgentId, agent: getAgent(explicitAgentId) ?? undefined };
113
+
114
+ const onDemandLabel = stringMeta(run.meta, "onDemandLabel");
115
+ const agent = listAgents().find((candidate) =>
116
+ (onDemandLabel !== undefined && candidate.label === onDemandLabel) ||
117
+ stringMeta(candidate.meta, "automationRunId") === run.id
118
+ );
119
+ return agent ? { agentId: agent.id, agent } : {};
120
+ }
121
+
122
+ function isOnDemandAutomationRun(run: AutomationRun): boolean {
123
+ if (run.meta.targetMode === "on_demand_agent") return true;
124
+ return getAutomation(run.automationId)?.targetPolicy.mode === "on_demand_agent";
125
+ }
126
+
106
127
  function runtimeBudgetForRun(run: AutomationRun): AutomationRuntimeBudget | undefined {
107
128
  const automation = getAutomation(run.automationId);
108
129
  if (!automation || automation.targetPolicy.mode !== "on_demand_agent") return undefined;
@@ -163,6 +184,50 @@ function maybeWarnRuntimeBudget(run: AutomationRun, task: Task, now: number): Me
163
184
  return message;
164
185
  }
165
186
 
187
+ function maybeFailNeverReadyOfflineTarget(run: AutomationRun, task: Task, now: number): AutomationReconcileResult | undefined {
188
+ if (!isOnDemandAutomationRun(run) || task.claimedBy) return undefined;
189
+ const { agentId, agent } = resolveAutomationRunAgent(run, task);
190
+ if (!agentId || !agent || agent.status !== "offline" || agent.ready) return undefined;
191
+
192
+ const reason = "Automation target went offline before becoming ready";
193
+ const taskResult = updateTaskStatus(task.id, {
194
+ status: "canceled",
195
+ agentId,
196
+ result: reason,
197
+ body: reason,
198
+ metadata: {
199
+ automationId: run.automationId,
200
+ automationRunId: run.id,
201
+ automationTargetOffline: true,
202
+ automationTargetNeverReady: true,
203
+ agentId,
204
+ },
205
+ });
206
+ const updatedTask = taskResult.task ?? getTask(task.id) ?? task;
207
+ updateRun(run.id, {
208
+ status: "failed",
209
+ finishedAt: now,
210
+ error: reason,
211
+ result: {
212
+ taskStatus: updatedTask.status,
213
+ automationTargetOffline: true,
214
+ automationTargetNeverReady: true,
215
+ agentId,
216
+ },
217
+ targetAgentId: agentId,
218
+ spawnedAgentId: run.spawnedAgentId ?? agentId,
219
+ meta: {
220
+ ...run.meta,
221
+ runtimeAgentId: agentId,
222
+ runtimeAgentStatus: agent.status,
223
+ runtimeAgentReady: agent.ready,
224
+ runtimeTargetOfflineAt: now,
225
+ runtimeTargetNeverReady: true,
226
+ },
227
+ }, now);
228
+ return { run: getAutomationRun(run.id)!, task: getTask(task.id) ?? updatedTask };
229
+ }
230
+
166
231
  function maybeTimeoutRuntimeBudget(run: AutomationRun, task: Task, now: number): AutomationReconcileResult | undefined {
167
232
  const budget = runtimeBudgetForRun(run);
168
233
  if (!budget) return undefined;
@@ -8,6 +8,7 @@ import { resolveCoordinatorAgentId } from "./steward-identity";
8
8
  import { stewardFallbackTarget } from "./config";
9
9
  import { handleLandedIssueLifecycle } from "./services/issue-lifecycle";
10
10
  import { handleLandedTaskLifecycle } from "./services/task-lifecycle";
11
+ import { runGit } from "./services/git-process";
11
12
  import type { WorkspaceMergePreview, WorkspaceRecord } from "./types";
12
13
 
13
14
  // #444 — branch.landed is the first rule-driven type: the on-main "merged" fan-out audience comes
@@ -46,7 +47,7 @@ export interface BranchLandedInput {
46
47
  }
47
48
 
48
49
  type BranchLandedLogFn = (message: string) => void;
49
- type LandedCommitSubjectResolver = (input: BranchLandedInput, log: BranchLandedLogFn) => string[] | undefined;
50
+ type LandedCommitSubjectResolver = (input: BranchLandedInput, log: BranchLandedLogFn) => string[] | undefined | Promise<string[] | undefined>;
50
51
 
51
52
  let landedCommitSubjectResolverForTests: LandedCommitSubjectResolver | undefined;
52
53
 
@@ -59,7 +60,7 @@ function cleanRef(value: string | undefined): string | undefined {
59
60
  return cleaned || undefined;
60
61
  }
61
62
 
62
- function landedCommitSubjects(input: BranchLandedInput, log: BranchLandedLogFn): string[] | undefined {
63
+ async function landedCommitSubjects(input: BranchLandedInput, log: BranchLandedLogFn): Promise<string[] | undefined> {
63
64
  if (landedCommitSubjectResolverForTests) return landedCommitSubjectResolverForTests(input, log);
64
65
  if (input.prLand) return undefined;
65
66
  const repoRoot = cleanRef(input.workspace.repoRoot);
@@ -67,17 +68,13 @@ function landedCommitSubjects(input: BranchLandedInput, log: BranchLandedLogFn):
67
68
  const tipSha = cleanRef(input.mergedSha);
68
69
  if (!repoRoot || !baseSha || !tipSha || baseSha === tipSha) return undefined;
69
70
  try {
70
- const proc = Bun.spawnSync(["git", "-C", repoRoot, "log", "--format=%B%x00", `${baseSha}..${tipSha}`], {
71
- stdin: "ignore",
72
- stdout: "pipe",
73
- stderr: "pipe",
74
- });
75
- if (proc.exitCode !== 0) {
76
- const error = Buffer.from(proc.stderr).toString("utf8").trim() || `exit ${proc.exitCode}`;
71
+ const { stdout, stderr, exitCode, timedOut } = await runGit(["-C", repoRoot, "log", "--format=%B%x00", `${baseSha}..${tipSha}`]);
72
+ if (exitCode !== 0) {
73
+ const error = timedOut ? "timed out" : stderr.trim() || `exit ${exitCode}`;
77
74
  log(`issue lifecycle commit range scan failed for ${input.workspace.id} (${baseSha.slice(0, 12)}..${tipSha.slice(0, 12)}): ${error}; falling back to tip subject`);
78
75
  return undefined;
79
76
  }
80
- const subjects = Buffer.from(proc.stdout).toString("utf8").split("\x00").map((msg) => msg.trim()).filter(Boolean);
77
+ const subjects = stdout.split("\x00").map((msg) => msg.trim()).filter(Boolean);
81
78
  return subjects.length > 0 ? subjects : undefined;
82
79
  } catch (error) {
83
80
  log(`issue lifecycle commit range scan failed for ${input.workspace.id} (${baseSha.slice(0, 12)}..${tipSha.slice(0, 12)}): ${errMessage(error)}; falling back to tip subject`);
@@ -94,6 +91,13 @@ function resolveLandedTask(workspace: Pick<WorkspaceRecord, "branch" | "ownerAge
94
91
  return task ? { taskId: task.id, taskLabel: task.title } : undefined;
95
92
  }
96
93
 
94
+ function landedNotificationIdempotencyKey(input: BranchLandedInput, landedTask: { taskId: number } | undefined, base: string): string | undefined {
95
+ const sha = cleanRef(input.mergedSha);
96
+ if (sha) return `branch-landed:${sha}`;
97
+ if (landedTask) return `branch-landed:task:${landedTask.taskId}:${base}`;
98
+ return undefined;
99
+ }
100
+
97
101
  /**
98
102
  * #239 — turn an authoritative land completion into a relay-driven push so the author
99
103
  * stops polling to learn it merged. Always emits the durable `branch.landed` event (the
@@ -108,8 +112,8 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
108
112
  const landedBranch = workspace.branch;
109
113
  const shortSha = input.mergedSha ? input.mergedSha.slice(0, 12) : undefined;
110
114
  const lifecycleLog = (message: string) => console.warn(message);
111
- const commitSubjects = landedCommitSubjects(input, lifecycleLog);
112
115
  const landedTask = resolveLandedTask(workspace);
116
+ const idempotencyKey = landedNotificationIdempotencyKey(input, landedTask, base);
113
117
 
114
118
  emitRelayEvent({
115
119
  type: "branch.landed",
@@ -151,6 +155,7 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
151
155
  // receipt accurate. Both are best-effort + isolated — a throw in either can never fail the merge
152
156
  // (it already landed before notifyBranchLanded runs). A non-task branch makes the Task drive a no-op.
153
157
  void (async () => {
158
+ const commitSubjects = await landedCommitSubjects(input, lifecycleLog);
154
159
  try {
155
160
  await handleLandedIssueLifecycle({ ...input, commitSubjects }, { log: lifecycleLog });
156
161
  } catch (error) {
@@ -208,6 +213,7 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
208
213
  continueLabel,
209
214
  }),
210
215
  payload: { ...payload, notification: "landed-success" },
216
+ idempotencyKey,
211
217
  replyExpected: false,
212
218
  },
213
219
  });
@@ -231,6 +237,7 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
231
237
  subject: `Merged to ${base}`,
232
238
  body: `🔀 ${branchLabel}${authorLabel} merged to \`${base}\`${shaLabel}${subjectLabel}.${publishLabel}`,
233
239
  payload: { ...payload, notification: "landed-success" },
240
+ idempotencyKey,
234
241
  replyExpected: false,
235
242
  },
236
243
  });
package/src/bus.ts CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  } from "agent-relay-sdk/protocol";
32
32
  import { errMessage, isRecord } from "agent-relay-sdk";
33
33
  import { getComponentAuth, isComponentAuthorizedFor, isAuthorized, isOriginAllowed, unauthorized } from "./security";
34
- import type { Command, ComponentToken, ContextState, Message, ProviderCapabilities, QuotaState, RegisterAgentInput, Task } from "./types";
34
+ import type { AgentCard, Command, ComponentToken, ContextState, Message, ProviderCapabilities, QuotaState, RegisterAgentInput, Task } from "./types";
35
35
 
36
36
  interface BusSocketData {
37
37
  kind: "bus";
@@ -503,36 +503,43 @@ function withRegistered(
503
503
  }
504
504
 
505
505
  function broadcastRelayEvent(event: RelayEvent): void {
506
+ const frame = JSON.stringify({
507
+ type: "event",
508
+ payload: {
509
+ seq: event.seq,
510
+ eventType: event.type,
511
+ source: event.source,
512
+ subject: event.subject,
513
+ data: event.data,
514
+ timestamp: event.timestamp,
515
+ },
516
+ });
517
+ const agentCache = new Map<string, AgentCard | null>();
506
518
  for (const conn of busConnections.values()) {
507
- if (!connectionWantsEvent(conn, event)) continue;
508
- send(conn.ws, {
509
- type: "event",
510
- payload: {
511
- seq: event.seq,
512
- eventType: event.type,
513
- source: event.source,
514
- subject: event.subject,
515
- data: event.data,
516
- timestamp: event.timestamp,
517
- },
518
- });
519
+ if (!connectionWantsEvent(conn, event, agentCache)) continue;
520
+ conn.ws.send(frame);
519
521
  }
520
522
  }
521
523
 
522
- function connectionWantsEvent(conn: BusConnection, event: RelayEvent): boolean {
524
+ function fanoutAgent(agentId: string, cache: Map<string, AgentCard | null>): AgentCard | null {
525
+ if (!cache.has(agentId)) cache.set(agentId, getAgent(agentId));
526
+ return cache.get(agentId) ?? null;
527
+ }
528
+
529
+ function connectionWantsEvent(conn: BusConnection, event: RelayEvent, agentCache: Map<string, AgentCard | null>): boolean {
523
530
  if (!matchesSubscription(conn.subscriptions, event.type)) return false;
531
+ if (event.type === "agent.status" && conn.agentId) return event.subject === conn.agentId || (typeof event.data.id === "string" && event.data.id === conn.agentId);
524
532
  if (event.type === "message.new" && conn.agentId) {
525
- return messageMatchesAgent(event.data as unknown as Message, conn.agentId, getAgent(conn.agentId));
533
+ return messageMatchesAgent(event.data as unknown as Message, conn.agentId, fanoutAgent(conn.agentId, agentCache));
526
534
  }
527
535
  if (event.type.startsWith("task.") && conn.agentId) {
528
536
  const target = typeof event.data.target === "string" ? event.data.target : "";
529
- return targetMatchesAgent(target, conn.agentId, getAgent(conn.agentId));
537
+ return targetMatchesAgent(target, conn.agentId, fanoutAgent(conn.agentId, agentCache));
530
538
  }
531
539
  if (event.type.startsWith("command.")) {
532
540
  const command = isRecord(event.data.command) ? event.data.command : undefined;
533
541
  if (!command) return true;
534
- const target = typeof command.target === "string" ? command.target : "";
535
- const source = typeof command.source === "string" ? command.source : "";
542
+ const target = typeof command.target === "string" ? command.target : "", source = typeof command.source === "string" ? command.source : "";
536
543
  return target === conn.componentId || target === conn.agentId || source === conn.componentId || source === conn.agentId;
537
544
  }
538
545
  return true;
@@ -1,12 +1,15 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { join } from "node:path";
2
3
  import { DB_SLOW_QUERY_MS } from "../config";
3
4
 
4
5
  // The shared SQLite handle. Set once by initDb() (schema.ts) and read everywhere
5
6
  // else through getDb(). Kept module-private so the only mutation path is setDb().
6
7
  let db: Database;
8
+ let dbPath: string | undefined;
7
9
 
8
- export function setDb(conn: Database): void {
10
+ export function setDb(conn: Database, path?: string): void {
9
11
  db = conn;
12
+ dbPath = path;
10
13
  }
11
14
 
12
15
  export function getDb(): Database {
@@ -14,6 +17,10 @@ export function getDb(): Database {
14
17
  return db;
15
18
  }
16
19
 
20
+ export function getDbPath(): string | undefined {
21
+ return dbPath;
22
+ }
23
+
17
24
  // Connection-scoped pragmas. journal_mode and auto_vacuum persist in the file;
18
25
  // the rest reset per connection, so they must be re-applied on every open.
19
26
  export function applyConnectionPragmas(conn: Database): void {
@@ -67,6 +74,51 @@ export function runDbMaintenance(options: { vacuum?: boolean } = {}): {
67
74
  return { analyzed: true, checkpointed: true, vacuumed };
68
75
  }
69
76
 
77
+ export async function runDbMaintenanceWorker<T>(operation: string, args: string[], signal?: AbortSignal): Promise<T> {
78
+ if (!dbPath || dbPath === ":memory:") {
79
+ throw new Error("database worker requires a file-backed database");
80
+ }
81
+ if (signal?.aborted) throw new Error("database worker aborted");
82
+ const proc = Bun.spawn([process.execPath, join(import.meta.dir, "maintenance-worker.ts"), operation, dbPath, ...args], {
83
+ stdin: "ignore",
84
+ stdout: "pipe",
85
+ stderr: "pipe",
86
+ });
87
+ let killTimer: Timer | undefined;
88
+ const abort = () => {
89
+ proc.kill("SIGTERM");
90
+ killTimer = setTimeout(() => proc.kill("SIGKILL"), 2_000);
91
+ };
92
+ signal?.addEventListener("abort", abort, { once: true });
93
+ try {
94
+ const [stdout, stderr, exitCode] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
95
+ if (signal?.aborted) throw new Error("database worker aborted");
96
+ if (exitCode !== 0) {
97
+ const detail = stderr.trim() || stdout.trim() || `exit ${exitCode}`;
98
+ throw new Error(`database worker ${operation} failed: ${detail}`);
99
+ }
100
+ return JSON.parse(stdout) as T;
101
+ } finally {
102
+ signal?.removeEventListener("abort", abort);
103
+ if (killTimer) clearTimeout(killTimer);
104
+ }
105
+ }
106
+
107
+ export async function runDbMaintenanceAsync(options: { vacuum?: boolean; signal?: AbortSignal } = {}): Promise<{
108
+ analyzed: boolean;
109
+ checkpointed: boolean;
110
+ vacuumed: boolean;
111
+ offThread: boolean;
112
+ }> {
113
+ if (!dbPath || dbPath === ":memory:") return { ...runDbMaintenance(options), offThread: false };
114
+ const result = await runDbMaintenanceWorker<{ analyzed: boolean; checkpointed: boolean; vacuumed: boolean }>(
115
+ "db-maintenance",
116
+ [options.vacuum ? "vacuum" : "no-vacuum"],
117
+ options.signal,
118
+ );
119
+ return { ...result, offThread: true };
120
+ }
121
+
70
122
  // Shared error types thrown across query domains and unwrapped at call sites.
71
123
  // `ValidationError` now lives in the SDK (agent-relay-sdk) as the single canonical
72
124
  // home shared with the orchestrator package; re-exported here so the ~120 server
@@ -0,0 +1,37 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { applyConnectionPragmas } from "./connection";
3
+
4
+ const [, , operation, dbPath, arg] = Bun.argv;
5
+
6
+ if (!operation || !dbPath) {
7
+ console.error("operation and db path are required");
8
+ process.exit(2);
9
+ }
10
+
11
+ const db = new Database(dbPath, { create: true });
12
+ try {
13
+ applyConnectionPragmas(db);
14
+ if (operation === "db-maintenance") {
15
+ db.run("ANALYZE");
16
+ db.run("PRAGMA optimize");
17
+ db.run("PRAGMA wal_checkpoint(TRUNCATE)");
18
+ let vacuumed = false;
19
+ if (arg === "vacuum") {
20
+ db.run("VACUUM");
21
+ vacuumed = true;
22
+ }
23
+ console.log(JSON.stringify({ analyzed: true, checkpointed: true, vacuumed }));
24
+ } else if (operation === "old-message-prune") {
25
+ const cutoff = Number(arg);
26
+ if (!Number.isFinite(cutoff)) throw new Error("old-message-prune requires a cutoff timestamp");
27
+ const prunedMessages = db.transaction(() => {
28
+ db.query("UPDATE messages SET reply_to = NULL WHERE reply_to IN (SELECT id FROM messages WHERE created_at < ?)").run(cutoff);
29
+ return db.query("DELETE FROM messages WHERE created_at < ?").run(cutoff).changes;
30
+ })();
31
+ console.log(JSON.stringify({ prunedMessages }));
32
+ } else {
33
+ throw new Error(`unknown maintenance operation: ${operation}`);
34
+ }
35
+ } finally {
36
+ db.close();
37
+ }
@@ -15,9 +15,9 @@ import {
15
15
  import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
16
16
  import { matchAgents } from "../agent-ref";
17
17
  import { getAgent } from "./agents.ts";
18
- import { getMessage, sendMessageWithResult } from "./messages.ts";
18
+ import { sendMessageWithResult } from "./messages.ts";
19
19
  import { deleteArtifactLinksForEntity } from "./artifacts.ts";
20
- import { getDb, timedQuery } from "./connection.ts";
20
+ import { getDb, getDbPath, runDbMaintenanceWorker, timedQuery } from "./connection.ts";
21
21
  import { legacyChannelTargets, isChannelAgentId } from "./delivery.ts";
22
22
  import { messageTaskClosed } from "./claim-sql.ts";
23
23
  import { MSG_SELECT, rowToMessage } from "./mappers.ts";
@@ -393,25 +393,58 @@ function relayConversationId(message: Message): string | undefined {
393
393
  return stringField(replyContext?.conversationId) ?? stringField(conversation?.id);
394
394
  }
395
395
 
396
- function isCoveredByLaterAgentResponse(message: Message, agentId: string): boolean {
396
+ function messagesCoveredByLaterAgentResponses(messages: Message[], agentId: string): Set<number> {
397
+ if (messages.length === 0) return new Set();
397
398
  // Order by id, not created_at: ids are monotonic insertion order, so this is
398
399
  // robust when a reply lands in the same millisecond as the message it covers
399
400
  // (created_at > … strictly would miss it, leaving the message wrongly pending).
400
- const replies = (getDb().query(`
401
- ${MSG_SELECT}
402
- WHERE m.from_agent = ?
403
- AND m.id > ?
404
- ORDER BY m.id ASC
405
- LIMIT 200
406
- `).all(agentId, message.id) as any[]).map(rowToMessage);
407
-
408
- const conversationId = relayConversationId(message);
409
- return replies.some((reply) => {
410
- if (reply.replyTo === message.id) return true;
411
- if (message.threadId !== undefined && reply.threadId === message.threadId) return true;
412
- if (!message.channel || reply.channel !== message.channel || !conversationId) return false;
413
- return relayConversationId(reply) === conversationId;
414
- });
401
+ const minMessageId = Math.min(...messages.map((message) => message.id));
402
+ const replies = getDb().query(`
403
+ SELECT id, thread_id, channel, reply_to, payload
404
+ FROM messages INDEXED BY idx_msg_from_id
405
+ WHERE from_agent = ?
406
+ AND id > ?
407
+ ORDER BY id ASC
408
+ `).all(agentId, minMessageId) as Array<{ id: number; thread_id: number | null; channel: string | null; reply_to: number | null; payload: string }>;
409
+ if (replies.length === 0) return new Set();
410
+
411
+ const covered = new Set<number>();
412
+ const conversationsById = new Map<number, string | undefined>();
413
+ for (const message of messages) conversationsById.set(message.id, relayConversationId(message));
414
+
415
+ for (const message of messages) {
416
+ const conversationId = conversationsById.get(message.id);
417
+ for (const reply of replies) {
418
+ if (reply.id <= message.id) continue;
419
+ if (reply.reply_to === message.id) {
420
+ covered.add(message.id);
421
+ break;
422
+ }
423
+ if (message.threadId !== undefined && reply.thread_id === message.threadId) {
424
+ covered.add(message.id);
425
+ break;
426
+ }
427
+ if (!message.channel || reply.channel !== message.channel || !conversationId) continue;
428
+ if (relayConversationId({
429
+ id: reply.id,
430
+ from: agentId,
431
+ to: "",
432
+ body: "",
433
+ kind: "chat",
434
+ channel: reply.channel ?? undefined,
435
+ replyTo: reply.reply_to ?? undefined,
436
+ threadId: reply.thread_id ?? undefined,
437
+ payload: parseJson(reply.payload ?? "{}", {}),
438
+ meta: {},
439
+ readBy: [],
440
+ createdAt: 0,
441
+ }) === conversationId) {
442
+ covered.add(message.id);
443
+ break;
444
+ }
445
+ }
446
+ }
447
+ return covered;
415
448
  }
416
449
 
417
450
  function replyObligationFromMessage(message: Message, agentId: string): ReplyObligation {
@@ -433,11 +466,20 @@ function replyObligationFromMessage(message: Message, agentId: string): ReplyObl
433
466
  export function listPendingReplyObligations(agentId: string, limit = 20): ReplyObligation[] {
434
467
  const scanLimit = Math.max(limit * 5, 50);
435
468
  const rows = timedQuery("listPendingReplyObligations", () => getDb().query(`
436
- ${MSG_SELECT}
437
- WHERE EXISTS (
438
- SELECT 1 FROM message_reads mr
439
- WHERE mr.message_id = m.id AND mr.agent_id = ?
440
- )
469
+ SELECT m.*, (
470
+ SELECT json_group_array(agent_id) FROM message_reads WHERE message_id = m.id
471
+ ) AS read_by_agents, (
472
+ SELECT json_group_array(json_object(
473
+ 'messageId', message_id,
474
+ 'actorId', actor_id,
475
+ 'emoji', emoji,
476
+ 'createdAt', created_at,
477
+ 'updatedAt', updated_at
478
+ )) FROM message_reactions WHERE message_id = m.id
479
+ ) AS reactions_json
480
+ FROM message_reads mr INDEXED BY idx_mr_agent
481
+ JOIN messages m ON m.id = mr.message_id
482
+ WHERE mr.agent_id = ?
441
483
  AND m.from_agent != ?
442
484
  AND NOT EXISTS (
443
485
  SELECT 1 FROM messages reply
@@ -447,10 +489,12 @@ export function listPendingReplyObligations(agentId: string, limit = 20): ReplyO
447
489
  ORDER BY m.created_at ASC
448
490
  LIMIT ?
449
491
  `).all(agentId, agentId, agentId, scanLimit) as any[]);
450
- return rows
492
+ const candidates = rows
451
493
  .map(rowToMessage)
452
- .filter(messageRequiresReply)
453
- .filter((message) => !isCoveredByLaterAgentResponse(message, agentId))
494
+ .filter(messageRequiresReply);
495
+ const covered = messagesCoveredByLaterAgentResponses(candidates, agentId);
496
+ return candidates
497
+ .filter((message) => !covered.has(message.id))
454
498
  .slice(0, limit)
455
499
  .map((message) => replyObligationFromMessage(message, agentId));
456
500
  }
@@ -484,25 +528,6 @@ export function channelObligationAnsweredBy(
484
528
  return open.length === 1 ? open[0]!.messageId : null;
485
529
  }
486
530
 
487
- /**
488
- * Spawn report-back linking (#492): a spawned worker's actual task is delivered as the provider
489
- * prompt, while `seedSpawnReplyObligation` creates a short Stop-hook contract message so the worker
490
- * cannot go idle without reporting back. Workers commonly send their natural completion report
491
- * directly to the spawning coordinator; when there is exactly one open spawn obligation from that
492
- * coordinator, that send is the answer. Link it to the obligation at send time so the Stop hook does
493
- * not nag the worker into re-sending the same verdict through `/reply`.
494
- */
495
- export function spawnObligationAnsweredBy(
496
- agentId: string,
497
- spawnerAgentId: string,
498
- ): number | null {
499
- const open = listPendingReplyObligations(agentId, 50).filter((obligation) => {
500
- if (obligation.from !== spawnerAgentId) return false;
501
- return getMessage(obligation.messageId)?.payload?.source === "spawn-obligation";
502
- });
503
- return open.length === 1 ? open[0]!.messageId : null;
504
- }
505
-
506
531
  /**
507
532
  * #573 — spawned children no longer get a report-back reply obligation. Their turn-final
508
533
  * response is promoted server-side to the spawn-parent by lineage, so creating a separate
@@ -605,6 +630,10 @@ export function getLatestMessageId(): number {
605
630
 
606
631
  export function pruneOldMessages(maxAgeMs: number): number {
607
632
  const cutoff = Date.now() - maxAgeMs;
633
+ return pruneOldMessagesBefore(cutoff);
634
+ }
635
+
636
+ function pruneOldMessagesBefore(cutoff: number): number {
608
637
  return getDb().transaction(() => {
609
638
  getDb()
610
639
  .query("UPDATE messages SET reply_to = NULL WHERE reply_to IN (SELECT id FROM messages WHERE created_at < ?)")
@@ -614,3 +643,15 @@ export function pruneOldMessages(maxAgeMs: number): number {
614
643
  .run(cutoff).changes;
615
644
  })();
616
645
  }
646
+
647
+ export async function pruneOldMessagesAsync(maxAgeMs: number, options: { signal?: AbortSignal } = {}): Promise<{
648
+ prunedMessages: number;
649
+ offThread: boolean;
650
+ }> {
651
+ const cutoff = Date.now() - maxAgeMs;
652
+ if (!getDbPath() || getDbPath() === ":memory:") {
653
+ return { prunedMessages: pruneOldMessagesBefore(cutoff), offThread: false };
654
+ }
655
+ const result = await runDbMaintenanceWorker<{ prunedMessages: number }>("old-message-prune", [String(cutoff)], options.signal);
656
+ return { ...result, offThread: true };
657
+ }
@@ -411,6 +411,7 @@ export function applyMigrations(): void {
411
411
  getDb().run("ALTER TABLE messages ADD COLUMN payload TEXT NOT NULL DEFAULT '{}'");
412
412
  }
413
413
  getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_kind ON messages(kind)");
414
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_from_id ON messages(from_agent, id)");
414
415
  getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_from_kind_id ON messages(from_agent, kind, id)"); getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_analytics_created ON messages(created_at, kind, from_agent, claimable, reply_to)"); getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_session_analytics ON messages(kind, created_at, json_extract(payload, '$.session.type'), json_extract(payload, '$.session.label'), json_extract(payload, '$.session.turnId'))");
415
416
  // (from_agent, idempotency_key) powers findMessageByIdempotencyKey — the retry-dedup lookup
416
417
  // AND the session-step in-place upsert (Codex tool running→completed / streamed reasoning),