@edspencer/paddock 0.0.1 → 0.57.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 (296) hide show
  1. package/README.md +69 -0
  2. package/install-notice.mjs +29 -0
  3. package/package.json +58 -5
  4. package/packages/server/dist/adoptable.d.ts +127 -0
  5. package/packages/server/dist/adoptable.js +315 -0
  6. package/packages/server/dist/app.d.ts +67 -0
  7. package/packages/server/dist/app.js +325 -0
  8. package/packages/server/dist/archive.d.ts +42 -0
  9. package/packages/server/dist/archive.js +125 -0
  10. package/packages/server/dist/attachments-config.d.ts +114 -0
  11. package/packages/server/dist/attachments-config.js +165 -0
  12. package/packages/server/dist/attachments-hint.d.ts +53 -0
  13. package/packages/server/dist/attachments-hint.js +140 -0
  14. package/packages/server/dist/attachments.d.ts +33 -0
  15. package/packages/server/dist/attachments.js +117 -0
  16. package/packages/server/dist/auth.d.ts +84 -0
  17. package/packages/server/dist/auth.js +247 -0
  18. package/packages/server/dist/background.d.ts +36 -0
  19. package/packages/server/dist/background.js +137 -0
  20. package/packages/server/dist/bind-safety.d.ts +56 -0
  21. package/packages/server/dist/bind-safety.js +51 -0
  22. package/packages/server/dist/brand.d.ts +44 -0
  23. package/packages/server/dist/brand.js +88 -0
  24. package/packages/server/dist/chat-dto.d.ts +210 -0
  25. package/packages/server/dist/chat-dto.js +308 -0
  26. package/packages/server/dist/cli/import-chats.d.ts +21 -0
  27. package/packages/server/dist/cli/import-chats.js +197 -0
  28. package/packages/server/dist/cli/paddock.d.ts +2 -0
  29. package/packages/server/dist/cli/paddock.js +247 -0
  30. package/packages/server/dist/concurrency.d.ts +57 -0
  31. package/packages/server/dist/concurrency.js +71 -0
  32. package/packages/server/dist/config.d.ts +538 -0
  33. package/packages/server/dist/config.js +625 -0
  34. package/packages/server/dist/curation-config.d.ts +55 -0
  35. package/packages/server/dist/curation-config.js +64 -0
  36. package/packages/server/dist/environment-prompt.d.ts +52 -0
  37. package/packages/server/dist/environment-prompt.js +61 -0
  38. package/packages/server/dist/event-bus.d.ts +87 -0
  39. package/packages/server/dist/event-bus.js +48 -0
  40. package/packages/server/dist/git.d.ts +128 -0
  41. package/packages/server/dist/git.js +446 -0
  42. package/packages/server/dist/github-auth.d.ts +44 -0
  43. package/packages/server/dist/github-auth.js +169 -0
  44. package/packages/server/dist/herdctl-agent-config.d.ts +98 -0
  45. package/packages/server/dist/herdctl-agent-config.js +331 -0
  46. package/packages/server/dist/herdctl-agent-names.d.ts +172 -0
  47. package/packages/server/dist/herdctl-agent-names.js +228 -0
  48. package/packages/server/dist/herdctl-jobs-index.d.ts +65 -0
  49. package/packages/server/dist/herdctl-jobs-index.js +225 -0
  50. package/packages/server/dist/herdctl-jobs.d.ts +98 -0
  51. package/packages/server/dist/herdctl-jobs.js +225 -0
  52. package/packages/server/dist/herdctl.d.ts +623 -0
  53. package/packages/server/dist/herdctl.js +1279 -0
  54. package/packages/server/dist/hook-config.d.ts +217 -0
  55. package/packages/server/dist/hook-config.js +224 -0
  56. package/packages/server/dist/http-bytes.d.ts +24 -0
  57. package/packages/server/dist/http-bytes.js +52 -0
  58. package/packages/server/dist/index.d.ts +1 -0
  59. package/packages/server/dist/index.js +14 -0
  60. package/packages/server/dist/instance-config.d.ts +143 -0
  61. package/packages/server/dist/instance-config.js +385 -0
  62. package/packages/server/dist/localcommand.d.ts +23 -0
  63. package/packages/server/dist/localcommand.js +140 -0
  64. package/packages/server/dist/management-auth.d.ts +99 -0
  65. package/packages/server/dist/management-auth.js +165 -0
  66. package/packages/server/dist/management-config.d.ts +164 -0
  67. package/packages/server/dist/management-config.js +286 -0
  68. package/packages/server/dist/management-mcp-server.d.ts +20 -0
  69. package/packages/server/dist/management-mcp-server.js +95 -0
  70. package/packages/server/dist/management-metadata.d.ts +67 -0
  71. package/packages/server/dist/management-metadata.js +56 -0
  72. package/packages/server/dist/management-ops.d.ts +110 -0
  73. package/packages/server/dist/management-ops.js +449 -0
  74. package/packages/server/dist/management-policy.d.ts +172 -0
  75. package/packages/server/dist/management-policy.js +259 -0
  76. package/packages/server/dist/message-provenance.d.ts +112 -0
  77. package/packages/server/dist/message-provenance.js +260 -0
  78. package/packages/server/dist/models.d.ts +164 -0
  79. package/packages/server/dist/models.js +208 -0
  80. package/packages/server/dist/openapi.d.ts +66 -0
  81. package/packages/server/dist/openapi.js +183 -0
  82. package/packages/server/dist/parent-detach.d.ts +30 -0
  83. package/packages/server/dist/parent-detach.js +109 -0
  84. package/packages/server/dist/preload.d.ts +43 -0
  85. package/packages/server/dist/preload.js +55 -0
  86. package/packages/server/dist/project-files.d.ts +76 -0
  87. package/packages/server/dist/project-files.js +191 -0
  88. package/packages/server/dist/project-mime.d.ts +35 -0
  89. package/packages/server/dist/project-mime.js +55 -0
  90. package/packages/server/dist/project-paths.d.ts +88 -0
  91. package/packages/server/dist/project-paths.js +126 -0
  92. package/packages/server/dist/project-types.d.ts +316 -0
  93. package/packages/server/dist/project-types.js +48 -0
  94. package/packages/server/dist/projects.d.ts +202 -0
  95. package/packages/server/dist/projects.js +894 -0
  96. package/packages/server/dist/queued-message.d.ts +35 -0
  97. package/packages/server/dist/queued-message.js +116 -0
  98. package/packages/server/dist/read-state.d.ts +46 -0
  99. package/packages/server/dist/read-state.js +139 -0
  100. package/packages/server/dist/recovery-config.d.ts +111 -0
  101. package/packages/server/dist/recovery-config.js +105 -0
  102. package/packages/server/dist/recovery.d.ts +262 -0
  103. package/packages/server/dist/recovery.js +490 -0
  104. package/packages/server/dist/resume-drain.d.ts +122 -0
  105. package/packages/server/dist/resume-drain.js +158 -0
  106. package/packages/server/dist/route-context.d.ts +168 -0
  107. package/packages/server/dist/route-context.js +39 -0
  108. package/packages/server/dist/route-errors.d.ts +8 -0
  109. package/packages/server/dist/route-errors.js +13 -0
  110. package/packages/server/dist/routes/chats.d.ts +12 -0
  111. package/packages/server/dist/routes/chats.js +1406 -0
  112. package/packages/server/dist/routes/git.d.ts +13 -0
  113. package/packages/server/dist/routes/git.js +236 -0
  114. package/packages/server/dist/routes/mcp.d.ts +83 -0
  115. package/packages/server/dist/routes/mcp.js +250 -0
  116. package/packages/server/dist/routes/meta.d.ts +15 -0
  117. package/packages/server/dist/routes/meta.js +447 -0
  118. package/packages/server/dist/routes/projects.d.ts +25 -0
  119. package/packages/server/dist/routes/projects.js +736 -0
  120. package/packages/server/dist/routes/triggers.d.ts +21 -0
  121. package/packages/server/dist/routes/triggers.js +324 -0
  122. package/packages/server/dist/routes/workspace-mount.d.ts +48 -0
  123. package/packages/server/dist/routes/workspace-mount.js +32 -0
  124. package/packages/server/dist/routes.d.ts +26 -0
  125. package/packages/server/dist/routes.js +29 -0
  126. package/packages/server/dist/run-provenance.d.ts +109 -0
  127. package/packages/server/dist/run-provenance.js +206 -0
  128. package/packages/server/dist/runs.d.ts +92 -0
  129. package/packages/server/dist/runs.js +72 -0
  130. package/packages/server/dist/schedule-config.d.ts +68 -0
  131. package/packages/server/dist/schedule-config.js +75 -0
  132. package/packages/server/dist/schedule-session.d.ts +34 -0
  133. package/packages/server/dist/schedule-session.js +120 -0
  134. package/packages/server/dist/self-mcp-descriptions.d.ts +20 -0
  135. package/packages/server/dist/self-mcp-descriptions.js +145 -0
  136. package/packages/server/dist/self-mcp-read.d.ts +15 -0
  137. package/packages/server/dist/self-mcp-read.js +97 -0
  138. package/packages/server/dist/self-mcp-triggers.d.ts +30 -0
  139. package/packages/server/dist/self-mcp-triggers.js +144 -0
  140. package/packages/server/dist/self-mcp-types.d.ts +248 -0
  141. package/packages/server/dist/self-mcp-types.js +10 -0
  142. package/packages/server/dist/self-mcp-util.d.ts +73 -0
  143. package/packages/server/dist/self-mcp-util.js +157 -0
  144. package/packages/server/dist/self-mcp-write.d.ts +35 -0
  145. package/packages/server/dist/self-mcp-write.js +215 -0
  146. package/packages/server/dist/self-mcp.d.ts +95 -0
  147. package/packages/server/dist/self-mcp.js +448 -0
  148. package/packages/server/dist/send-file-mcp.d.ts +46 -0
  149. package/packages/server/dist/send-file-mcp.js +281 -0
  150. package/packages/server/dist/session-hub.d.ts +186 -0
  151. package/packages/server/dist/session-hub.js +291 -0
  152. package/packages/server/dist/spawn-capability.d.ts +99 -0
  153. package/packages/server/dist/spawn-capability.js +106 -0
  154. package/packages/server/dist/star.d.ts +26 -0
  155. package/packages/server/dist/star.js +94 -0
  156. package/packages/server/dist/start.d.ts +1 -0
  157. package/packages/server/dist/start.js +21 -0
  158. package/packages/server/dist/subagents.d.ts +337 -0
  159. package/packages/server/dist/subagents.js +626 -0
  160. package/packages/server/dist/sweep.d.ts +122 -0
  161. package/packages/server/dist/sweep.js +696 -0
  162. package/packages/server/dist/tooldetails.d.ts +33 -0
  163. package/packages/server/dist/tooldetails.js +466 -0
  164. package/packages/server/dist/transcribe.d.ts +69 -0
  165. package/packages/server/dist/transcribe.js +119 -0
  166. package/packages/server/dist/transcripts.d.ts +42 -0
  167. package/packages/server/dist/transcripts.js +164 -0
  168. package/packages/server/dist/trigger-config.d.ts +278 -0
  169. package/packages/server/dist/trigger-config.js +417 -0
  170. package/packages/server/dist/trigger-runtime.d.ts +94 -0
  171. package/packages/server/dist/trigger-runtime.js +82 -0
  172. package/packages/server/dist/trigger-session.d.ts +34 -0
  173. package/packages/server/dist/trigger-session.js +122 -0
  174. package/packages/server/dist/triggers.d.ts +83 -0
  175. package/packages/server/dist/triggers.js +124 -0
  176. package/packages/server/dist/trusted-proxy.d.ts +57 -0
  177. package/packages/server/dist/trusted-proxy.js +157 -0
  178. package/packages/server/dist/turn-notice.d.ts +154 -0
  179. package/packages/server/dist/turn-notice.js +271 -0
  180. package/packages/server/dist/unread.d.ts +36 -0
  181. package/packages/server/dist/unread.js +123 -0
  182. package/packages/server/dist/usage.d.ts +66 -0
  183. package/packages/server/dist/usage.js +243 -0
  184. package/packages/server/dist/wake-injection.d.ts +156 -0
  185. package/packages/server/dist/wake-injection.js +136 -0
  186. package/packages/server/dist/whisper-local.d.ts +9 -0
  187. package/packages/server/dist/whisper-local.js +122 -0
  188. package/packages/server/dist/ws-context.d.ts +158 -0
  189. package/packages/server/dist/ws-context.js +1 -0
  190. package/packages/server/dist/ws-protocol.d.ts +423 -0
  191. package/packages/server/dist/ws-protocol.js +251 -0
  192. package/packages/server/dist/ws-self-mcp.d.ts +52 -0
  193. package/packages/server/dist/ws-self-mcp.js +28 -0
  194. package/packages/server/dist/ws-triggers.d.ts +29 -0
  195. package/packages/server/dist/ws-triggers.js +261 -0
  196. package/packages/server/dist/ws-turn.d.ts +86 -0
  197. package/packages/server/dist/ws-turn.js +751 -0
  198. package/packages/server/dist/ws.d.ts +62 -0
  199. package/packages/server/dist/ws.js +1024 -0
  200. package/packages/server/package.json +6 -0
  201. package/packages/web/dist/assets/InstanceConfigPage-Bypd_quL.js +2 -0
  202. package/packages/web/dist/assets/MarkdownRenderer-Co8w9iuX.js +30 -0
  203. package/packages/web/dist/assets/ProjectRedirect-DXrA24Cy.js +2 -0
  204. package/packages/web/dist/assets/ProjectView-B9opP7Sv.js +13 -0
  205. package/packages/web/dist/assets/ProjectsGrid-D2cwb2jY.js +2 -0
  206. package/packages/web/dist/assets/arc-B--FIRB8.js +2 -0
  207. package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-B6dRWZZ6.js +37 -0
  208. package/packages/web/dist/assets/bash-I8pq0VWm.js +2 -0
  209. package/packages/web/dist/assets/blockDiagram-GPEHLZMM-DLxFAvvf.js +133 -0
  210. package/packages/web/dist/assets/c-D2F1sQUL.js +2 -0
  211. package/packages/web/dist/assets/c4Diagram-AAUBKEIU-CNee5ks4.js +11 -0
  212. package/packages/web/dist/assets/channel-Bw0A52CB.js +2 -0
  213. package/packages/web/dist/assets/chunk-2J33WTMH-DObw03Xl.js +2 -0
  214. package/packages/web/dist/assets/chunk-4BX2VUAB-CH2SR6nN.js +2 -0
  215. package/packages/web/dist/assets/chunk-55IACEB6-WlVpegT3.js +2 -0
  216. package/packages/web/dist/assets/chunk-727SXJPM-g8GT9Rgd.js +207 -0
  217. package/packages/web/dist/assets/chunk-AQP2D5EJ-DbN4PsDF.js +232 -0
  218. package/packages/web/dist/assets/chunk-FMBD7UC4-DSN3BjFG.js +16 -0
  219. package/packages/web/dist/assets/chunk-ND2GUHAM-D9R4le0z.js +2 -0
  220. package/packages/web/dist/assets/chunk-QZHKN3VN-D4YgRaKH.js +2 -0
  221. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-itf_kt-q.js +2 -0
  222. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-itf_kt-q.js +2 -0
  223. package/packages/web/dist/assets/core-CysJ2gLj.js +4 -0
  224. package/packages/web/dist/assets/cose-bilkent-S5V4N54A-D1PB0KNu.js +2 -0
  225. package/packages/web/dist/assets/cpp-Bm3ii6kY.js +2 -0
  226. package/packages/web/dist/assets/csharp-BNpuYZMG.js +2 -0
  227. package/packages/web/dist/assets/css-DazXZka4.js +2 -0
  228. package/packages/web/dist/assets/cytoscape.esm-CUqq0XTU.js +332 -0
  229. package/packages/web/dist/assets/dagre-BM42HDAG-C6Uuq-dq.js +5 -0
  230. package/packages/web/dist/assets/defaultLocale-DX6XiGOO.js +2 -0
  231. package/packages/web/dist/assets/diagram-2AECGRRQ-C9JvQ425.js +44 -0
  232. package/packages/web/dist/assets/diagram-5GNKFQAL-Cu6pdh2d.js +11 -0
  233. package/packages/web/dist/assets/diagram-KO2AKTUF-PmXcUQhY.js +4 -0
  234. package/packages/web/dist/assets/diagram-LMA3HP47-9ww6_9Iq.js +25 -0
  235. package/packages/web/dist/assets/diagram-OG6HWLK6-DWQVjv_n.js +25 -0
  236. package/packages/web/dist/assets/erDiagram-TEJ5UH35-6F7xTuff.js +86 -0
  237. package/packages/web/dist/assets/flowDiagram-I6XJVG4X-SCiGu7m6.js +163 -0
  238. package/packages/web/dist/assets/format-yf7Vf9kR.js +2 -0
  239. package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BNpPwxkL.js +293 -0
  240. package/packages/web/dist/assets/gitGraphDiagram-PVQCEYII-C0gyAJjW.js +107 -0
  241. package/packages/web/dist/assets/go-BPx_y7j8.js +2 -0
  242. package/packages/web/dist/assets/graph-BlwPajkw.js +2 -0
  243. package/packages/web/dist/assets/index-B-YuzkF6.js +71 -0
  244. package/packages/web/dist/assets/index-CvGiQddO.css +1 -0
  245. package/packages/web/dist/assets/infoDiagram-5YYISTIA-CJ2CFPfy.js +3 -0
  246. package/packages/web/dist/assets/ini-B3BVgEsG.js +2 -0
  247. package/packages/web/dist/assets/init-Gi6I4Gst.js +2 -0
  248. package/packages/web/dist/assets/ishikawaDiagram-YF4QCWOH-Cs0D32Ry.js +71 -0
  249. package/packages/web/dist/assets/java-B5VKqtH2.js +2 -0
  250. package/packages/web/dist/assets/javascript-BKRaQes9.js +2 -0
  251. package/packages/web/dist/assets/journeyDiagram-JHISSGLW-Bc9fuI2i.js +140 -0
  252. package/packages/web/dist/assets/json-DIYVocXf.js +2 -0
  253. package/packages/web/dist/assets/kanban-definition-UN3LZRKU-D_tC7QfC.js +90 -0
  254. package/packages/web/dist/assets/katex-C5jXJg4s.js +258 -0
  255. package/packages/web/dist/assets/kotlin-Cw-eF_bm.js +3 -0
  256. package/packages/web/dist/assets/lastTab-CU1zsF0j.js +2 -0
  257. package/packages/web/dist/assets/layout-Boy8vhSP.js +2 -0
  258. package/packages/web/dist/assets/linear-BzGHkF9K.js +2 -0
  259. package/packages/web/dist/assets/mermaid.core-DlbsYzE-.js +310 -0
  260. package/packages/web/dist/assets/mindmap-definition-RKZ34NQL-Bxeh-ToD.js +97 -0
  261. package/packages/web/dist/assets/ordinal-Cboi1Yqb.js +2 -0
  262. package/packages/web/dist/assets/php-CRdd4dfS.js +3 -0
  263. package/packages/web/dist/assets/pieDiagram-4H26LBE5-CDmilo8S.js +31 -0
  264. package/packages/web/dist/assets/python-kSlZHhJb.js +2 -0
  265. package/packages/web/dist/assets/quadrantDiagram-W4KKPZXB-D14-JyE9.js +8 -0
  266. package/packages/web/dist/assets/queued-LamEU0GH.js +2 -0
  267. package/packages/web/dist/assets/requirementDiagram-4Y6WPE33-CJ1PUGu-.js +85 -0
  268. package/packages/web/dist/assets/ruby-7T4ktxyv.js +2 -0
  269. package/packages/web/dist/assets/rust-HC3MBUhY.js +2 -0
  270. package/packages/web/dist/assets/sankeyDiagram-5OEKKPKP-BkUK73W0.js +41 -0
  271. package/packages/web/dist/assets/scss-DZLA7YXz.js +2 -0
  272. package/packages/web/dist/assets/sequenceDiagram-3UESZ5HK-sbcvsuLp.js +163 -0
  273. package/packages/web/dist/assets/sql-BC88IN-V.js +2 -0
  274. package/packages/web/dist/assets/stateDiagram-AJRCARHV-DV-7BdHK.js +2 -0
  275. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BlkGdXKW.js +2 -0
  276. package/packages/web/dist/assets/swift-Dn8kKO_1.js +2 -0
  277. package/packages/web/dist/assets/timeline-definition-PNZ67QCA-C5su21G_.js +121 -0
  278. package/packages/web/dist/assets/typescript-C2FFdlUC.js +2 -0
  279. package/packages/web/dist/assets/vennDiagram-CIIHVFJN-JNkIJAaQ.js +35 -0
  280. package/packages/web/dist/assets/wardley-L42UT6IY-qcz6t2vR.js +174 -0
  281. package/packages/web/dist/assets/wardleyDiagram-YWT4CUSO-CMrOIZ-c.js +79 -0
  282. package/packages/web/dist/assets/xml-BXBhIUeX.js +2 -0
  283. package/packages/web/dist/assets/xychartDiagram-2RQKCTM6-C9q6Oy9u.js +8 -0
  284. package/packages/web/dist/assets/yaml-drsDnIKT.js +2 -0
  285. package/packages/web/dist/favicon.ico +0 -0
  286. package/packages/web/dist/fonts/inter-latin.woff2 +0 -0
  287. package/packages/web/dist/fonts/jetbrains-mono-latin.woff2 +0 -0
  288. package/packages/web/dist/icons/apple-touch-icon.png +0 -0
  289. package/packages/web/dist/icons/favicon-16.png +0 -0
  290. package/packages/web/dist/icons/favicon-32.png +0 -0
  291. package/packages/web/dist/icons/icon-192.png +0 -0
  292. package/packages/web/dist/icons/icon-512.png +0 -0
  293. package/packages/web/dist/icons/maskable-512.png +0 -0
  294. package/packages/web/dist/index.html +56 -0
  295. package/packages/web/dist/manifest.webmanifest +32 -0
  296. package/packages/web/dist/sw.js +139 -0
@@ -0,0 +1,1279 @@
1
+ /**
2
+ * HerdctlService — REAL wrapper around @herdctl/core's FleetManager.
3
+ *
4
+ * This module is NOT a stub: it imports and constructs the public
5
+ * @herdctl/core API (paddock depends on ^5.27.0). It is the single seam between
6
+ * paddock and herdctl, so that any remaining gaps in the public API are isolated
7
+ * here. See docs/INTEGRATION.md for the verified call-by-call contract.
8
+ *
9
+ * As of @herdctl/core 5.11.0 the four prior app-layer workarounds are GONE —
10
+ * paddock now uses the first-class APIs:
11
+ *
12
+ * - **Runtime agents:** `fleet.addAgent({...})` / `fleet.removeAgent(name)`
13
+ * register/unregister a project's keeper + sweeper agents in memory. No more
14
+ * generate-per-agent-yaml + regenerate-herdctl.yaml + `reload()` dance. The
15
+ * FleetManager boots from a minimal zero-agent config (fleet + defaults only)
16
+ * and every agent is added programmatically. `addAgent` validates the config,
17
+ * deep-merges fleet `defaults`, resolves the working_directory to an absolute
18
+ * path, and wires it into the scheduler so it is immediately triggerable and
19
+ * shows up in `getFleetStatus()` / `getAgentInfo()`.
20
+ *
21
+ * - **Sessions:** `fleet.getAgentSessions(name, {limit})` and
22
+ * `fleet.getAgentSessionMessages(name, sessionId)` derive the agent's
23
+ * working_directory + Docker mode from the loaded config — no hand-rolled
24
+ * SessionDiscoveryService keyed on working dirs.
25
+ *
26
+ * - **Streaming:** `trigger(agent, schedule?, { onMessage, prompt, resume })`.
27
+ * `onMessage(msg: SDKMessage)` fires per SDK message; the returned
28
+ * TriggerResult carries the final `sessionId`. New chat = `resume: null`;
29
+ * resume = `resume: <sessionId>`. This is the `batch` drive-mode path; the
30
+ * default `session` mode instead drives `openChatSession`, which ALWAYS runs
31
+ * the SDK runtime regardless of the agent's `runtime` field (see #585).
32
+ *
33
+ * - **Delete / rename a chat:** `fleet.deleteSession(name, sessionId)` removes
34
+ * the transcript (and invalidates the discovery cache), and
35
+ * `fleet.setSessionName(name, sessionId, customName)` sets a custom name —
36
+ * both keyed by agent name, no deep imports.
37
+ *
38
+ * Freshness note: `fleet.getAgentSessions` uses the FleetManager's internal
39
+ * SessionDiscoveryService, which has a 30s directory cache. A brand-new
40
+ * project's transcript dir does not exist until its first turn, and the
41
+ * discovery service does NOT cache a missing directory — so the first session
42
+ * of a project surfaces immediately. `deleteSession`/`setSessionName` invalidate
43
+ * the cache internally. The one nuance vs. the prior code: a *second* new chat
44
+ * created in an already-listed project within 30s may take up to the cache TTL
45
+ * to appear (the prior code invalidated a private cache we can no longer reach).
46
+ * That last nuance is now closed too: core exposes `fleet.invalidateSessions(name)`
47
+ * and paddock calls it after each turn (`ws-turn.ts`) and around fork/promote.
48
+ */
49
+ import { FleetManager, countPendingAsyncQueueEntries, getCliSessionFile, } from "@herdctl/core";
50
+ import { consumeResumedTurn, consumeBackgroundTurns } from "./resume-drain.js";
51
+ import { createSDKMessageHandler } from "@herdctl/chat";
52
+ import { promises as fs } from "node:fs";
53
+ import { randomUUID } from "node:crypto";
54
+ import path from "node:path";
55
+ import { DEFAULT_MODEL } from "./models.js";
56
+ import { ensureProjectChats, projectChatsDir } from "./transcripts.js";
57
+ import { triggerRunsOnOwnAgent, isCuratorTrigger, } from "./trigger-config.js";
58
+ import { buildAgentConfig, buildSweeperConfig, sweeperWorkingDir, buildTriggerConfig, ensureConfigFile as writeBootConfigFile, } from "./herdctl-agent-config.js";
59
+ import * as jobs from "./herdctl-jobs.js";
60
+ import { JobsDirIndex } from "./herdctl-jobs-index.js";
61
+ import { AdoptableIndex } from "./adoptable.js";
62
+ /**
63
+ * The name/visibility helpers + tool/model constants are pure (they only read
64
+ * their arguments, no fleet/cfg/session state), so they live in a sibling module
65
+ * for readability + isolated unit-testing (issue #403). Re-exported here so every
66
+ * external importer keeps resolving them via `./herdctl.js` unchanged.
67
+ */
68
+ export { keeperAgentName, keeperSlugFromAgent, SWEEPER_PREFIX, sweeperAgentName, HOOK_AGENT_PREFIX, hookAgentName, TRIGGER_AGENT_PREFIX, triggerAgentName, visibleProjectAgentNames, BROWSER_MCP_TOOL, DENIED_TOOLS, browserMcpServers, KEEPER_MAX_CONCURRENT, KEEPER_SESSION_TIMEOUT, } from "./herdctl-agent-names.js";
69
+ import { keeperAgentName, sweeperAgentName, hookAgentName, triggerAgentName, visibleProjectAgentNames, } from "./herdctl-agent-names.js";
70
+ /**
71
+ * Slice a Claude Code transcript to the PREFIX up to and including the message
72
+ * turn anchored at `cutUuid` (issue #451 — fork/revert from a point). Returns the
73
+ * sliced JSONL, or null if no record carries that uuid.
74
+ *
75
+ * A logical turn spans several JSONL records: one assistant message is written as
76
+ * one record per content block (text, tool_use, …), all sharing one `message.id`
77
+ * but each with its own `uuid`; a tool_use is then answered by a `type:"user"`
78
+ * `tool_result` record. To keep the kept prefix internally consistent (no
79
+ * assistant tool_use left without its result, which would break resume), after
80
+ * the anchor record we greedily absorb (a) further records of the same
81
+ * `message.id` — the anchor message's sibling blocks — and (b) immediately
82
+ * following `type:"user"` tool_result records answering them, stopping at the
83
+ * next genuine new turn. This is exact for plain chat turns (the primary case);
84
+ * for deeply interleaved tool turns the boundary is a safe over-include.
85
+ *
86
+ * `endOnCompletedAssistant` (used by REVERT, #451): when the anchor is one of the
87
+ * USER's own prompts, don't keep it as a dangling un-answered trailing turn —
88
+ * Paddock's resume-drain would replay that as a phantom turn on the next resume
89
+ * and the model would read the prompt as stale backlog. Instead, land the cut on
90
+ * the last COMPLETED assistant turn before that prompt (a record with a terminal
91
+ * `stop_reason`), so "revert to one of my messages" rewinds to the AI's reply
92
+ * just before it and the next message continues cleanly. Fork leaves this off so
93
+ * a fork from a user message keeps it (the fork's keeper answers it).
94
+ */
95
+ const TERMINAL_STOP = new Set(["end_turn", "stop_sequence", "max_tokens"]);
96
+ export function sliceTranscriptAtUuid(raw, cutUuid, endOnCompletedAssistant = false) {
97
+ const lines = raw.split("\n");
98
+ const meta = lines.map((ln) => {
99
+ const t = ln.trim();
100
+ if (!t)
101
+ return null;
102
+ try {
103
+ const o = JSON.parse(t);
104
+ const content = o.message?.content;
105
+ const hasToolResult = Array.isArray(content) &&
106
+ content.some((b) => b?.type === "tool_result");
107
+ const stop = o.message?.stop_reason;
108
+ return {
109
+ uuid: typeof o.uuid === "string" ? o.uuid : undefined,
110
+ type: o.type,
111
+ mid: typeof o.message?.id === "string" ? o.message.id : undefined,
112
+ hasToolResult,
113
+ // A completed assistant turn — safe boundary to end a reverted transcript on.
114
+ terminalAssistant: o.type === "assistant" && typeof stop === "string" && TERMINAL_STOP.has(stop),
115
+ };
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ });
121
+ let idx = -1;
122
+ let anchorMid;
123
+ for (let i = 0; i < meta.length; i++) {
124
+ if (meta[i]?.uuid === cutUuid) {
125
+ idx = i;
126
+ anchorMid = meta[i].mid;
127
+ break;
128
+ }
129
+ }
130
+ if (idx === -1)
131
+ return null;
132
+ // Revert to one of the user's own prompts → rewind to the last completed
133
+ // assistant turn before it, rather than leaving a dangling un-answered prompt.
134
+ if (endOnCompletedAssistant && meta[idx].type === "user" && !meta[idx].hasToolResult) {
135
+ for (let b = idx - 1; b >= 0; b--) {
136
+ if (meta[b]?.terminalAssistant) {
137
+ return lines.slice(0, b + 1).join("\n") + "\n";
138
+ }
139
+ }
140
+ // No completed assistant turn precedes it (e.g. the first user message): fall
141
+ // through to the normal inclusive slice — there's no cleaner boundary.
142
+ }
143
+ let end = idx;
144
+ for (let j = idx + 1; j < meta.length; j++) {
145
+ const m = meta[j];
146
+ if (m === null) {
147
+ // Blank line — includable filler; keep scanning.
148
+ end = j;
149
+ continue;
150
+ }
151
+ if (anchorMid && m.mid === anchorMid) {
152
+ end = j; // a sibling content block of the anchor's assistant message
153
+ continue;
154
+ }
155
+ if (m.type === "user" && m.hasToolResult) {
156
+ end = j; // a tool_result answering a kept tool_use
157
+ continue;
158
+ }
159
+ break;
160
+ }
161
+ return lines.slice(0, end + 1).join("\n") + "\n";
162
+ }
163
+ export class HerdctlService {
164
+ cfg;
165
+ fleet = null;
166
+ started = false;
167
+ /**
168
+ * The model currently registered for each agent (keyed by agent name). Lets
169
+ * `ensureAgentModel` skips a re-registration when the
170
+ * requested model already matches the live agent config.
171
+ *
172
+ * SINGLE-USER CAVEAT: the keeper is one shared agent per project, so the
173
+ * model is last-write-wins across concurrent chats of the same project — if
174
+ * two chats of the same project pick different models, whichever triggered
175
+ * last wins for both. Acceptable for paddock's single-user POC; a clean
176
+ * per-trigger model override is a herdctl follow-up.
177
+ */
178
+ agentModels = new Map();
179
+ /**
180
+ * Each agent's working directory (the keeper's cwd), recorded at
181
+ * registration. Used to resolve the `claude` CLI transcript for a resume so we
182
+ * can measure its pending async-input-queue depth (the resume self-interrupt
183
+ * fix's residue gate — see {@link residueDepthFor} + `./resume-drain.ts`).
184
+ */
185
+ agentWorkingDirs = new Map();
186
+ /**
187
+ * Live session-mode turns keyed by the synthetic turn id we hand back as the
188
+ * `jobId` (Paddock#111). `openChatSession` creates no herdctl job record, so
189
+ * there's no core `jobId` to cancel via `cancelJob`; instead we register the
190
+ * live {@link RuntimeSession} here and Stop → `session.interrupt()` (see
191
+ * {@link cancel}). Entries are removed when the turn's stream ends.
192
+ */
193
+ liveSessions = new Map();
194
+ /**
195
+ * Incremental index of `<stateDir>/jobs`, backing the unread-badge reads
196
+ * (`lastTurnCompletedAt*`) — one per service, reused across requests so a warm
197
+ * scan only parses records it has never seen (#529). See
198
+ * `./herdctl-jobs-index.ts` for the invariant it rests on and its invalidation.
199
+ *
200
+ * Built lazily on first read, like the job-record helpers it replaced: unit
201
+ * tests construct this service against a partial config with no `stateDir`,
202
+ * and must keep being able to.
203
+ */
204
+ jobsIndexOrNull = null;
205
+ get jobsIndex() {
206
+ return (this.jobsIndexOrNull ??= new JobsDirIndex(this.cfg.stateDir));
207
+ }
208
+ constructor(cfg) {
209
+ this.cfg = cfg;
210
+ }
211
+ /**
212
+ * The environment system prompt to append to a keeper turn (issue #635), or
213
+ * `undefined` when the instance opted out (`environmentPrompt: ""`) — herdctl
214
+ * treats a falsy `systemPromptAppend` as "don't touch the system prompt", so
215
+ * an opted-out instance is byte-identical to pre-#635 behaviour.
216
+ *
217
+ * Read from `this.cfg` here rather than taken as a per-turn option **on
218
+ * purpose**. Every user-visible turn shape — a human send, a slash command, a
219
+ * scheduled wake, a spawned sub-chat, a `driveMode: batch` trigger — funnels
220
+ * through {@link chat}, {@link chatSession} or {@link runCommand}, so applying
221
+ * it at that choke point makes coverage structural. Threading it through
222
+ * `ChatTurnOptions` instead would make it one more thing five call sites have
223
+ * to remember, which is exactly how `isSidechainMessage` ended up wired into
224
+ * 1 of 5 turn paths.
225
+ *
226
+ * It is orthogonal to `nativeSystemPrompt`: herdctl appends to whatever prompt
227
+ * the agent has, so this rides on top of the native preset AND of Paddock's
228
+ * terse replace prompt (see `buildSystemPrompt` in core's sdk-runtime, which
229
+ * folds an append into a preset's `append` field or concatenates onto a
230
+ * literal string).
231
+ */
232
+ get environmentPromptAppend() {
233
+ return this.cfg.environmentPrompt || undefined;
234
+ }
235
+ /**
236
+ * The same value for the **CLI** runtime (`driveMode: batch`), where it is NOT
237
+ * always safe to send.
238
+ *
239
+ * herdctl's CLI runtime has no `--append-system-prompt`; it folds
240
+ * `systemPromptAppend` into the single `--system-prompt` flag
241
+ * (`cli-runtime.ts`), and `--system-prompt` REPLACES Claude Code's preset:
242
+ *
243
+ * agent.system_prompt && append → --system-prompt "<agent>\n\n<append>" ✅ appends
244
+ * append only → --system-prompt "<append>" ❌ REPLACES
245
+ *
246
+ * Paddock sets `agent.system_prompt` only when `nativeSystemPrompt` is false
247
+ * (herdctl-agent-config.ts), so on a DEFAULT batch instance the second branch
248
+ * fires and a two-rule environment note would silently become the agent's
249
+ * entire system prompt — the whole coding preset gone. That is the same
250
+ * replace-vs-append trap #635 warns about, one layer down.
251
+ *
252
+ * So: on the CLI path we send the append only when it will genuinely be
253
+ * appended. A default (native) batch instance is left byte-identical to
254
+ * pre-#635 and simply doesn't get the environment prompt — a missing hint is
255
+ * survivable; a missing coding prompt is not. The SDK runtime has no such
256
+ * problem (it folds the text into the preset's `append` field), and the SDK
257
+ * runtime is what every chat actually uses: `openChatSession` hard-codes it,
258
+ * and `session` is the default drive mode. Batch is the legacy path.
259
+ *
260
+ * Lifting this needs `--append-system-prompt` support in core's CLI runtime;
261
+ * tracked upstream in edspencer/herdctl.
262
+ */
263
+ get environmentPromptAppendForCli() {
264
+ return this.cfg.nativeSystemPrompt ? undefined : this.environmentPromptAppend;
265
+ }
266
+ /**
267
+ * Construct + initialize the FleetManager against a minimal zero-agent
268
+ * config (fleet + defaults only). Agents are then registered programmatically
269
+ * via `fleet.addAgent(...)` — a keeper + sweeper for each workspace, the root
270
+ * included. No per-agent yaml files; no `reload()`.
271
+ */
272
+ async init(projects) {
273
+ await this.ensureConfigFile();
274
+ this.fleet = new FleetManager({
275
+ configPath: this.cfg.herdctlConfigPath,
276
+ stateDir: this.cfg.stateDir,
277
+ // Per-deployment gate for programmatic schedule mutation (issue #265 / DD-7,
278
+ // herdctl#376). OFF by default: an instance opts in (PADDOCK_SCHEDULE_MUTATION)
279
+ // before herdctl's runtime schedule-mutation APIs can add or remove a
280
+ // schedule at runtime. Declaring schedules in project.yaml is unaffected.
281
+ allowScheduleMutation: this.cfg.scheduleMutationEnabled,
282
+ // Hand the engine the SAME Claude home paddock resolved (#588). Omitting
283
+ // it makes the engine fall back to `os.homedir()/.claude` while paddock
284
+ // honours `CLAUDE_HOME`, so discovery, adoption and paddock's `.chats/`
285
+ // symlinks would resolve against different directories — sessions list
286
+ // from one home and open empty from the other. Masked whenever the two
287
+ // coincide, which is why it must be explicit.
288
+ claudeHomePath: this.cfg.claudeHome,
289
+ });
290
+ await this.fleet.initialize();
291
+ // Register a keeper + sweeper for every existing project, recording each
292
+ // keeper's resolved model so per-chat overrides can short-circuit later.
293
+ // ensureProjectChats relocates this project's transcripts into <dir>/.chats
294
+ // (migrating any existing real transcript dir) so the project is portable.
295
+ for (const project of projects) {
296
+ // Symlink Claude's encoded transcript dir for the keeper's cwd (workingDir)
297
+ // at the .chats store in the metadata dir — so repo-backed transcripts stay
298
+ // out of the external checkout's working tree (issue #187). For a notebook
299
+ // project workingDir === dir, so this is the classic behavior.
300
+ await ensureProjectChats(project.workingDir, project.dir, this.cfg.claudeHome);
301
+ await this.ensureSweeperHome(project);
302
+ await this.fleet.addAgent(this.keeperAgentConfig(project), { replace: true });
303
+ await this.fleet.addAgent(this.sweeperAgentConfig(project), { replace: true });
304
+ // Register each EVENT trigger as its own agent `trigger-<slug>-<name>` (Epic T /
305
+ // T1). Schedule triggers ride the keeper's forwarded `schedules` block (above);
306
+ // webhook triggers are reserved.
307
+ await this.registerTriggerAgents(project);
308
+ this.agentModels.set(keeperAgentName(project.slug), project.model ?? DEFAULT_MODEL);
309
+ this.agentWorkingDirs.set(keeperAgentName(project.slug), project.workingDir);
310
+ }
311
+ }
312
+ /** Start the scheduler (keeper agents currently have no schedules, but this
313
+ * keeps the fleet "running" for status + future cron curators). */
314
+ async start() {
315
+ if (!this.fleet)
316
+ throw new Error("HerdctlService not initialized");
317
+ if (this.started)
318
+ return;
319
+ await this.fleet.start();
320
+ this.started = true;
321
+ // Warm the jobs index off the critical path (#529). It is incremental, so
322
+ // the expensive full parse only ever happens once per process — doing it at
323
+ // boot rather than inside the user's first `GET /api/projects` is the
324
+ // difference between a ~1.1 s first paint and a ~40 ms one. Fire-and-forget:
325
+ // a failure here is invisible, since every read rebuilds what it needs.
326
+ void this.jobsIndex.read().catch(() => undefined);
327
+ }
328
+ async stop() {
329
+ if (this.fleet && this.started) {
330
+ await this.fleet.stop({ waitForJobs: false }).catch(() => undefined);
331
+ this.started = false;
332
+ }
333
+ }
334
+ /** Expose the raw FleetManager (events, advanced ops) to callers that need it. */
335
+ get manager() {
336
+ if (!this.fleet)
337
+ throw new Error("HerdctlService not initialized");
338
+ return this.fleet;
339
+ }
340
+ /**
341
+ * Is a `claude` SUBPROCESS still alive on this session at the SDK/reaper layer
342
+ * (issue #397)? Consults herdctl's `SessionReaper` via the fleet's session
343
+ * lifecycle manager: `true` while the reaper is keeping a session open for a
344
+ * killed background task (keepAlive) or holding it through its re-invocation
345
+ * grace. Paddock's recovery engine treats this as a busy signal so it never
346
+ * fires a COMPETING resume into a session a prior subprocess still holds live
347
+ * (which the SDK resolves by interrupting the in-flight turn). Null-safe: with
348
+ * no fleet, no session-lifecycle manager (batch mode / reaper disabled), or any
349
+ * error, returns `false` — the pre-#397 behaviour.
350
+ */
351
+ isSdkSessionLive(sessionId) {
352
+ try {
353
+ return this.fleet?.getSessionLifecycle()?.reaper.isSessionLive(sessionId) ?? false;
354
+ }
355
+ catch {
356
+ return false;
357
+ }
358
+ }
359
+ async fleetStatus() {
360
+ return this.manager.getFleetStatus();
361
+ }
362
+ async agents() {
363
+ return this.manager.getAgentInfo();
364
+ }
365
+ /**
366
+ * Register (or replace) a project's keeper + sweeper agents at runtime. Uses
367
+ * `fleet.addAgent` (replace: true), so a re-create or rename of the same slug
368
+ * is idempotent. The new agents are immediately triggerable and visible in
369
+ * fleet status — no yaml + reload round-trip.
370
+ */
371
+ async ensureProjectAgent(project) {
372
+ if (!this.fleet)
373
+ return;
374
+ await ensureProjectChats(project.workingDir, project.dir, this.cfg.claudeHome);
375
+ await this.ensureSweeperHome(project);
376
+ await this.fleet.addAgent(this.keeperAgentConfig(project), { replace: true });
377
+ await this.fleet.addAgent(this.sweeperAgentConfig(project), { replace: true });
378
+ // Re-register the project's EVENT-trigger agents (Epic T / T1) from the live
379
+ // record (schedule triggers ride the keeper's forwarded `schedules` block above;
380
+ // webhook triggers are reserved).
381
+ await this.registerTriggerAgents(project);
382
+ // Record the keeper's resolved model so per-chat overrides can detect a
383
+ // no-op. ensureProjectAgent re-registers at project.model (the persisted
384
+ // default), so a model change via PATCH takes effect here too.
385
+ this.agentModels.set(keeperAgentName(project.slug), project.model ?? DEFAULT_MODEL);
386
+ this.agentWorkingDirs.set(keeperAgentName(project.slug), project.workingDir);
387
+ }
388
+ /**
389
+ * Register (or replace) every trigger a project declares that runs on its OWN scoped
390
+ * agent `trigger-<slug>-<name>` (Epic T). Idempotent (`addAgent` replace:true). Which triggers
391
+ * get a scoped agent is decided by {@link triggerRunsOnOwnAgent}: EVENT triggers
392
+ * always; SCHEDULE triggers only when they declare a non-empty `run.tools` allow-list
393
+ * (T2 — an unscoped schedule still rides the keeper's forwarded `schedules` block and
394
+ * runs as the keeper); webhook triggers are reserved (no ingress). A scoped schedule
395
+ * ALSO keeps its keeper `schedules` entry — that is only the cron TIMING; the fired
396
+ * turn executes on this scoped agent. A trigger-less project is a no-op. Called at
397
+ * boot ({@link init}) and on every {@link ensureProjectAgent}.
398
+ */
399
+ async registerTriggerAgents(project) {
400
+ if (!this.fleet)
401
+ return;
402
+ for (const [name, trigger] of Object.entries(project.triggers ?? {})) {
403
+ // The post-turn CURATOR (event/afterTurn) trigger, T5, never runs as its own
404
+ // agent — SweepService executes it via the project's `sweeper-<slug>` agent.
405
+ // Registering a `trigger-<slug>-<name>` for it would be a dead, never-fired agent.
406
+ if (!triggerRunsOnOwnAgent(trigger) || isCuratorTrigger(trigger))
407
+ continue;
408
+ await this.fleet.addAgent(this.triggerAgentConfig(project, name, trigger), { replace: true });
409
+ }
410
+ }
411
+ /**
412
+ * Register (or replace) ONE trigger's scoped agent at runtime (Epic T) — the herdctl
413
+ * half of a trigger mutation (the Paddock half persists it via
414
+ * `ProjectStore.setTrigger`). Idempotent; immediately fireable. Consumed by
415
+ * {@link import("./triggers.js").TriggerService}. A no-op for a trigger that does NOT
416
+ * run on its own agent ({@link triggerRunsOnOwnAgent}) — an unscoped schedule arms via
417
+ * the keeper re-register; a webhook is reserved.
418
+ */
419
+ async ensureTriggerAgent(project, name, trigger) {
420
+ if (!this.fleet)
421
+ return;
422
+ // The curator (event/afterTurn) trigger, T5, is executed by SweepService (via the
423
+ // `sweeper-<slug>` agent), not as its own agent — so never register one for it.
424
+ if (!triggerRunsOnOwnAgent(trigger) || isCuratorTrigger(trigger))
425
+ return;
426
+ await this.fleet.addAgent(this.triggerAgentConfig(project, name, trigger), { replace: true });
427
+ }
428
+ /**
429
+ * Unregister one event trigger's agent at runtime (Epic T / T1) — the inverse of
430
+ * {@link ensureTriggerAgent}. Never throws if the agent is already gone.
431
+ */
432
+ async removeTriggerAgent(slug, name) {
433
+ if (!this.fleet)
434
+ return;
435
+ await this.fleet.removeAgent(triggerAgentName(slug, name)).catch(() => undefined);
436
+ }
437
+ /**
438
+ * Ensure a project's keeper agent is registered at `model`, re-registering it
439
+ * (addAgent replace:true) only when the model actually changed. Used by the
440
+ * WS chat path to honor a per-chat model override before triggering.
441
+ *
442
+ * No herdctl per-trigger model API exists yet, so a model override is applied
443
+ * by re-registering the (single, shared) keeper agent. See the `agentModels`
444
+ * single-user caveat: this is last-write-wins across concurrent chats of the
445
+ * same project.
446
+ */
447
+ async ensureAgentModel(project, model) {
448
+ if (!this.fleet)
449
+ return;
450
+ const name = keeperAgentName(project.slug);
451
+ if (this.agentModels.get(name) === model)
452
+ return;
453
+ await this.fleet.addAgent(this.keeperAgentConfig(project, model), { replace: true });
454
+ this.agentModels.set(name, model);
455
+ }
456
+ /**
457
+ * Force the FleetManager's session-discovery cache to drop its cached listing
458
+ * for an agent, so a brand-new chat surfaces immediately (rather than waiting
459
+ * out the 30s directory cache). New public API in @herdctl/core 5.12.0. No-op
460
+ * and never throws if the fleet isn't ready.
461
+ */
462
+ invalidateSessions(agentName) {
463
+ if (!this.fleet)
464
+ return;
465
+ this.fleet.invalidateSessions(agentName);
466
+ }
467
+ // --- native-chat adoption (#588) ---------------------------------------
468
+ /**
469
+ * Detector for a project's importable native Claude Code chats, with its own
470
+ * mtime-keyed cache. Lazy for the same reason {@link jobsIndex} is: unit tests
471
+ * construct this service against a partial config.
472
+ */
473
+ adoptableIndexOrNull = null;
474
+ get adoptableIndex() {
475
+ return (this.adoptableIndexOrNull ??= new AdoptableIndex(this.cfg.claudeHome, this.cfg.stateDir));
476
+ }
477
+ /** What `project` could import right now (see `./adoptable.ts`). */
478
+ async listAdoptable(project) {
479
+ return this.adoptableIndex.adoptableFor(this.manager, project, keeperAgentName(project.slug));
480
+ }
481
+ /**
482
+ * Drop the cached adoptable summary for a workspace (or all of them).
483
+ *
484
+ * The workspace key may be `""` (the root), so this forwards the argument
485
+ * as-is — `if (!slug)` here would silently clear the WRONG thing.
486
+ */
487
+ invalidateAdoptable(projectKey) {
488
+ this.adoptableIndexOrNull?.invalidate(projectKey);
489
+ }
490
+ /**
491
+ * Import a project's native Claude Code chats.
492
+ *
493
+ * With `sourceCwd`, imports that one source; without, every source detection
494
+ * currently offers. `mode` defaults to `copy`, which never mutates the user's
495
+ * original `~/.claude` transcripts and preserves their mtimes; `move` and
496
+ * `link` exist for the CLI and nothing in the web UI should reach them.
497
+ *
498
+ * ## Honouring the noise filter
499
+ *
500
+ * Detection withholds empty and slash-command-only transcripts, but the engine
501
+ * adopts a whole SOURCE — it has no "adopt these ids" call. Left alone, that
502
+ * means the header offers 2 chats and the import brings in 4, including a
503
+ * zero-byte stub. So anything the engine adopted that detection had withheld is
504
+ * released again ({@link FleetManager.unadoptSession}) and reported in
505
+ * `skipped` under the filter's own reason, and — in `copy` mode only — the copy
506
+ * this call just placed is removed.
507
+ *
508
+ * That deletion is deliberately narrow. It happens only when all of: the mode
509
+ * is `copy` (in `move` the original is GONE, so the copy is the user's only
510
+ * remaining copy and must never be touched); the engine reported the id as
511
+ * newly `adopted` in THIS call, so it placed the file here and did not find one
512
+ * (`destination-exists` is a skip, not an adopt); and the destination path
513
+ * differs from the source, so an in-place adoption can never delete an
514
+ * original. The result is that a withheld session is left exactly as it was
515
+ * before the import.
516
+ *
517
+ * Afterwards the herdctl session cache, the jobs index and the adoptable cache
518
+ * are all dropped, so the imported chats list on the very next request — the
519
+ * same trio {@link promoteSession} invalidates for the same reason.
520
+ */
521
+ async adoptChats(project, opts = {}) {
522
+ const agent = keeperAgentName(project.slug);
523
+ const mode = opts.mode ?? "copy";
524
+ const dryRun = opts.dryRun ?? false;
525
+ const summary = await this.listAdoptable(project);
526
+ const sources = opts.sourceCwd !== undefined
527
+ ? [opts.sourceCwd]
528
+ : summary.sources.map((s) => s.sourceCwd);
529
+ // sessionId -> why detection withheld it.
530
+ const withheld = new Map(summary.filtered.map((f) => [f.sessionId, f.reason]));
531
+ const adopted = [];
532
+ const skipped = [];
533
+ for (const fromWorkingDir of sources) {
534
+ // Sequential, not parallel: each source re-scans the adoption store, so a
535
+ // session offered by two sources is adopted by the first and reported
536
+ // `already-adopted` by the second instead of racing to place the same file.
537
+ const result = await this.manager.adoptSessionsFrom(agent, {
538
+ fromWorkingDir,
539
+ mode,
540
+ dryRun,
541
+ });
542
+ skipped.push(...result.skipped);
543
+ for (const sessionId of result.adopted) {
544
+ const reason = withheld.get(sessionId);
545
+ if (reason === undefined) {
546
+ adopted.push(sessionId);
547
+ continue;
548
+ }
549
+ skipped.push({ sessionId, reason });
550
+ if (dryRun)
551
+ continue;
552
+ await this.releaseWithheld(agent, project, fromWorkingDir, sessionId, mode);
553
+ }
554
+ }
555
+ // A dry run wrote nothing, so there is nothing to invalidate — and dropping
556
+ // caches would misrepresent it as a state change.
557
+ if (!dryRun && adopted.length > 0) {
558
+ this.invalidateSessions(agent);
559
+ this.jobsIndexOrNull?.invalidate();
560
+ this.invalidateAdoptable(project.slug);
561
+ }
562
+ // A session adopted from one source and then met again in the NEXT source
563
+ // (its fresh copy now sits in the project's own transcript folder) comes back
564
+ // as `already-adopted`. That is true per-source, but as a RESULT it reads as
565
+ // "imported 7, skipped 5" for an import of 7. A session this call imported is
566
+ // never also reported as skipped by it.
567
+ const done = new Set(adopted);
568
+ return { adopted, skipped: skipped.filter((s) => !done.has(s.sessionId)) };
569
+ }
570
+ /**
571
+ * Undo the adoption of a session detection had withheld, restoring the state
572
+ * that existed before this import. See {@link adoptChats} for why the file
573
+ * removal is safe and why it is limited to `copy` mode.
574
+ */
575
+ async releaseWithheld(agent, project, fromWorkingDir, sessionId, mode) {
576
+ await this.manager.unadoptSession(agent, sessionId).catch(() => undefined);
577
+ if (mode !== "copy")
578
+ return;
579
+ const home = this.cfg.claudeHome;
580
+ const placed = getCliSessionFile(project.workingDir, sessionId, home);
581
+ const origin = getCliSessionFile(fromWorkingDir, sessionId, home);
582
+ // In-place adoption placed nothing; `origin` IS the user's file.
583
+ if (path.resolve(placed) === path.resolve(origin))
584
+ return;
585
+ await fs.rm(placed, { force: true }).catch(() => undefined);
586
+ }
587
+ /**
588
+ * Unregister a project's keeper + sweeper (+ any event-hook) agents at runtime.
589
+ * Uses `fleet.removeAgent`, the inverse of ensureProjectAgent. Running jobs are
590
+ * unaffected; the scheduler stops triggering the removed agents. `hookNames` are
591
+ * the project's declared hook names (from its DTO) so their `hook-<slug>-<name>`
592
+ * agents are torn down too — caller passes `Object.keys(project.hooks ?? {})`.
593
+ */
594
+ async removeProjectAgent(slug, hookNames = [], triggerNames = []) {
595
+ if (!this.fleet)
596
+ return;
597
+ await this.fleet.removeAgent(keeperAgentName(slug)).catch(() => undefined);
598
+ await this.fleet.removeAgent(sweeperAgentName(slug)).catch(() => undefined);
599
+ for (const name of hookNames) {
600
+ await this.fleet.removeAgent(hookAgentName(slug, name)).catch(() => undefined);
601
+ }
602
+ // Event triggers (Epic T / T1) register their own `trigger-<slug>-<name>` agent —
603
+ // tear those down too; schedule/webhook trigger names contribute no agent, so
604
+ // removeAgent is a harmless no-op for them.
605
+ for (const name of triggerNames) {
606
+ await this.fleet.removeAgent(triggerAgentName(slug, name)).catch(() => undefined);
607
+ }
608
+ }
609
+ /**
610
+ * Delete a single chat (session) by agent name + session id. The FleetManager
611
+ * resolves the agent's working directory, removes the transcript JSONL, and
612
+ * invalidates the discovery cache so the list reflects it immediately.
613
+ * Validates the sessionId (rejects path traversal). Returns true if a
614
+ * transcript file was removed, false if none existed.
615
+ */
616
+ async deleteSession(agentName, sessionId) {
617
+ return this.manager.deleteSession(agentName, sessionId);
618
+ }
619
+ /**
620
+ * Set (or clear) a chat's custom display name. Writes through the fleet's
621
+ * shared SessionMetadataStore so a subsequent getAgentSessions reflects it
622
+ * immediately. Pass null/empty to clear.
623
+ */
624
+ async renameSession(agentName, sessionId, name) {
625
+ await this.manager.setSessionName(agentName, sessionId, name);
626
+ }
627
+ /**
628
+ * Trigger an agent with a prompt and stream output via onMessage.
629
+ * Returns the TriggerResult (carries the final sessionId).
630
+ */
631
+ async chat(agentName, opts) {
632
+ return this.manager.trigger(agentName, undefined, {
633
+ prompt: opts.prompt,
634
+ resume: opts.resume,
635
+ triggerType: opts.triggerType ?? "web",
636
+ onMessage: opts.onMessage,
637
+ onJobCreated: opts.onJobCreated,
638
+ injectedMcpServers: opts.injectedMcpServers,
639
+ // CLI runtime — see the getter: only safe when it will append, not replace.
640
+ systemPromptAppend: this.environmentPromptAppendForCli,
641
+ });
642
+ }
643
+ /**
644
+ * Pending async-input-queue depth of a resumed session's `claude` transcript —
645
+ * the residue gate for the resume self-interrupt drain (see `./resume-drain.ts`
646
+ * and {@link drainBacklogThenConsume}). Resolves the agent's CLI transcript from
647
+ * its recorded working directory. Returns 0 (→ no drain, original fast path) for
648
+ * an unknown agent or any read error, so a detection failure never changes turn
649
+ * behavior. Public so the ws.ts wake consumer can gate on it too.
650
+ */
651
+ async residueDepthFor(agentName, sessionId) {
652
+ const workingDir = this.agentWorkingDirs.get(agentName);
653
+ if (!workingDir)
654
+ return 0;
655
+ try {
656
+ return await countPendingAsyncQueueEntries(getCliSessionFile(workingDir, sessionId));
657
+ }
658
+ catch {
659
+ return 0;
660
+ }
661
+ }
662
+ /**
663
+ * Session-mode counterpart to {@link chat} (Paddock#111). Drives the turn
664
+ * through a persistent, herdctl-managed `openChatSession` (`manageLifecycle:
665
+ * true`) instead of a one-shot `trigger()`, so a `ScheduleWakeup` / `/loop` the
666
+ * turn leaves behind is captured by the reaper (herdctl#307) and re-fired
667
+ * through the scheduler — i.e. cross-turn autonomy actually works. The turn
668
+ * itself streams identically (same `onMessage` shape), so callers are unchanged.
669
+ *
670
+ * Contract differences from {@link chat}:
671
+ * - No herdctl job record exists, so the returned `jobId` is a synthetic turn
672
+ * id we register in {@link liveSessions}; Stop maps to `session.interrupt()`
673
+ * via {@link cancel}.
674
+ * - We do NOT `close()` the session: it's managed, so the reaper tears it down
675
+ * when idle (and captures its wakeups on the Stop hook). Per the core
676
+ * contract, the message stream ending IS the reap.
677
+ */
678
+ async chatSession(agentName, opts) {
679
+ const startedAt = new Date().toISOString();
680
+ const turnId = randomUUID();
681
+ const isResume = typeof opts.resume === "string";
682
+ let session;
683
+ try {
684
+ session = await this.manager.openChatSession(agentName, {
685
+ resume: opts.resume,
686
+ prompt: opts.prompt,
687
+ manageLifecycle: true,
688
+ injectedMcpServers: opts.injectedMcpServers,
689
+ systemPromptAppend: this.environmentPromptAppend,
690
+ // Stream assistant text token-by-token: the SDK emits `stream_event` /
691
+ // `text_delta` chunks that the translator surfaces as incremental
692
+ // `chat:response` frames (edspencer/herdctl#382, paddock#315).
693
+ includePartialMessages: true,
694
+ });
695
+ }
696
+ catch (err) {
697
+ return {
698
+ jobId: turnId,
699
+ agentName,
700
+ scheduleName: null,
701
+ startedAt,
702
+ prompt: opts.prompt,
703
+ success: false,
704
+ error: err instanceof Error ? err : new Error(String(err)),
705
+ };
706
+ }
707
+ this.liveSessions.set(turnId, session);
708
+ // Surface the synthetic id as the turn's jobId so the client renders Stop and
709
+ // a subsequent chat:cancel routes back to this live session.
710
+ opts.onJobCreated?.(turnId);
711
+ let sessionId = isResume ? opts.resume : null;
712
+ let success = false;
713
+ let error;
714
+ try {
715
+ // Gap A — survival parity. BOTH fresh and resume now consume via the
716
+ // NON-CLOSING consumeResumedTurn, which drives the stream with a manual
717
+ // iterator and stops on the primary turn's terminal `result` WITHOUT calling
718
+ // iterator.return() — leaving teardown to the reaper ("managed" session).
719
+ // The fresh path used to use `for await … break`, and a `break` invokes the
720
+ // iterator's `.return()`, which tears down the `claude` subprocess — killing
721
+ // any background task the fresh FIRST turn launched, bypassing the reaper's
722
+ // keepAlive (decideReap keeps a session alive while it holds background work).
723
+ // On a fresh session the async-input queue is empty, so residueProbe returns
724
+ // 0 and this breaks right after the first result exactly like the old fast
725
+ // path — just without the close, so a fresh-turn bg task now SURVIVES. A
726
+ // resume additionally drains any stale async-input backlog first (the #427
727
+ // resume self-interrupt fix). The reaper owns teardown after we return.
728
+ const consumed = await consumeResumedTurn(session, {
729
+ residueProbe: () => this.residueDepthFor(agentName, sessionId ?? opts.resume ?? ""),
730
+ onMessage: opts.onMessage,
731
+ onSessionId: (id) => {
732
+ sessionId = id;
733
+ },
734
+ log: (msg) =>
735
+ // eslint-disable-next-line no-console
736
+ console.log(`[resume-drain] chatSession ${agentName} (${sessionId ?? opts.resume}): ${msg}`),
737
+ });
738
+ success = consumed.success;
739
+ if (consumed.sessionId)
740
+ sessionId = consumed.sessionId;
741
+ // Gap B — keep delivering autonomous background-completion turns. The reaper
742
+ // may hold this (managed) session open because the turn launched continuous
743
+ // background work; when a task completes, its re-invocation turn arrives
744
+ // LATER on this SAME stream. Detach a consumer over the handed-back iterator
745
+ // so those turns reach the live UI (via onBackgroundMessage) instead of only
746
+ // landing in the transcript — and so the stream keeps advancing, feeding the
747
+ // reaper its `activity`/`background_tasks_changed` signals. Detached (`void`)
748
+ // so THIS call still returns the moment the primary turn is done; it ends
749
+ // itself when the reaper reaps the idle session (stream yields `done`).
750
+ if (opts.onBackgroundMessage) {
751
+ void consumeBackgroundTurns(consumed.iterator, consumed.pending, opts.onBackgroundMessage, opts.onBackgroundDone);
752
+ }
753
+ }
754
+ catch (err) {
755
+ error = err instanceof Error ? err : new Error(String(err));
756
+ }
757
+ finally {
758
+ this.liveSessions.delete(turnId);
759
+ }
760
+ return {
761
+ jobId: turnId,
762
+ agentName,
763
+ scheduleName: null,
764
+ startedAt,
765
+ prompt: opts.prompt,
766
+ success,
767
+ sessionId: sessionId ?? undefined,
768
+ error,
769
+ };
770
+ }
771
+ /**
772
+ * Register the consumer that drives scheduler-fired session wakes (Paddock#111
773
+ * gap 3). herdctl resumes the woken session (`manageLifecycle: true`) and hands
774
+ * it here; the handler must consume the stream onto the hub/transcript so the
775
+ * autonomous turn is visible even though no client is watching. A no-op if the
776
+ * fleet has no lifecycle manager. Safe to call before/after {@link init}.
777
+ */
778
+ onSessionWake(handler) {
779
+ this.manager.setSessionWakeHandler(handler);
780
+ }
781
+ /**
782
+ * Register the resolver herdctl calls to RE-ESTABLISH this session's in-process
783
+ * injected MCP servers when a scheduler-fired wake resumes an idle/reaped session
784
+ * (edspencer/herdctl#390, wired in @herdctl/core 5.22.1). The wake path drives the
785
+ * turn inside herdctl (a `ScheduleWakeup` / `/loop` / `CronCreate` re-fire), so it
786
+ * bypasses Paddock's per-turn injection — without this the resumed `claude`
787
+ * subprocess re-spawns with the `mcp__paddock*__*` tools "allowed" but UNBACKED,
788
+ * so they vanish from the catalog for the whole autonomous stretch (the "MCP flap").
789
+ * The resolver is SYNCHRONOUS (herdctl threads its result into `openChatSession`
790
+ * before the subprocess spawns) and MUST NOT throw — herdctl catches + logs a throw
791
+ * and degrades to no-injection, but we return `undefined` (safe no-injection default)
792
+ * rather than rely on that. A no-op if the fleet has no lifecycle manager; safe to
793
+ * call before/after {@link init}. Thin passthrough, mirroring {@link onSessionWake}.
794
+ */
795
+ setResolveInjectedMcpServers(resolve) {
796
+ this.manager.setResolveInjectedMcpServers(resolve);
797
+ }
798
+ /**
799
+ * Register the consumer that OWNS execution of a fired schedule (issue #265 /
800
+ * DD-1, herdctl#375). When set, herdctl routes every scheduler-fired trigger
801
+ * (interval / cron / forced "trigger now") here instead of running it headless
802
+ * via its built-in ScheduleExecutor — so Paddock runs the turn on its OWN hub /
803
+ * resume path (`startAgentTurn`), keeping the scheduled chat visible + re-attachable
804
+ * (and never `isSidechain`-hidden). A no-op fallback to headless applies if no
805
+ * handler is registered. Thin passthrough, mirroring {@link onSessionWake}.
806
+ */
807
+ onScheduleTrigger(handler) {
808
+ this.manager.setScheduleTriggerHandler(handler);
809
+ }
810
+ /**
811
+ * The runtime state of a project keeper's schedules (issue #266 / D4) — the
812
+ * live `ScheduleInfo` herdctl tracks (status, `lastRunAt`/`nextRunAt`,
813
+ * `lastError`) for the schedules Paddock forwarded into the keeper agent. The
814
+ * D4 UI merges this with the project.yaml declaration (which carries the
815
+ * Paddock-only `prompt`/`promptFile`/`resume_session`) for the list view. A
816
+ * schedule-less keeper (or one herdctl hasn't finished arming) yields `[]`;
817
+ * errors are swallowed to `[]` so the settings pane degrades to the declared
818
+ * definitions rather than failing to render.
819
+ */
820
+ async listAgentSchedules(project) {
821
+ const agent = keeperAgentName(project.slug);
822
+ const all = await this.manager.getSchedules().catch(() => []);
823
+ return all.filter((s) => s.agentName === agent);
824
+ }
825
+ /**
826
+ * Run a slash command (e.g. `/compact`) against an agent's chat session.
827
+ *
828
+ * Unlike {@link chat}, which sends the text as a one-shot prompt via
829
+ * `trigger()`, this drives herdctl's streaming session (`openChatSession`) —
830
+ * the only mode in which the SDK dispatches slash commands and exposes control
831
+ * requests. The command runs against the resumed `sessionId`, so `/compact`
832
+ * compacts the real chat history. SDK messages stream via `onMessage` (same
833
+ * shape as {@link chat}); resolves when the turn completes, then closes the
834
+ * session. Returns the resolved session id.
835
+ */
836
+ async runCommand(agentName, opts) {
837
+ const isResume = typeof opts.resume === "string";
838
+ const session = await this.manager.openChatSession(agentName, {
839
+ resume: opts.resume,
840
+ // Stream the command's assistant text token-by-token (paddock#315).
841
+ includePartialMessages: true,
842
+ // A slash command's output renders in the same chat bubble as any other
843
+ // turn, so it gets the same environment context (#635).
844
+ systemPromptAppend: this.environmentPromptAppend,
845
+ });
846
+ let sessionId = isResume ? opts.resume : null;
847
+ try {
848
+ // Gap A parity: BOTH branches consume via the non-closing consumeResumedTurn
849
+ // (no `for await … break`, whose break would iterator.return() and close the
850
+ // CLI mid-consume). Send the command first — it queues behind any stale
851
+ // async-input backlog the CLI replays — then consume with the queue-drain
852
+ // break rule so a backlog turn's `result` can't tear the command turn down.
853
+ // No backlog (the fresh case) → breaks right after the first result. NOTE:
854
+ // unlike chatSession, runCommand opens WITHOUT manageLifecycle, so the reaper
855
+ // does NOT own this session; we still close() it explicitly in `finally`, so
856
+ // a slash-command turn's teardown is unchanged — this only unifies HOW we
857
+ // stop consuming.
858
+ await session.send(opts.command);
859
+ const consumed = await consumeResumedTurn(session, {
860
+ residueProbe: () => this.residueDepthFor(agentName, sessionId ?? opts.resume ?? ""),
861
+ onMessage: opts.onMessage,
862
+ onSessionId: (id) => {
863
+ sessionId = id;
864
+ },
865
+ log: (msg) =>
866
+ // eslint-disable-next-line no-console
867
+ console.log(`[resume-drain] runCommand ${agentName} (${sessionId ?? opts.resume}): ${msg}`),
868
+ });
869
+ if (consumed.sessionId)
870
+ sessionId = consumed.sessionId;
871
+ }
872
+ catch {
873
+ // Stream error — fall through and let the caller surface completion.
874
+ }
875
+ finally {
876
+ await session.close();
877
+ }
878
+ return { sessionId };
879
+ }
880
+ /**
881
+ * Cancel a running turn (Stop button → WS chat:cancel). Handles BOTH drive
882
+ * modes off the single id the client holds as `jobId`:
883
+ * - **session mode** — the id is a synthetic turn id in {@link liveSessions};
884
+ * interrupt the live `RuntimeSession` (there is no herdctl job to cancel).
885
+ * - **batch mode** — the id is a real herdctl job id; abort it via `cancelJob`
886
+ * (which kills the CLI subprocess / aborts the SDK query).
887
+ *
888
+ * Returns `true` if something was actually cancelled. Errors are logged rather
889
+ * than silently swallowed (the previous `.catch(() => undefined)` hid every
890
+ * failure, so a broken Stop looked like a no-op).
891
+ */
892
+ async cancel(jobId) {
893
+ const live = this.liveSessions.get(jobId);
894
+ if (live) {
895
+ try {
896
+ await live.interrupt();
897
+ return true;
898
+ }
899
+ catch (err) {
900
+ console.warn(`[herdctl] session interrupt failed for ${jobId}:`, err);
901
+ return false;
902
+ }
903
+ }
904
+ try {
905
+ await this.manager.cancelJob(jobId);
906
+ return true;
907
+ }
908
+ catch (err) {
909
+ // JobNotFoundError is expected if the turn already finished; log others.
910
+ console.warn(`[herdctl] cancelJob failed for ${jobId}:`, err);
911
+ return false;
912
+ }
913
+ }
914
+ /**
915
+ * Run a project's sweeper (curator) agent with a fresh session and the given
916
+ * prompt. Used OUT OF BAND by SweepService — never from the user-chat path —
917
+ * so a sweep can never enqueue another sweep. resume:null forces a clean
918
+ * session each time (the sweep is stateless).
919
+ *
920
+ * The sweeper is tool-less and returns its result as plain assistant text
921
+ * (the two marked sections SweepService parses), so we accumulate the
922
+ * assistant text deltas via @herdctl/chat's shared translator (same pattern
923
+ * as ws.ts) and return them alongside the TriggerResult.
924
+ */
925
+ async runSweeper(slug, prompt) {
926
+ let text = "";
927
+ const translate = createSDKMessageHandler({
928
+ onText: (chunk) => {
929
+ if (chunk)
930
+ text += chunk;
931
+ },
932
+ });
933
+ const result = await this.manager.trigger(sweeperAgentName(slug), undefined, {
934
+ prompt,
935
+ resume: null,
936
+ triggerType: "manual",
937
+ onMessage: async (m) => {
938
+ // core's SDKMessage types `message` as `unknown` (wider) than the
939
+ // translator's structurally-narrower SDKMessage — same runtime object,
940
+ // cast across the package boundary.
941
+ await translate(m);
942
+ },
943
+ });
944
+ return { result, text };
945
+ }
946
+ /** Recent sessions for a project's keeper agent, used to build the sweep digest. */
947
+ async recentSessions(project, limit = 10) {
948
+ return this.manager.getAgentSessions(keeperAgentName(project.slug), { limit });
949
+ }
950
+ /**
951
+ * List a project's chats across ALL of its VISIBLE agents (Epic G / G3, GG-5):
952
+ * the keeper PLUS every declared event-hook agent (`hook-<slug>-<name>`), so a
953
+ * hook's chats show up in the sidebar alongside the keeper's. The sweeper is
954
+ * deliberately excluded — it's the `hideChats` case (its curation chats stay
955
+ * hidden, unchanged). See
956
+ * {@link visibleProjectAgentNames}.
957
+ *
958
+ * Sessions are keyed by working directory in core's discovery, and every one of
959
+ * a project's agents shares the project cwd, so `getAgentSessions` attributes each
960
+ * session to the specific agent that owns it (keeper vs. a hook). We query each
961
+ * visible agent and merge — deduping by session id defensively (attribution is
962
+ * per-agent, so overlap shouldn't happen) — then re-sort mtime-descending to
963
+ * restore the interleaved recency order a single listing would have had. Each
964
+ * per-agent query is fault-isolated: a hook whose agent failed to register (or was
965
+ * just removed) yields `[]` instead of failing the whole list.
966
+ *
967
+ * A **hook-less project** (the overwhelmingly common case) has exactly one visible
968
+ * agent — its keeper — so we short-circuit to the single un-merged listing: same
969
+ * one directory scan (and no dedup/sort work) the pre-G3 keeper-only path did, i.e.
970
+ * zero added cost unless the project actually declares hooks. The extra per-hook
971
+ * scans only kick in for a project that has hook chats to show.
972
+ */
973
+ async listSessions(project) {
974
+ const agentNames = visibleProjectAgentNames(project);
975
+ if (agentNames.length === 1)
976
+ return this.manager.getAgentSessions(agentNames[0]);
977
+ const perAgent = await Promise.all(agentNames.map((name) => this.manager.getAgentSessions(name).catch(() => [])));
978
+ const seen = new Set();
979
+ const merged = [];
980
+ for (const list of perAgent) {
981
+ for (const s of list) {
982
+ if (seen.has(s.sessionId))
983
+ continue;
984
+ seen.add(s.sessionId);
985
+ merged.push(s);
986
+ }
987
+ }
988
+ // ISO-8601 mtimes sort lexicographically in chronological order → descending.
989
+ merged.sort((a, b) => (a.mtime < b.mtime ? 1 : a.mtime > b.mtime ? -1 : 0));
990
+ return merged;
991
+ }
992
+ /**
993
+ * The on-disk job-record reads (run history + the unread badge) live in
994
+ * `./herdctl-jobs.js` as pure functions over `<stateDir>/jobs` (issue #403);
995
+ * these thin wrappers thread `this.cfg.stateDir` (or, for the two unread-badge
996
+ * reads, this service's {@link jobsIndex}). See there for the full semantics of
997
+ * each (unread signal, per-project grouping, run history).
998
+ */
999
+ async lastTurnCompletedAt() {
1000
+ return jobs.lastTurnCompletedAt(this.jobsIndex);
1001
+ }
1002
+ async lastTurnCompletedAtByProject() {
1003
+ return jobs.lastTurnCompletedAtByProject(this.jobsIndex);
1004
+ }
1005
+ async listProjectRuns(project, limit = 100) {
1006
+ return jobs.listProjectRuns(this.cfg.stateDir, project, limit);
1007
+ }
1008
+ async listRunsForAgents(agents, limit = 200) {
1009
+ return jobs.listRunsForAgents(this.cfg.stateDir, agents, limit);
1010
+ }
1011
+ /**
1012
+ * Promote a chat into a NEW project: re-home its transcript into that
1013
+ * project's `.chats/` (rewriting the embedded `cwd` so the transcript reports
1014
+ * the new project dir) and re-point the session's herdctl job records at the
1015
+ * new keeper. After this + the cache invalidations below the chat lists and
1016
+ * resumes under the new project with NO restart — doing the same writes from
1017
+ * outside the process would need one, to drop the attribution-index cache.
1018
+ *
1019
+ * Moves one chat from one keeper's store to another's. The root is a workspace
1020
+ * like any other, so root chats carry the promote action too.
1021
+ *
1022
+ * The caller MUST have already created `to` and registered its keeper
1023
+ * (ensureProjectAgent), so its `.chats/` + transcript symlink exist. Throws if
1024
+ * the source transcript can't be read (e.g. unknown session id).
1025
+ */
1026
+ async promoteSession(sessionId, from, to) {
1027
+ if (!/^[A-Za-z0-9._-]+$/.test(sessionId)) {
1028
+ throw new Error(`Invalid session id: ${sessionId}`);
1029
+ }
1030
+ const fromFile = path.join(projectChatsDir(from.dir), `${sessionId}.jsonl`);
1031
+ const toFile = path.join(projectChatsDir(to.dir), `${sessionId}.jsonl`);
1032
+ // Read the source transcript (throws ENOENT for an unknown/absent session).
1033
+ const raw = await fs.readFile(fromFile, "utf8");
1034
+ // Rewrite ONLY the embedded `cwd` token. This relies on Claude Code writing
1035
+ // compact JSON (`"cwd":"/abs/path"` — no spaces, no escaping for a plain abs
1036
+ // path).
1037
+ // Rewrite to the destination's KEEPER cwd. For a repo-backed project that's
1038
+ // the nested checkout (workingDir), not the metadata dir, so a promoted chat
1039
+ // resumes in the right place (issue #187).
1040
+ //
1041
+ // Resume does not actually DEPEND on this: #516 Phase 6 established
1042
+ // empirically that Claude Code keys resume on the transcript's location, not
1043
+ // its recorded `cwd`. It is kept because the recorded value is what the
1044
+ // transcript claims about itself, and a stale one is confusing to read.
1045
+ const rewritten = raw.split(`"cwd":"${from.workingDir}"`).join(`"cwd":"${to.workingDir}"`);
1046
+ await fs.mkdir(projectChatsDir(to.dir), { recursive: true });
1047
+ await fs.writeFile(toFile, rewritten, "utf8");
1048
+ // Preserve the real last-activity time on the moved file.
1049
+ const st = await fs.stat(fromFile).catch(() => null);
1050
+ if (st)
1051
+ await fs.utimes(toFile, st.atime, st.mtime).catch(() => undefined);
1052
+ // Drop the source copy AND evict the SOURCE agent's in-process tracking of
1053
+ // this session. The latter is essential for same-process resume: the agent
1054
+ // that CREATED the session still "owns" the session id in herdctl's live
1055
+ // session state, so resuming under the new keeper without evicting it forks
1056
+ // a fresh session instead of continuing (a process restart clears the same
1057
+ // state, which is why it "worked after restart"). deleteSession removes the
1058
+ // source transcript (already moved → no-op on content) and clears that state.
1059
+ const fromAgent = keeperAgentName(from.slug);
1060
+ await this.manager.deleteSession(fromAgent, sessionId).catch(() => undefined);
1061
+ await fs.rm(fromFile, { force: true });
1062
+ // Re-attribute the session to the new keeper, then drop the discovery +
1063
+ // attribution caches so the move shows immediately.
1064
+ await jobs.reattributeSession(this.cfg.stateDir, sessionId, to, st ? st.mtime : new Date());
1065
+ this.jobsIndexOrNull?.invalidate();
1066
+ this.invalidateSessions(keeperAgentName(to.slug));
1067
+ this.invalidateSessions(fromAgent);
1068
+ }
1069
+ /**
1070
+ * Fork a project chat: eagerly duplicate `sourceSessionId`'s transcript into a
1071
+ * brand-new session in the SAME project, so the fork exists immediately — a
1072
+ * real, resumable chat with the full parent history visible — rather than being
1073
+ * materialized only when the user sends a first message. The source is left
1074
+ * untouched (this is a copy, not a move).
1075
+ *
1076
+ * Same mechanics as {@link promoteSession} (copy the JSONL, keep it
1077
+ * discoverable + attributed), with two differences: a NEW session id is minted
1078
+ * and rewritten onto every transcript line (so the copy is internally
1079
+ * consistent with its new filename — Claude Code stamps appended lines with the
1080
+ * file's id, and a mismatch is version-fragile), and the source file is kept.
1081
+ * `cwd` is unchanged (the fork stays in the same project). Returns the new id.
1082
+ */
1083
+ /**
1084
+ * Whether a chat transcript exists for `sessionId` in `project`. Used by the
1085
+ * self-management MCP write tools (#214) to validate a caller-supplied target
1086
+ * before forking / messaging it, so a bad id yields a clean "not found" instead
1087
+ * of a raw ENOENT (fork) or a false-positive success (send_message). Rejects a
1088
+ * malformed id (path-traversal guard) as non-existent.
1089
+ */
1090
+ async sessionExists(project, sessionId) {
1091
+ if (!/^[A-Za-z0-9._-]+$/.test(sessionId))
1092
+ return false;
1093
+ try {
1094
+ await fs.access(path.join(projectChatsDir(project.dir), `${sessionId}.jsonl`));
1095
+ return true;
1096
+ }
1097
+ catch {
1098
+ return false;
1099
+ }
1100
+ }
1101
+ async forkSession(project, sourceSessionId, name, fromUuid) {
1102
+ if (!/^[A-Za-z0-9._-]+$/.test(sourceSessionId)) {
1103
+ throw new Error(`Invalid session id: ${sourceSessionId}`);
1104
+ }
1105
+ const dir = projectChatsDir(project.dir);
1106
+ // Read the source transcript (throws ENOENT for an unknown/absent session).
1107
+ let raw = await fs.readFile(path.join(dir, `${sourceSessionId}.jsonl`), "utf8");
1108
+ // Fork-from-here (issue #451): when a message uuid is given, copy only the
1109
+ // transcript PREFIX up to and including that message's turn, so the new chat
1110
+ // branches at the chosen point instead of inheriting the whole history. The
1111
+ // tail (later turns) is left out of the copy; the source is untouched.
1112
+ if (fromUuid) {
1113
+ const sliced = sliceTranscriptAtUuid(raw, fromUuid);
1114
+ if (sliced == null) {
1115
+ throw new Error(`Message ${fromUuid} not found in session ${sourceSessionId}`);
1116
+ }
1117
+ raw = sliced;
1118
+ }
1119
+ const newId = randomUUID();
1120
+ // Rewrite the embedded session id on every line. Same compact-JSON
1121
+ // assumption `promoteSession` makes for `cwd` (`"sessionId":"<id>"` /
1122
+ // `"session_id":"<id>"` — no spaces).
1123
+ const rewritten = raw
1124
+ .split(`"sessionId":"${sourceSessionId}"`)
1125
+ .join(`"sessionId":"${newId}"`)
1126
+ .split(`"session_id":"${sourceSessionId}"`)
1127
+ .join(`"session_id":"${newId}"`);
1128
+ await fs.writeFile(path.join(dir, `${newId}.jsonl`), rewritten, "utf8");
1129
+ // Name it (e.g. "Fork of <parent>") and make it discoverable + attributed to
1130
+ // the keeper immediately (a fresh transcript with no job records otherwise
1131
+ // relies on cwd attribution alone).
1132
+ const keeper = keeperAgentName(project.slug);
1133
+ if (name)
1134
+ await this.manager.setSessionName(keeper, newId, name).catch(() => undefined);
1135
+ await jobs.writeAdoptionJob(this.cfg.stateDir, newId, project, new Date());
1136
+ this.jobsIndexOrNull?.invalidate();
1137
+ this.invalidateSessions(keeper);
1138
+ return newId;
1139
+ }
1140
+ /**
1141
+ * Revert a chat back to an earlier message (issue #451): truncate the session's
1142
+ * transcript at `toUuid`, in place, keeping the SAME session id — so the chat's
1143
+ * identity/URL is preserved and the next turn continues as if the later
1144
+ * messages never happened. The dropped tail is backed up (recoverable) to a
1145
+ * `.reverts/` sidecar that is deliberately kept out of the discoverable
1146
+ * `<id>.jsonl` namespace so it never lists as a chat.
1147
+ *
1148
+ * NOTE: this rolls back the CONVERSATION only — real-world side-effects of the
1149
+ * reverted turns (files written, PRs opened, messages sent) are NOT undone. The
1150
+ * caller's UI is responsible for warning about that. Returns the number of
1151
+ * transcript records dropped.
1152
+ */
1153
+ async revertSession(project, sessionId, toUuid) {
1154
+ if (!/^[A-Za-z0-9._-]+$/.test(sessionId)) {
1155
+ throw new Error(`Invalid session id: ${sessionId}`);
1156
+ }
1157
+ const dir = projectChatsDir(project.dir);
1158
+ const file = path.join(dir, `${sessionId}.jsonl`);
1159
+ const raw = await fs.readFile(file, "utf8");
1160
+ // endOnCompletedAssistant: revert lands on a completed AI turn (reverting to
1161
+ // one of your own prompts rewinds to the reply before it) so resume doesn't
1162
+ // replay a dangling prompt as a phantom turn (#451).
1163
+ const sliced = sliceTranscriptAtUuid(raw, toUuid, true);
1164
+ if (sliced == null) {
1165
+ throw new Error(`Message ${toUuid} not found in session ${sessionId}`);
1166
+ }
1167
+ const countLines = (s) => s.split("\n").filter((l) => l.trim()).length;
1168
+ const removed = countLines(raw) - countLines(sliced);
1169
+ // Back up the full transcript before truncating (recoverable revert). The
1170
+ // `.reverts/` dir is not the `<id>.jsonl` session namespace, so it is never
1171
+ // discovered/listed as a chat.
1172
+ const backupsDir = path.join(dir, ".reverts");
1173
+ await fs.mkdir(backupsDir, { recursive: true });
1174
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1175
+ await fs
1176
+ .copyFile(file, path.join(backupsDir, `${sessionId}-${stamp}.jsonl`))
1177
+ .catch(() => undefined);
1178
+ await fs.writeFile(file, sliced, "utf8");
1179
+ // The truncated file's shrunk mtime invalidates the usage/context mtime
1180
+ // caches automatically; drop the discovery cache so the shorter transcript
1181
+ // (new preview/last-turn) lists immediately.
1182
+ this.invalidateSessions(keeperAgentName(project.slug));
1183
+ return { removed };
1184
+ }
1185
+ /**
1186
+ * Attribute an *in-flight* session to its agent the moment its id is known —
1187
+ * mid first turn — so a brand-new chat lists in the sidebar immediately
1188
+ * instead of only after the first turn finishes (issue #100).
1189
+ *
1190
+ * The core attribution index is built from herdctl job records, and herdctl
1191
+ * writes the resolved `session_id` into a run's own job record only on
1192
+ * completion. While the first turn runs, that record has `session_id: null`,
1193
+ * so the session is unattributed and {@link SessionDiscoveryService.getAgentSessions}
1194
+ * filters it out. We close that window with the SAME synthetic-job-record
1195
+ * trick used for fork/promote: write an adoption record keyed to the session
1196
+ * id, then drop the discovery + attribution caches so the next list call
1197
+ * surfaces it. Idempotent — a stable per-session job id means a repeat call
1198
+ * just overwrites the same file, and when the real run later finalizes its own
1199
+ * record (same session id, same agent) the attribution is unchanged.
1200
+ *
1201
+ * @param sessionId - The freshly-resolved session id of the running turn
1202
+ * @param agentName - The qualified agent the session belongs to
1203
+ */
1204
+ async attributeRunningSession(sessionId, agentName) {
1205
+ if (!/^[A-Za-z0-9._-]+$/.test(sessionId))
1206
+ return;
1207
+ await jobs.writeAgentAdoptionJob(this.cfg.stateDir, sessionId, agentName, new Date());
1208
+ this.jobsIndexOrNull?.invalidate();
1209
+ this.invalidateSessions(agentName);
1210
+ }
1211
+ /** Read the parsed messages of a session, by agent name. */
1212
+ async sessionMessages(agentName, sessionId) {
1213
+ // Injected/synthetic (`isMeta:true`) user lines — a skill's SKILL.md,
1214
+ // command output — are dropped by @herdctl/core's parser (>=5.13.2), so a
1215
+ // skill body no longer renders as a giant user bubble (issue #31).
1216
+ return this.manager.getAgentSessionMessages(agentName, sessionId);
1217
+ }
1218
+ // Per-session token usage (last-turn context fill AND cumulative totals) is
1219
+ // read directly from the transcript in ./usage.ts (readSessionTokenUsage) —
1220
+ // paddock owns the `.chats/` layout, and core's SessionUsage only exposes the
1221
+ // last-turn fill, so the cumulative extractor supersedes the old
1222
+ // sessionUsage/sessionUsageCached wrappers that used to live here (issue #152).
1223
+ /**
1224
+ * Slash commands available to an agent, memoized per agent name. The set is
1225
+ * stable per project (built-ins + the project's `.claude/commands` + any
1226
+ * MCP-provided commands) and each underlying `listAgentCommands` call spawns a
1227
+ * short-lived `claude` streaming subprocess (open → query → close), so we
1228
+ * cache aggressively. The cache is process-lifetime: it fills on first use and
1229
+ * only clears on server restart — the same boot-only invalidation the fleet's
1230
+ * agent registration already relies on. A concurrent first call is
1231
+ * deduplicated by caching the in-flight promise, so a burst of composer fetches
1232
+ * spawns exactly one subprocess.
1233
+ */
1234
+ commandsCache = new Map();
1235
+ async listCommands(agentName) {
1236
+ const hit = this.commandsCache.get(agentName);
1237
+ if (hit)
1238
+ return hit;
1239
+ // Cache the promise (not the resolved value) so concurrent callers share one
1240
+ // subprocess; drop it on rejection so a transient failure can be retried.
1241
+ const pending = this.manager.listAgentCommands(agentName).catch((err) => {
1242
+ this.commandsCache.delete(agentName);
1243
+ throw err;
1244
+ });
1245
+ this.commandsCache.set(agentName, pending);
1246
+ return pending;
1247
+ }
1248
+ // --- agent configs -----------------------------------------------------
1249
+ //
1250
+ // Thin instance wrappers over the pure builders in `./herdctl-agent-config.js`
1251
+ // (issue #403): each threads `this.cfg` so the extracted functions stay pure
1252
+ // (config in via params, no `this`). The private names are unchanged so the
1253
+ // existing unit-test seams (which reach `sweeperAgentConfig`/`ensureConfigFile`
1254
+ // via a cast) and internal callers keep working.
1255
+ keeperAgentConfig(project, modelOverride) {
1256
+ return buildAgentConfig(this.cfg, project, modelOverride);
1257
+ }
1258
+ sweeperAgentConfig(project) {
1259
+ return buildSweeperConfig(this.cfg, project);
1260
+ }
1261
+ /**
1262
+ * Create the sweeper's own working directory (+ symlink its encoded transcript
1263
+ * path at a `.chats/` inside it), so the tool-less curator never shares a CLI
1264
+ * session directory with the project's keeper (issue #548). Reuses
1265
+ * `ensureProjectChats` with the sweeper dir as both cwd and chats host, which
1266
+ * also mkdir's the dir itself. Never throws — the helper swallows its own
1267
+ * errors, and a missing symlink only means Claude keeps its default location.
1268
+ */
1269
+ async ensureSweeperHome(project) {
1270
+ const dir = sweeperWorkingDir(this.cfg, project.slug);
1271
+ await ensureProjectChats(dir, dir);
1272
+ }
1273
+ triggerAgentConfig(project, triggerName, trigger) {
1274
+ return buildTriggerConfig(this.cfg, project, triggerName, trigger);
1275
+ }
1276
+ async ensureConfigFile() {
1277
+ await writeBootConfigFile(this.cfg);
1278
+ }
1279
+ }