@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,1406 @@
1
+ /**
2
+ * Chat (session) routes: the project-chat cluster (list / runs / usage / create /
3
+ * messages / subagents / context / delete / rename / fork / archive / star / seen /
4
+ * unread / promote). Chat SENDING happens over WS; these are the REST reads +
5
+ * lifecycle mutations.
6
+ *
7
+ * Every route here is workspace-scoped and mounted twice (`/api/root` and
8
+ * `/api/projects/:slug`), so the root — whose key is `""` — is served by these
9
+ * same handlers with no special-casing.
10
+ */
11
+ import path from "node:path";
12
+ import { PathTraversalError } from "@herdctl/core";
13
+ import { keeperAgentName } from "../herdctl.js";
14
+ import { ROOT_KEY } from "../projects.js";
15
+ import { applyMessageProvenance } from "../message-provenance.js";
16
+ import { buildProjectRuns } from "../runs.js";
17
+ import { DEFAULT_MODEL } from "../models.js";
18
+ import { projectChatsDir } from "../transcripts.js";
19
+ import { readSubagentMessages, readSessionTokenUsageWithSubagents } from "../subagents.js";
20
+ import { readContextSeries } from "../usage.js";
21
+ import { enrichWithToolDetails } from "../tooldetails.js";
22
+ import { scanTranscriptNotice } from "../turn-notice.js";
23
+ import { childOf, HUMAN_ROOT, ADOPTED_ROOT } from "../run-provenance.js";
24
+ import { sendProjectError } from "../route-errors.js";
25
+ import { mapWithConcurrency, FILE_FANOUT_CONCURRENCY } from "../concurrency.js";
26
+ import { CHAT_USAGE_SCOPES, SAFE_SESSION_ID, RUNS_SEEN_SESSION, clampRunsLimit, toChatUsage, buildProjectChats, makeTriggerResolver, makeParentResolver, normalizeBatchSessionIds, BATCH_SESSIONS_MAX, } from "../chat-dto.js";
27
+ /**
28
+ * Workspace-scoped chat routes: paths are declared RELATIVE to the workspace
29
+ * (e.g. `/chats/:sessionId`), and this plugin is mounted TWICE — once at
30
+ * `/api/root` and once at `/api/projects/:slug` (see `workspace-mount.ts`).
31
+ *
32
+ * Handlers still read `req.params.slug`; the root mount injects `slug: ""` in an
33
+ * `onRequest` hook, which runs before params validation, so the `required:
34
+ * ["slug"]` schemas below keep validating unchanged on both mounts.
35
+ */
36
+ export function registerChatWorkspaceRoutes(app, ctx) {
37
+ const { projects, herdctl, archive, star, readState, unread, parentDetach, runProvenance, messageProvenance, events, readStateUser, agentForSlug, projectDirForSlug, cleanupAttachments, chatUsageResolver, } = ctx;
38
+ /**
39
+ * The single 400 for a batch route whose `sessionIds` body is unusable —
40
+ * missing, empty, over the cap, or holding an id that isn't path-safe. Batch
41
+ * validation is all-or-nothing (see {@link normalizeBatchSessionIds}), so this
42
+ * is the only failure shape those routes have.
43
+ */
44
+ const sendBadBatch = (reply) => reply.code(400).send({
45
+ error: `sessionIds must be an array of 1-${BATCH_SESSIONS_MAX} valid session ids`,
46
+ code: "bad_request",
47
+ });
48
+ /**
49
+ * Does this session have a live turn right now? The session hub is the only
50
+ * thing that knows (a running turn is in-memory state, not something any
51
+ * sidecar or job record has yet), and REST reaches it through the same
52
+ * context the MCP surface's `list_chats` uses.
53
+ *
54
+ * The context is optional in the dep bag, so a server built without the WS
55
+ * layer reports "nothing is running" rather than throwing — the attention
56
+ * feed then degrades to its unread half instead of 500ing.
57
+ */
58
+ const isRunning = (sessionId) => ctx.managementOpsContext?.hub.isRunning(sessionId) ?? false;
59
+ // --- chats (sessions) --------------------------------------------------
60
+ app.get("/chats", {
61
+ schema: {
62
+ tags: ["Chats"],
63
+ summary: "List a project's chats",
64
+ description: "Returns the project's chats (sessions) with per-chat metadata (name, last-turn time, archived/starred flags, last-seen watermark, run provenance, trigger info). Usage rings are fetched separately (see GET /api/projects/:slug/chats/usage). Response: an object `{ chats: [...] }`.",
65
+ params: {
66
+ type: "object",
67
+ properties: {
68
+ slug: { type: "string", description: "Project slug." },
69
+ },
70
+ required: ["slug"],
71
+ },
72
+ response: {
73
+ 200: {
74
+ description: "Object `{ chats }` where `chats` is an array of chat DTOs.",
75
+ type: "object",
76
+ additionalProperties: true,
77
+ },
78
+ },
79
+ },
80
+ }, async (req, reply) => {
81
+ try {
82
+ const project = await projects.get(req.params.slug);
83
+ const [sessions, lastTurnAt] = await Promise.all([
84
+ herdctl.listSessions(project).catch(() => []),
85
+ herdctl.lastTurnCompletedAt().catch(() => new Map()),
86
+ ]);
87
+ const keeper = keeperAgentName(project.slug);
88
+ const archivedOf = (s) => archive.isArchived(keeper, s.sessionId);
89
+ const starredOf = (s) => star.isStarred(keeper, s.sessionId);
90
+ const user = readStateUser(req);
91
+ const lastSeenOf = (s) => readState.getLastSeen(user, keeper, s.sessionId);
92
+ const unreadOf = (s) => unread.isUnread(user, keeper, s.sessionId);
93
+ const provenanceOf = (s) => runProvenance.get(s.sessionId);
94
+ const triggerOf = makeTriggerResolver(project);
95
+ // Parent edge for the nested chat list: an explicit detach (#508) wins, else
96
+ // the recorded RunProvenance edge, else a backfill from who injected the
97
+ // kickoff prompt. All three are in-memory sidecar reads.
98
+ const parentOf = makeParentResolver(runProvenance, messageProvenance, project.slug, (id) => parentDetach.isDetached(keeper, id));
99
+ // No usage resolver — see the GET /api/projects/:slug route (issue #116).
100
+ // Usage rings are fetched separately so a list refresh stays cheap.
101
+ return {
102
+ chats: await buildProjectChats(project.dir, sessions, undefined, archivedOf, lastTurnAt, lastSeenOf, provenanceOf, triggerOf, starredOf, unreadOf, parentOf),
103
+ };
104
+ }
105
+ catch (err) {
106
+ return sendProjectError(reply, err);
107
+ }
108
+ });
109
+ // --- the Home tab's attention feed (#599) ----------------------------------
110
+ /**
111
+ * The chats in this workspace's SUBTREE that want the user's attention right
112
+ * now: the ones with a live turn, and the ones holding an unread reply.
113
+ *
114
+ * Subtree, not workspace, is what makes root Home fleet-wide and a project's
115
+ * Home project-scoped through ONE handler. A workspace's key is its path
116
+ * relative to `projectsRoot`, so its descendants are exactly the workspaces
117
+ * whose key it prefixes — and the root's key is `""`, which prefixes every
118
+ * key there is. The root therefore sweeps the whole fleet and a (leaf)
119
+ * project sweeps only itself, with no branch on the key and no second
120
+ * implementation for the root to drift from. Nesting, when it lands, makes an
121
+ * intermediate workspace work for free.
122
+ *
123
+ * `running` is authoritative (the live hub), not inferred from timestamps.
124
+ * `unread` reuses the sidebar badge's derivation — the manual override (#458)
125
+ * or a completed turn newer than the read watermark (#189) — with ONE
126
+ * deliberate difference: archived chats are filed away on purpose, so they
127
+ * are excluded here. That means an archived chat with an unread turn still
128
+ * counts toward the sidebar's badge (`buildChatTurns` never consults
129
+ * `ArchiveStore`) while not appearing in this list. The two can therefore
130
+ * disagree by exactly that set; making them agree means teaching the badge
131
+ * about archiving, which is a behaviour change to the badge, not to this
132
+ * route.
133
+ */
134
+ app.get("/chats/attention", {
135
+ schema: {
136
+ tags: ["Chats"],
137
+ summary: "List the chats in this workspace's subtree that are running or unread",
138
+ description: "Returns `{ running, unread }` — the chats in this workspace AND its descendants that currently have a live turn (`running`) or hold an unread reply (`unread`). Each row is a chat DTO plus the `projectSlug`/`projectName` it belongs to, so a fleet-wide list stays attributable. On the root mount (`/api/root/chats/attention`) the subtree is the whole instance, because the root workspace's key (`\"\"`) prefixes every workspace key; on a project mount it is that project alone. A chat is never in both lists: a live turn hasn't landed a reply yet, so running wins.",
139
+ params: {
140
+ type: "object",
141
+ properties: {
142
+ slug: { type: "string", description: "Project slug." },
143
+ },
144
+ required: ["slug"],
145
+ },
146
+ response: {
147
+ 200: {
148
+ description: "Object `{ running, unread }`, each an array of chat DTOs enriched with `projectSlug` and `projectName`.",
149
+ type: "object",
150
+ additionalProperties: true,
151
+ },
152
+ },
153
+ },
154
+ }, async (req, reply) => {
155
+ try {
156
+ const self = await projects.get(req.params.slug);
157
+ // Descendants = every workspace this one's key prefixes. The root's key
158
+ // is `""`, so its prefix is `""` and the filter admits everything; a
159
+ // project's prefix is `"<slug>/"`. The `=== ROOT_KEY` test is explicit
160
+ // on purpose — `self.slug ? ... : ...` would take the root down the
161
+ // project branch and silently return an empty fleet.
162
+ const prefix = self.slug === ROOT_KEY ? "" : `${self.slug}/`;
163
+ const all = await projects.list().catch(() => []);
164
+ const subtree = [self, ...all.filter((p) => p.slug !== self.slug && p.slug.startsWith(prefix))];
165
+ const user = readStateUser(req);
166
+ // One shared job-record scan for the whole subtree rather than one per
167
+ // workspace: `lastTurnCompletedAt()` is the cached jobs-dir index, and
168
+ // re-reading it per project is what made the old N-request client
169
+ // fan-out expensive.
170
+ const lastTurnAt = await herdctl.lastTurnCompletedAt().catch(() => new Map());
171
+ const running = [];
172
+ const unreadRows = [];
173
+ for (const p of subtree) {
174
+ const sessions = await herdctl.listSessions(p).catch(() => []);
175
+ if (sessions.length === 0)
176
+ continue;
177
+ const keeper = keeperAgentName(p.slug);
178
+ const archivedOf = (s) => archive.isArchived(keeper, s.sessionId);
179
+ const starredOf = (s) => star.isStarred(keeper, s.sessionId);
180
+ const lastSeenOf = (s) => readState.getLastSeen(user, keeper, s.sessionId);
181
+ const unreadOf = (s) => unread.isUnread(user, keeper, s.sessionId);
182
+ const provenanceOf = (s) => runProvenance.get(s.sessionId);
183
+ const triggerOf = makeTriggerResolver(p);
184
+ const parentOf = makeParentResolver(runProvenance, messageProvenance, p.slug, (id) => parentDetach.isDetached(keeper, id));
185
+ const chats = await buildProjectChats(p.dir, sessions, undefined, archivedOf, lastTurnAt, lastSeenOf, provenanceOf, triggerOf, starredOf, unreadOf, parentOf);
186
+ for (const c of chats) {
187
+ const row = { ...c, projectSlug: p.slug, projectName: p.name };
188
+ if (isRunning(c.sessionId)) {
189
+ running.push(row);
190
+ continue;
191
+ }
192
+ // Archived chats are filed away on purpose; they stay out of the
193
+ // unread feed so archiving is a real way to silence a chat. A
194
+ // RUNNING archived chat still shows — it is live work either way.
195
+ if (c.archived)
196
+ continue;
197
+ const completed = c.lastTurnCompletedAt ? Date.parse(c.lastTurnCompletedAt) : NaN;
198
+ const seen = c.lastSeen ?? 0;
199
+ if (c.unread || (Number.isFinite(completed) && completed > seen))
200
+ unreadRows.push(row);
201
+ }
202
+ }
203
+ return { running, unread: unreadRows };
204
+ }
205
+ catch (err) {
206
+ return sendProjectError(reply, err);
207
+ }
208
+ });
209
+ // --- run history: "while you were away" (E3 / #268 / DD-6) ------------------
210
+ // A project-level view of what ran unattended (scheduled + spawned) plus human
211
+ // runs, sourced from herdctl job records (listProjectRuns) joined with the A1
212
+ // provenance marker (#261) for true origin/depth. Cost is a P3 seam (null).
213
+ //
214
+ // The since-last-visit digest reuses the read-state watermark (#189): a per-
215
+ // user "runs last seen" epoch keyed under the keeper agent with the reserved
216
+ // sentinel session id below (a plain UUID can't contain "__", so it can never
217
+ // alias a real chat's read-state).
218
+ app.get("/runs", {
219
+ schema: {
220
+ tags: ["Chats"],
221
+ summary: "List a project's run history",
222
+ description: "Returns the project's 'while you were away' run history (scheduled + spawned + human runs) sourced from herdctl job records joined with provenance markers, plus the per-user since-last-visit watermark. Response: an object describing runs and the last-seen digest state.",
223
+ params: {
224
+ type: "object",
225
+ properties: {
226
+ slug: { type: "string", description: "Project slug." },
227
+ },
228
+ required: ["slug"],
229
+ },
230
+ querystring: {
231
+ type: "object",
232
+ additionalProperties: true,
233
+ properties: {
234
+ limit: { type: "string", description: "Max number of runs to return (clamped server-side)." },
235
+ },
236
+ },
237
+ response: {
238
+ 200: {
239
+ description: "Object with the project's runs and since-last-visit digest state.",
240
+ type: "object",
241
+ additionalProperties: true,
242
+ },
243
+ },
244
+ },
245
+ }, async (req, reply) => {
246
+ try {
247
+ const project = await projects.get(req.params.slug);
248
+ const keeper = keeperAgentName(project.slug);
249
+ const limit = clampRunsLimit(req.query.limit);
250
+ const [jobs, lastSeen] = await Promise.all([
251
+ herdctl.listProjectRuns(project, limit).catch(() => []),
252
+ readState
253
+ .getLastSeen(readStateUser(req), keeper, RUNS_SEEN_SESSION)
254
+ .catch(() => 0),
255
+ ]);
256
+ // Resolve provenance for each DISTINCT session referenced by a run — a
257
+ // cheap in-memory map read per id (RunProvenanceStore is lazy-loaded once).
258
+ const sessionIds = [
259
+ ...new Set(jobs.map((j) => j.session_id).filter((s) => !!s)),
260
+ ];
261
+ const provBySession = new Map();
262
+ await Promise.all(sessionIds.map(async (sid) => {
263
+ const p = await runProvenance.get(sid).catch(() => undefined);
264
+ if (p)
265
+ provBySession.set(sid, p);
266
+ }));
267
+ return buildProjectRuns(jobs, provBySession, lastSeen);
268
+ }
269
+ catch (err) {
270
+ return sendProjectError(reply, err);
271
+ }
272
+ });
273
+ // Advance the per-user "runs last seen" watermark (clears the since-last-visit
274
+ // digest). Mirrors the chat-seen endpoint: optional `{ when }`, defaults to now,
275
+ // monotonic in the store (an older `when` is a no-op).
276
+ app.post("/runs/seen", {
277
+ schema: {
278
+ tags: ["Chats"],
279
+ summary: "Mark a project's runs as seen",
280
+ description: "Advances the per-user 'runs last seen' watermark (clears the since-last-visit digest). Optional body `{ when }` (epoch-ms) defaults to now; monotonic in the store (an older `when` is a no-op). Response: `{ ok, lastSeen }`.",
281
+ params: {
282
+ type: "object",
283
+ properties: {
284
+ slug: { type: "string", description: "Project slug." },
285
+ },
286
+ required: ["slug"],
287
+ },
288
+ body: {
289
+ type: ["object", "null"],
290
+ additionalProperties: true,
291
+ properties: {
292
+ when: { description: "Epoch-ms timestamp to record as last seen; defaults to now." },
293
+ },
294
+ required: [],
295
+ },
296
+ response: {
297
+ 200: {
298
+ description: "Object `{ ok, lastSeen }` with the recorded watermark.",
299
+ type: "object",
300
+ additionalProperties: true,
301
+ },
302
+ },
303
+ },
304
+ }, async (req, reply) => {
305
+ try {
306
+ const keeper = await agentForSlug(req.params.slug);
307
+ const when = typeof req.body?.when === "number" && Number.isFinite(req.body.when)
308
+ ? req.body.when
309
+ : Date.now();
310
+ await readState.setLastSeen(readStateUser(req), keeper, RUNS_SEEN_SESSION, when);
311
+ return reply.code(200).send({ ok: true, lastSeen: when });
312
+ }
313
+ catch (err) {
314
+ return sendProjectError(reply, err);
315
+ }
316
+ });
317
+ // Bulk context-window usage for ALL of a project's chats, keyed by session id
318
+ // (issue #116). This is the expensive part the chat list needs for its per-chat
319
+ // usage rings (issue #77) — each session's fill is read by streaming its
320
+ // transcript (memoized on transcript mtime). Split out of the project-detail
321
+ // and chat-list payloads so the ProjectView renders immediately and the client
322
+ // fills rings in progressively. Sessions with no usage data are omitted.
323
+ //
324
+ // Scoped by archived state (issue #537). There is no stored counter: usage is
325
+ // derived by streaming each transcript end to end, so the cost is proportional
326
+ // to transcript bytes, not chat count. The sidebar collapses the Archived group
327
+ // by default, so computing archived rings on every project open is work whose
328
+ // result is never rendered — on a live-scale project that was 72% of the bytes
329
+ // streamed. `scope` therefore defaults to `active`; the client asks for
330
+ // `archived` only once that group is actually expanded.
331
+ app.get("/chats/usage", {
332
+ schema: {
333
+ tags: ["Chats"],
334
+ summary: "Bulk context-window usage for a project's chats",
335
+ description: "Returns per-chat context-window usage keyed by session id — the expensive data the chat list needs for its usage rings. Scoped by `scope`, which defaults to `active` (non-archived chats only): usage is derived by streaming each transcript, and archived rings are hidden behind a collapsed group, so computing them by default is wasted work (issue #537). Pass `scope=archived` when that group is expanded, or `scope=all` for the whole project. Sessions with no usage data are omitted. Response: `{ usage: { <sessionId>: <usage> } }`.",
336
+ params: {
337
+ type: "object",
338
+ properties: {
339
+ slug: { type: "string", description: "Project slug." },
340
+ },
341
+ required: ["slug"],
342
+ },
343
+ querystring: {
344
+ type: "object",
345
+ properties: {
346
+ scope: {
347
+ type: "string",
348
+ enum: [...CHAT_USAGE_SCOPES],
349
+ default: "active",
350
+ description: "Which chats to compute usage for: `active` (non-archived, the default), `archived`, or `all`.",
351
+ },
352
+ },
353
+ },
354
+ response: {
355
+ 200: {
356
+ description: "Object `{ usage }` mapping session id to its context-window usage.",
357
+ type: "object",
358
+ additionalProperties: true,
359
+ },
360
+ },
361
+ },
362
+ }, async (req, reply) => {
363
+ try {
364
+ const project = await projects.get(req.params.slug);
365
+ const sessions = await herdctl.listSessions(project).catch(() => []);
366
+ const scope = req.query.scope ?? "active";
367
+ const keeper = keeperAgentName(project.slug);
368
+ const usageOf = chatUsageResolver(project.dir, project.model ?? DEFAULT_MODEL);
369
+ // Bounded, NOT `Promise.all` (#544): one transcript read per chat, so an
370
+ // unbounded map opens 1,515 concurrent streams on a real project and
371
+ // peaks at 706 MB for no speed — 16 measured faster AND 4× smaller.
372
+ const entries = await mapWithConcurrency(sessions, FILE_FANOUT_CONCURRENCY, async (s) => {
373
+ // `all` skips the archive lookup entirely; the other two split on it.
374
+ // The lookup is an in-memory sidecar read, so it is free next to the
375
+ // transcript stream it is deciding whether to skip.
376
+ if (scope !== "all" &&
377
+ (await archive.isArchived(keeper, s.sessionId)) !== (scope === "archived")) {
378
+ return null;
379
+ }
380
+ const u = await usageOf(s).catch(() => null);
381
+ return u ? [s.sessionId, u] : null;
382
+ });
383
+ const usage = {};
384
+ for (const e of entries)
385
+ if (e)
386
+ usage[e[0]] = e[1];
387
+ return { usage };
388
+ }
389
+ catch (err) {
390
+ return sendProjectError(reply, err);
391
+ }
392
+ });
393
+ app.post("/chats", {
394
+ schema: {
395
+ tags: ["Chats"],
396
+ summary: "Get the WS target for a new project chat",
397
+ description: "Thin endpoint: validates the project and returns the WebSocket target for opening a new chat. It does NOT create the chat or send a message — a new chat is minted lazily by the first WS `chat:send` (with no sessionId), and the session id arrives in `chat:complete`. Responds 201 with `{ projectSlug, sessionId: null, ws: \"/ws\", note }`.",
398
+ params: {
399
+ type: "object",
400
+ properties: {
401
+ slug: { type: "string", description: "Project slug." },
402
+ },
403
+ required: ["slug"],
404
+ },
405
+ response: {
406
+ 201: {
407
+ description: "Object `{ projectSlug, sessionId, ws, note }` giving the WS target `/ws`.",
408
+ type: "object",
409
+ additionalProperties: true,
410
+ },
411
+ },
412
+ },
413
+ }, async (req, reply) => {
414
+ // A "new chat" is created lazily by the first WS chat:send with no
415
+ // sessionId (the SDK mints the session id, returned in chat:complete).
416
+ // This endpoint just validates the project and returns the WS target so
417
+ // the client can open a socket. TODO: persist a custom chat name up-front
418
+ // via @herdctl/core SessionMetadataStore once we have a session id.
419
+ try {
420
+ const project = await projects.get(req.params.slug);
421
+ return reply.code(201).send({
422
+ projectSlug: project.slug,
423
+ sessionId: null,
424
+ ws: "/ws",
425
+ note: "Open /ws and send chat:send with this projectSlug; session id arrives in chat:complete.",
426
+ });
427
+ }
428
+ catch (err) {
429
+ return sendProjectError(reply, err);
430
+ }
431
+ });
432
+ // Messages of a specific chat (session) within a project.
433
+ app.get("/chats/:sessionId/messages", {
434
+ schema: {
435
+ tags: ["Chats"],
436
+ summary: "Read a project chat's messages",
437
+ description: "READ-ONLY: returns the messages of a project chat, enriched with tool details and per-message provenance, plus a synthetic trailing notice if the chat dead-ended at a usage/subscription limit. Sending a message is WebSocket-only (a `chat:send` frame on GET /ws) — there is no HTTP send-message endpoint. Response: `{ messages: [...] }`.",
438
+ params: {
439
+ type: "object",
440
+ properties: {
441
+ slug: { type: "string", description: "Project slug." },
442
+ sessionId: { type: "string", description: "Chat (session) id." },
443
+ },
444
+ required: ["slug", "sessionId"],
445
+ },
446
+ response: {
447
+ 200: {
448
+ description: "Object `{ messages }` with the chat's message array.",
449
+ type: "object",
450
+ additionalProperties: true,
451
+ },
452
+ },
453
+ },
454
+ }, async (req, reply) => {
455
+ try {
456
+ const agent = await agentForSlug(req.params.slug);
457
+ const projectDir = await projectDirForSlug(req.params.slug);
458
+ const messages = await herdctl
459
+ .sessionMessages(agent, req.params.sessionId)
460
+ .catch(() => []);
461
+ const enriched = await enrichWithToolDetails(projectDir, req.params.sessionId, messages);
462
+ // Per-message provenance (issue #290): attribute machine-injected turns to
463
+ // their sender (chat / schedule) by joining the ordered injection markers.
464
+ // Human-typed turns match no marker and stay unlabelled (the default).
465
+ const markers = await messageProvenance.list(req.params.sessionId).catch(() => []);
466
+ const withProvenance = applyMessageProvenance(enriched, markers);
467
+ // Per-message context fill (issue #451): join the uuid→contextTokens
468
+ // series (assistant turns only) and forward-fill it across the user/tool
469
+ // messages between turns, so every message answers "how full was the
470
+ // window as of here". Rides the mtime-cached series; open-chat path only.
471
+ const series = SAFE_SESSION_ID.test(req.params.sessionId)
472
+ ? await readContextSeries(projectDir, req.params.sessionId).catch(() => new Map())
473
+ : new Map();
474
+ if (series.size > 0) {
475
+ let lastCtx;
476
+ for (const m of withProvenance) {
477
+ if (m.uuid && series.has(m.uuid))
478
+ lastCtx = series.get(m.uuid);
479
+ if (lastCtx !== undefined)
480
+ m.contextTokens = lastCtx;
481
+ }
482
+ }
483
+ // #329: `@herdctl/core` drops synthetic messages when it parses the
484
+ // transcript, so a turn that dead-ended at a subscription/usage limit
485
+ // leaves no visible trace on reload. Re-scan the raw JSONL for a TRAILING
486
+ // usage-limit dead-end and append it as a synthetic notice turn so the
487
+ // reason the chat stopped survives a refresh (a later real reply clears it).
488
+ const notice = SAFE_SESSION_ID.test(req.params.sessionId)
489
+ ? await scanTranscriptNotice(path.join(projectChatsDir(projectDir), `${req.params.sessionId}.jsonl`))
490
+ : null;
491
+ if (notice) {
492
+ const last = withProvenance[withProvenance.length - 1];
493
+ withProvenance.push({
494
+ role: "assistant",
495
+ content: "",
496
+ timestamp: last?.timestamp ?? new Date().toISOString(),
497
+ uuid: `notice-${req.params.sessionId}`,
498
+ notice,
499
+ });
500
+ }
501
+ return { messages: withProvenance };
502
+ }
503
+ catch (err) {
504
+ return sendProjectError(reply, err);
505
+ }
506
+ });
507
+ // Step-by-step transcript of a sub-agent launched from a Task/Agent tool block
508
+ // within a project chat (issue #37). `toolUseId` is the parent tool_use id
509
+ // carried on the enriched tool call; it resolves to the sub-agent's own
510
+ // transcript under `.chats/<sessionId>/subagents/`.
511
+ app.get("/chats/:sessionId/subagents/:toolUseId/messages", {
512
+ schema: {
513
+ tags: ["Chats"],
514
+ summary: "Read a sub-agent's transcript within a project chat",
515
+ description: "Returns the step-by-step transcript of a sub-agent launched from a Task/Agent tool block within a project chat. `toolUseId` is the parent tool_use id carried on the enriched tool call. Response: `{ messages: [...] }`.",
516
+ params: {
517
+ type: "object",
518
+ properties: {
519
+ slug: { type: "string", description: "Project slug." },
520
+ sessionId: { type: "string", description: "Chat (session) id." },
521
+ toolUseId: { type: "string", description: "Parent tool_use id of the sub-agent launch." },
522
+ },
523
+ required: ["slug", "sessionId", "toolUseId"],
524
+ },
525
+ response: {
526
+ 200: {
527
+ description: "Object `{ messages }` with the sub-agent's message array.",
528
+ type: "object",
529
+ additionalProperties: true,
530
+ },
531
+ },
532
+ },
533
+ }, async (req, reply) => {
534
+ try {
535
+ const projectDir = await projectDirForSlug(req.params.slug);
536
+ const messages = await readSubagentMessages(projectDir, req.params.sessionId, req.params.toolUseId);
537
+ return { messages };
538
+ }
539
+ catch (err) {
540
+ return sendProjectError(reply, err);
541
+ }
542
+ });
543
+ // Context-window usage for a project chat, read from the transcript's most
544
+ // recent turn. Lets the UI show "context used" for a chat opened from history,
545
+ // before any new turn streams a fresh usage value. `usage` is null when the
546
+ // transcript has no usage data.
547
+ app.get("/chats/:sessionId/context", {
548
+ schema: {
549
+ tags: ["Chats"],
550
+ summary: "Context-window usage for a project chat",
551
+ description: "Returns the context-window usage for a project chat, read from the transcript's most recent turn — lets the UI show 'context used' before a new turn streams a fresh value. Response: `{ usage }`, where `usage` is null when the transcript has no usage data.",
552
+ params: {
553
+ type: "object",
554
+ properties: {
555
+ slug: { type: "string", description: "Project slug." },
556
+ sessionId: { type: "string", description: "Chat (session) id." },
557
+ },
558
+ required: ["slug", "sessionId"],
559
+ },
560
+ response: {
561
+ 200: {
562
+ description: "Object `{ usage }` (null when the transcript has no usage data).",
563
+ type: "object",
564
+ additionalProperties: true,
565
+ },
566
+ },
567
+ },
568
+ }, async (req, reply) => {
569
+ try {
570
+ const projectDir = await projectDirForSlug(req.params.slug);
571
+ // Every key here addresses a workspace, so the per-workspace model
572
+ // override always applies.
573
+ const p = await projects.get(req.params.slug).catch(() => null);
574
+ const model = p?.model ?? DEFAULT_MODEL;
575
+ const u = await readSessionTokenUsageWithSubagents(projectDir, req.params.sessionId).catch(() => null);
576
+ return { usage: u ? toChatUsage(u, model) : null };
577
+ }
578
+ catch (err) {
579
+ return sendProjectError(reply, err);
580
+ }
581
+ });
582
+ // Delete a chat (session) within a project: removes its transcript JSONL.
583
+ app.delete("/chats/:sessionId", {
584
+ schema: {
585
+ tags: ["Chats"],
586
+ summary: "Delete a project chat",
587
+ description: "Deletes a project chat (session): removes its transcript JSONL and clears any archived/starred flag so a future session id can't inherit it. Responds 200 with `{ ok, removed }`.",
588
+ params: {
589
+ type: "object",
590
+ properties: {
591
+ slug: { type: "string", description: "Project slug." },
592
+ sessionId: { type: "string", description: "Chat (session) id." },
593
+ },
594
+ required: ["slug", "sessionId"],
595
+ },
596
+ response: {
597
+ 200: {
598
+ description: "Object `{ ok, removed }` indicating whether the transcript was removed.",
599
+ type: "object",
600
+ additionalProperties: true,
601
+ },
602
+ },
603
+ },
604
+ }, async (req, reply) => {
605
+ try {
606
+ const agent = await agentForSlug(req.params.slug);
607
+ await cleanupAttachments(agent, req.params.sessionId);
608
+ const removed = await herdctl.deleteSession(agent, req.params.sessionId);
609
+ // Drop any archived/starred/unread/detached flag so a future session id
610
+ // can't inherit it. (Read-state watermarks, other users' unread overrides,
611
+ // provenance and any queued message are deliberately NOT swept here — that
612
+ // predates #508 and is a wider cleanup than a chat delete should attempt.)
613
+ await archive.setArchived(agent, req.params.sessionId, false).catch(() => undefined);
614
+ await star.setStarred(agent, req.params.sessionId, false).catch(() => undefined);
615
+ await unread
616
+ .setUnread(readStateUser(req), agent, req.params.sessionId, false)
617
+ .catch(() => undefined);
618
+ // #508: the detach override is per-chat state like the three above, so it
619
+ // has to be cleared on the same path — otherwise a recycled session id
620
+ // silently starts life detached from a parent it never had.
621
+ await parentDetach.setDetached(agent, req.params.sessionId, false).catch(() => undefined);
622
+ return reply.code(200).send({ ok: true, removed });
623
+ }
624
+ catch (err) {
625
+ return sendProjectError(reply, err);
626
+ }
627
+ });
628
+ // Rename a chat (session): set or clear its custom display name. Now unblocked
629
+ // by @herdctl/core's fleet.setSessionName (issue #10). A null/empty name
630
+ // clears any custom name.
631
+ app.patch("/chats/:sessionId", {
632
+ schema: {
633
+ tags: ["Chats"],
634
+ summary: "Rename a project chat",
635
+ description: "Sets or clears a project chat's custom display name. A null/empty `name` in the body clears any custom name. Responds 200 with `{ ok }`.",
636
+ params: {
637
+ type: "object",
638
+ properties: {
639
+ slug: { type: "string", description: "Project slug." },
640
+ sessionId: { type: "string", description: "Chat (session) id." },
641
+ },
642
+ required: ["slug", "sessionId"],
643
+ },
644
+ body: {
645
+ type: ["object", "null"],
646
+ additionalProperties: true,
647
+ properties: {
648
+ name: { description: "New display name; null/empty clears the custom name." },
649
+ },
650
+ required: [],
651
+ },
652
+ response: {
653
+ 200: {
654
+ description: "Object `{ ok }`.",
655
+ type: "object",
656
+ additionalProperties: true,
657
+ },
658
+ },
659
+ },
660
+ }, async (req, reply) => {
661
+ try {
662
+ const agent = await agentForSlug(req.params.slug);
663
+ const name = req.body?.name ?? null;
664
+ await herdctl.renameSession(agent, req.params.sessionId, name);
665
+ return reply.code(200).send({ ok: true });
666
+ }
667
+ catch (err) {
668
+ return sendProjectError(reply, err);
669
+ }
670
+ });
671
+ // Fork a project chat: eagerly duplicate its transcript into a NEW session in
672
+ // the same project (leaving the source untouched) so the fork exists right
673
+ // away — a real, resumable chat with the parent's full history — rather than
674
+ // being created lazily on a first message. Optional `name` sets its title
675
+ // (e.g. "Fork of <parent>"). Returns the new session id.
676
+ app.post("/chats/:sessionId/fork", {
677
+ schema: {
678
+ tags: ["Chats"],
679
+ summary: "Fork a project chat",
680
+ description: "Eagerly duplicates a project chat's transcript into a NEW session in the same project (leaving the source untouched), so the fork exists immediately as a real, resumable chat with the parent's history. Optional body `{ name }` sets the fork's title; optional `{ fromUuid }` branches at an earlier message so the fork inherits only the prefix up to that turn (issue #451). Responds 201 with `{ sessionId }` (the new session id).",
681
+ params: {
682
+ type: "object",
683
+ properties: {
684
+ slug: { type: "string", description: "Project slug." },
685
+ sessionId: { type: "string", description: "Source chat (session) id to fork." },
686
+ },
687
+ required: ["slug", "sessionId"],
688
+ },
689
+ body: {
690
+ type: ["object", "null"],
691
+ additionalProperties: true,
692
+ properties: {
693
+ name: { description: "Title for the new forked chat." },
694
+ fromUuid: {
695
+ description: "Message uuid to branch at; the fork inherits only the history up to that turn. Omitted ⇒ the full transcript.",
696
+ },
697
+ },
698
+ },
699
+ response: {
700
+ 201: {
701
+ description: "Object `{ sessionId }` with the new forked session id.",
702
+ type: "object",
703
+ additionalProperties: true,
704
+ },
705
+ },
706
+ },
707
+ }, async (req, reply) => {
708
+ try {
709
+ const project = await projects.get(req.params.slug);
710
+ // `fromUuid` (issue #451): branch at an earlier message instead of copying
711
+ // the whole history — the fork inherits only the prefix up to that turn.
712
+ const newId = await herdctl.forkSession(project, req.params.sessionId, req.body?.name, req.body?.fromUuid);
713
+ // Record the lineage. A UI fork previously stamped NO provenance at all,
714
+ // so a hand-forked chat was indistinguishable from a human root — and its
715
+ // only parent link lived in the forking browser's localStorage. Inherit
716
+ // depth from the source so the fork-bomb bound (#262) still holds.
717
+ const src = await runProvenance.get(req.params.sessionId).catch(() => undefined);
718
+ await runProvenance
719
+ .stamp(newId, childOf(src ?? HUMAN_ROOT, { project: project.slug, sessionId: req.params.sessionId }))
720
+ .catch(() => undefined);
721
+ return reply.code(201).send({ sessionId: newId });
722
+ }
723
+ catch (err) {
724
+ return sendProjectError(reply, err);
725
+ }
726
+ });
727
+ // Revert a project chat back to an earlier message (issue #451): truncate this
728
+ // session's transcript at `uuid`, in place (same session id), so the chat
729
+ // continues as if the later turns never happened. The dropped tail is backed
730
+ // up (recoverable). Rolls back the CONVERSATION only — real side-effects of the
731
+ // reverted turns are NOT undone (the UI warns). Returns the count dropped.
732
+ app.post("/chats/:sessionId/revert", {
733
+ schema: {
734
+ tags: ["Chats"],
735
+ summary: "Revert a project chat to an earlier message",
736
+ description: "Truncates the chat's transcript at `uuid`, in place (same session id), so the conversation continues as if the later turns never happened. The dropped tail is backed up and recoverable. Rolls back the CONVERSATION only — real side-effects of the reverted turns are NOT undone. Requires body `{ uuid }` (400 without it). Returns an object with the count of removed messages.",
737
+ params: {
738
+ type: "object",
739
+ properties: {
740
+ slug: { type: "string", description: "Project slug." },
741
+ sessionId: { type: "string", description: "Chat (session) id to revert." },
742
+ },
743
+ required: ["slug", "sessionId"],
744
+ },
745
+ body: {
746
+ type: ["object", "null"],
747
+ additionalProperties: true,
748
+ properties: {
749
+ uuid: { description: "Message uuid to truncate at (required; 400 when missing)." },
750
+ },
751
+ },
752
+ response: {
753
+ 200: {
754
+ description: "Object reporting how many messages were removed.",
755
+ type: "object",
756
+ additionalProperties: true,
757
+ },
758
+ },
759
+ },
760
+ }, async (req, reply) => {
761
+ try {
762
+ const uuid = req.body?.uuid;
763
+ if (!uuid)
764
+ return reply.code(400).send({ error: "uuid is required" });
765
+ const project = await projects.get(req.params.slug);
766
+ const { removed } = await herdctl.revertSession(project, req.params.sessionId, uuid);
767
+ return reply.code(200).send({ ok: true, removed });
768
+ }
769
+ catch (err) {
770
+ return sendProjectError(reply, err);
771
+ }
772
+ });
773
+ // Archive (or unarchive) a project chat (#95). A non-destructive toggle on a
774
+ // persisted per-chat flag — the transcript is untouched and the chat stays
775
+ // openable/resumable/forkable; it just moves into the Archived section.
776
+ app.post("/chats/:sessionId/archive", {
777
+ schema: {
778
+ tags: ["Chats"],
779
+ summary: "Archive or unarchive a project chat",
780
+ description: "Non-destructive toggle of a project chat's persisted archived flag — the transcript is untouched and the chat stays openable/resumable/forkable. Body `{ archived }` defaults to true; a real transition into archived fires the project's onArchive hooks. Responds 200 with `{ ok, archived }`.",
781
+ params: {
782
+ type: "object",
783
+ properties: {
784
+ slug: { type: "string", description: "Project slug." },
785
+ sessionId: { type: "string", description: "Chat (session) id." },
786
+ },
787
+ required: ["slug", "sessionId"],
788
+ },
789
+ body: {
790
+ type: ["object", "null"],
791
+ additionalProperties: true,
792
+ properties: {
793
+ archived: { description: "Target archived state; defaults to true when omitted." },
794
+ },
795
+ required: [],
796
+ },
797
+ response: {
798
+ 200: {
799
+ description: "Object `{ ok, archived }` with the resulting archived state.",
800
+ type: "object",
801
+ additionalProperties: true,
802
+ },
803
+ },
804
+ },
805
+ }, async (req, reply) => {
806
+ try {
807
+ const agent = await agentForSlug(req.params.slug);
808
+ const archived = req.body?.archived !== false; // default true
809
+ const changed = await archive.setArchived(agent, req.params.sessionId, archived);
810
+ // Epic G / G1: after the archive COMMITS, emit `onArchive` (only on a real
811
+ // transition into archived) so the hook dispatcher fires the project's enabled
812
+ // onArchive hooks. Fire-and-forget — never blocks/fails the archive response.
813
+ if (changed && archived) {
814
+ events?.emit("onArchive", { slug: req.params.slug, sessionId: req.params.sessionId });
815
+ }
816
+ return reply.code(200).send({ ok: true, archived });
817
+ }
818
+ catch (err) {
819
+ return sendProjectError(reply, err);
820
+ }
821
+ });
822
+ // Star (or unstar) a project chat (#373). A non-destructive toggle on a
823
+ // persisted per-chat flag — orthogonal to archiving; a starred chat just sorts
824
+ // to the top of its population (active or Archived). Fires no lifecycle event.
825
+ app.post("/chats/:sessionId/star", {
826
+ schema: {
827
+ tags: ["Chats"],
828
+ summary: "Star or unstar a project chat",
829
+ description: "Non-destructive toggle of a project chat's persisted starred flag (orthogonal to archiving; a starred chat sorts to the top of its population). Body `{ starred }` defaults to true. Responds 200 with `{ ok, starred }`.",
830
+ params: {
831
+ type: "object",
832
+ properties: {
833
+ slug: { type: "string", description: "Project slug." },
834
+ sessionId: { type: "string", description: "Chat (session) id." },
835
+ },
836
+ required: ["slug", "sessionId"],
837
+ },
838
+ body: {
839
+ type: ["object", "null"],
840
+ additionalProperties: true,
841
+ properties: {
842
+ starred: { description: "Target starred state; defaults to true when omitted." },
843
+ },
844
+ required: [],
845
+ },
846
+ response: {
847
+ 200: {
848
+ description: "Object `{ ok, starred }` with the resulting starred state.",
849
+ type: "object",
850
+ additionalProperties: true,
851
+ },
852
+ },
853
+ },
854
+ }, async (req, reply) => {
855
+ try {
856
+ const agent = await agentForSlug(req.params.slug);
857
+ const starred = req.body?.starred !== false; // default true
858
+ await star.setStarred(agent, req.params.sessionId, starred);
859
+ return reply.code(200).send({ ok: true, starred });
860
+ }
861
+ catch (err) {
862
+ return sendProjectError(reply, err);
863
+ }
864
+ });
865
+ // Mark a project chat SEEN (#189): persist the user's last-viewed moment for
866
+ // this chat server-side (keyed by user when present, else a shared bucket), so
867
+ // the unread affordance (#160/#161) follows the user across devices. Body's
868
+ // optional `when` (epoch-ms) lets the client pass its own timestamp; defaults
869
+ // to now. Mirrors the archive toggle's shape/validation. Monotonic in the
870
+ // store (an older `when` is a no-op), so it never resurrects a stale unread.
871
+ app.post("/chats/:sessionId/seen", {
872
+ schema: {
873
+ tags: ["Chats"],
874
+ summary: "Mark a project chat as seen",
875
+ description: "Persists the user's last-viewed moment for a project chat server-side, driving the cross-device unread affordance. Optional body `{ when }` (epoch-ms) defaults to now; monotonic in the store (an older `when` is a no-op). Responds 200 with `{ ok, lastSeen }`.",
876
+ params: {
877
+ type: "object",
878
+ properties: {
879
+ slug: { type: "string", description: "Project slug." },
880
+ sessionId: { type: "string", description: "Chat (session) id." },
881
+ },
882
+ required: ["slug", "sessionId"],
883
+ },
884
+ body: {
885
+ type: ["object", "null"],
886
+ additionalProperties: true,
887
+ properties: {
888
+ when: { description: "Epoch-ms timestamp to record as last seen; defaults to now." },
889
+ },
890
+ required: [],
891
+ },
892
+ response: {
893
+ 200: {
894
+ description: "Object `{ ok, lastSeen }` with the recorded watermark.",
895
+ type: "object",
896
+ additionalProperties: true,
897
+ },
898
+ },
899
+ },
900
+ }, async (req, reply) => {
901
+ try {
902
+ const agent = await agentForSlug(req.params.slug);
903
+ const when = typeof req.body?.when === "number" && Number.isFinite(req.body.when)
904
+ ? req.body.when
905
+ : Date.now();
906
+ const user = readStateUser(req);
907
+ await readState.setLastSeen(user, agent, req.params.sessionId, when);
908
+ // Marking a chat seen also clears any MANUAL unread override (#458): once
909
+ // the user has viewed it, the "look at it again later" flag is spent.
910
+ await unread.setUnread(user, agent, req.params.sessionId, false).catch(() => undefined);
911
+ return reply.code(200).send({ ok: true, lastSeen: when });
912
+ }
913
+ catch (err) {
914
+ return sendProjectError(reply, err);
915
+ }
916
+ });
917
+ // Mark a project chat UNREAD (#458): set (or clear) the per-user MANUAL unread
918
+ // override, so a chat resurfaces its unread cue even after its last turn was
919
+ // seen — the email "mark as unread" pattern. Mirrors the archive/star toggles'
920
+ // shape (`{ unread?: boolean }`, defaults true). Clearing it is equivalent to
921
+ // marking seen; the client's toggle uses `/seen` for read and this for unread.
922
+ app.post("/chats/:sessionId/unread", {
923
+ schema: {
924
+ tags: ["Chats"],
925
+ summary: "Mark a project chat unread",
926
+ description: "Sets (or clears) the per-user MANUAL unread override so a chat resurfaces its unread cue even after its last turn was seen — the email \"mark as unread\" pattern. Body `{ unread }` defaults to true; clearing it is equivalent to marking seen (see the `/seen` route). Returns `{ ok, unread }`.",
927
+ params: {
928
+ type: "object",
929
+ properties: {
930
+ slug: { type: "string", description: "Project slug." },
931
+ sessionId: { type: "string", description: "Chat (session) id." },
932
+ },
933
+ required: ["slug", "sessionId"],
934
+ },
935
+ body: {
936
+ type: ["object", "null"],
937
+ additionalProperties: true,
938
+ properties: {
939
+ unread: { description: "Whether to flag the chat unread. Defaults to true." },
940
+ },
941
+ },
942
+ response: {
943
+ 200: {
944
+ description: "Object `{ ok, unread }` reflecting the stored flag.",
945
+ type: "object",
946
+ additionalProperties: true,
947
+ },
948
+ },
949
+ },
950
+ }, async (req, reply) => {
951
+ try {
952
+ const agent = await agentForSlug(req.params.slug);
953
+ const flag = req.body?.unread !== false; // default true
954
+ await unread.setUnread(readStateUser(req), agent, req.params.sessionId, flag);
955
+ return reply.code(200).send({ ok: true, unread: flag });
956
+ }
957
+ catch (err) {
958
+ return sendProjectError(reply, err);
959
+ }
960
+ });
961
+ // Detach a project chat from its parent (#508): promote it — with its own
962
+ // subtree intact — to the top level of the nested chat list, so a family can be
963
+ // archived/deleted "except this one".
964
+ //
965
+ // This is an OVERRIDE, not an edit: most live parent edges are INFERRED from
966
+ // who injected the kickoff prompt rather than recorded, so clearing an edge
967
+ // would simply be re-derived on the next list load. The flag is checked ahead of
968
+ // both resolver tiers (see `makeParentResolver`). Nothing is destroyed, so
969
+ // `{ detached: false }` re-attaches for free.
970
+ app.post("/chats/:sessionId/detach", {
971
+ schema: {
972
+ tags: ["Chats"],
973
+ summary: "Detach a project chat from its parent",
974
+ description: "Promotes a nested chat to the top level of the chat tree, keeping its own descendants under it. Persisted as an explicit override that is checked AHEAD of both parent-resolution tiers (the recorded `RunProvenance.parentSessionId` and the inferred kickoff-message edge), so it survives a reload. Body `{ detached }` defaults to true; `false` re-attaches (the underlying edge was never destroyed). Responds 200 with `{ ok, detached }`.",
975
+ params: {
976
+ type: "object",
977
+ properties: {
978
+ slug: { type: "string", description: "Project slug." },
979
+ sessionId: { type: "string", description: "Chat (session) id." },
980
+ },
981
+ required: ["slug", "sessionId"],
982
+ },
983
+ body: {
984
+ type: ["object", "null"],
985
+ additionalProperties: true,
986
+ properties: {
987
+ detached: {
988
+ description: "Whether the chat should render as a root regardless of its parent edge. Defaults to true.",
989
+ },
990
+ },
991
+ required: [],
992
+ },
993
+ response: {
994
+ 200: {
995
+ description: "Object `{ ok, detached }` with the resulting detached state.",
996
+ type: "object",
997
+ additionalProperties: true,
998
+ },
999
+ },
1000
+ },
1001
+ }, async (req, reply) => {
1002
+ try {
1003
+ const agent = await agentForSlug(req.params.slug);
1004
+ const detached = req.body?.detached !== false; // default true
1005
+ await parentDetach.setDetached(agent, req.params.sessionId, detached);
1006
+ return reply.code(200).send({ ok: true, detached });
1007
+ }
1008
+ catch (err) {
1009
+ return sendProjectError(reply, err);
1010
+ }
1011
+ });
1012
+ // --- batch (subtree) chat actions (#508) -------------------------------
1013
+ //
1014
+ // Shift-clicking a parent row applies archive / mark-read-unread / delete to
1015
+ // that chat AND every descendant. Looping the single-chat routes client-side
1016
+ // over 21 chats is 21 round trips that can half-succeed, leaving a torn family
1017
+ // (parent archived, some children not) with no single thing to roll back. These
1018
+ // routes take the whole id set, so the flag sidecars commit in ONE write and the
1019
+ // client has one call to roll back.
1020
+ //
1021
+ // They sit under the literal `/chats/batch/` prefix; a real session id is a UUID,
1022
+ // so it can never collide with the `/chats/:sessionId/...` routes.
1023
+ app.post("/chats/batch/archive", {
1024
+ schema: {
1025
+ tags: ["Chats"],
1026
+ summary: "Archive or unarchive many project chats",
1027
+ description: "Applies a project chat's archived flag to a whole SET of chats (the nested chat list's shift-click subtree action, #508) in a single atomic sidecar write, so a parent and its descendants can't end up in different states. Body `{ sessionIds, archived }`; `archived` defaults to true. Each real transition into archived fires the project's onArchive triggers, exactly like the single-chat route. Responds 200 with `{ ok, archived, changed }` where `changed` lists the ids whose flag actually moved.",
1028
+ params: {
1029
+ type: "object",
1030
+ properties: { slug: { type: "string", description: "Project slug." } },
1031
+ required: ["slug"],
1032
+ },
1033
+ body: {
1034
+ type: "object",
1035
+ additionalProperties: true,
1036
+ properties: {
1037
+ sessionIds: {
1038
+ type: "array",
1039
+ items: { type: "string" },
1040
+ description: "Chat (session) ids to apply the flag to. 1–500 entries.",
1041
+ },
1042
+ archived: { description: "Target archived state; defaults to true when omitted." },
1043
+ },
1044
+ required: ["sessionIds"],
1045
+ },
1046
+ response: {
1047
+ 200: {
1048
+ description: "Object `{ ok, archived, changed }`.",
1049
+ type: "object",
1050
+ additionalProperties: true,
1051
+ },
1052
+ },
1053
+ },
1054
+ }, async (req, reply) => {
1055
+ try {
1056
+ const sessionIds = normalizeBatchSessionIds(req.body?.sessionIds);
1057
+ if (!sessionIds)
1058
+ return sendBadBatch(reply);
1059
+ const agent = await agentForSlug(req.params.slug);
1060
+ const archived = req.body?.archived !== false; // default true
1061
+ const changed = await archive.setManyArchived(agent, sessionIds, archived);
1062
+ // One `onArchive` per REAL transition into archived — the same contract the
1063
+ // single-chat route keeps, so a subtree archive fires the project's triggers
1064
+ // for each newly-archived chat and for none of the already-archived ones.
1065
+ //
1066
+ // Note the FAN-OUT this implies: shift-archiving a 21-chat family emits 21
1067
+ // events, and a project with an enabled onArchive trigger will start 21
1068
+ // agent turns from one click. That is the contract working as specified
1069
+ // (the trigger asked to fire per archived chat), and coalescing here would
1070
+ // silently drop events a subscriber is entitled to — but it is a real cost,
1071
+ // and the first person to enable an onArchive trigger will meet it.
1072
+ // Rate-limiting belongs in the dispatcher, not in this route.
1073
+ if (archived) {
1074
+ for (const sessionId of changed) {
1075
+ events?.emit("onArchive", { slug: req.params.slug, sessionId });
1076
+ }
1077
+ }
1078
+ return reply.code(200).send({ ok: true, archived, changed });
1079
+ }
1080
+ catch (err) {
1081
+ return sendProjectError(reply, err);
1082
+ }
1083
+ });
1084
+ // Mark many project chats read or unread (#508). `unread: false` is the "mark
1085
+ // read" half and does BOTH halves of what reading a chat means — clears the
1086
+ // manual override AND advances each chat's last-seen watermark — because the
1087
+ // derived unread signal (#160) would otherwise re-raise the cue the instant the
1088
+ // manual flag was cleared.
1089
+ app.post("/chats/batch/unread", {
1090
+ schema: {
1091
+ tags: ["Chats"],
1092
+ summary: "Mark many project chats read or unread",
1093
+ description: "Applies the read/unread action to a whole SET of chats (the nested chat list's shift-click subtree action, #508) in a single atomic sidecar write. Body `{ sessionIds, unread }`; `unread` defaults to true. `unread: false` means \"mark read\": it clears each chat's manual unread override AND advances its last-seen watermark (the derived unread signal would otherwise immediately re-raise the cue). Responds 200 with `{ ok, unread, changed }`.",
1094
+ params: {
1095
+ type: "object",
1096
+ properties: { slug: { type: "string", description: "Project slug." } },
1097
+ required: ["slug"],
1098
+ },
1099
+ body: {
1100
+ type: "object",
1101
+ additionalProperties: true,
1102
+ properties: {
1103
+ sessionIds: {
1104
+ type: "array",
1105
+ items: { type: "string" },
1106
+ description: "Chat (session) ids to apply the flag to. 1–500 entries.",
1107
+ },
1108
+ unread: { description: "Whether to flag the chats unread. Defaults to true." },
1109
+ },
1110
+ required: ["sessionIds"],
1111
+ },
1112
+ response: {
1113
+ 200: {
1114
+ description: "Object `{ ok, unread, changed }`.",
1115
+ type: "object",
1116
+ additionalProperties: true,
1117
+ },
1118
+ },
1119
+ },
1120
+ }, async (req, reply) => {
1121
+ try {
1122
+ const sessionIds = normalizeBatchSessionIds(req.body?.sessionIds);
1123
+ if (!sessionIds)
1124
+ return sendBadBatch(reply);
1125
+ const agent = await agentForSlug(req.params.slug);
1126
+ const user = readStateUser(req);
1127
+ const flag = req.body?.unread !== false; // default true
1128
+ const changed = await unread.setManyUnread(user, agent, sessionIds, flag);
1129
+ // Marking read also advances read-state, mirroring the single-chat client
1130
+ // flow (which calls `/seen`, and `/seen` clears the manual flag).
1131
+ if (!flag) {
1132
+ await readState
1133
+ .setManyLastSeen(user, agent, sessionIds, Date.now())
1134
+ .catch(() => undefined);
1135
+ }
1136
+ return reply.code(200).send({ ok: true, unread: flag, changed });
1137
+ }
1138
+ catch (err) {
1139
+ return sendProjectError(reply, err);
1140
+ }
1141
+ });
1142
+ // Delete many project chats (#508) — the shift-click subtree delete, behind a
1143
+ // count-aware confirmation in the UI.
1144
+ //
1145
+ // Unlike the flag routes this can NOT be atomic: each delete unlinks a transcript
1146
+ // file, and the filesystem offers no transaction. So it is best-effort per chat
1147
+ // and honest about it — every id is attempted (one failure never abandons the
1148
+ // rest) and the response names which ones failed, so the client can surface a
1149
+ // partial failure instead of pretending the family is gone.
1150
+ app.post("/chats/batch/delete", {
1151
+ schema: {
1152
+ tags: ["Chats"],
1153
+ summary: "Delete many project chats",
1154
+ description: "Deletes a SET of project chats (the nested chat list's shift-click subtree delete, #508): removes each transcript JSONL and clears its archived/starred/unread/detached flags. Body `{ sessionIds }`. Filesystem deletes cannot be atomic, so this is best-effort per chat: every id is attempted and the response reports `{ ok, removed, failed }` — `removed` lists the ids whose transcript was deleted, `failed` the ids that errored.",
1155
+ params: {
1156
+ type: "object",
1157
+ properties: { slug: { type: "string", description: "Project slug." } },
1158
+ required: ["slug"],
1159
+ },
1160
+ body: {
1161
+ type: "object",
1162
+ additionalProperties: true,
1163
+ properties: {
1164
+ sessionIds: {
1165
+ type: "array",
1166
+ items: { type: "string" },
1167
+ description: "Chat (session) ids to delete. 1–500 entries.",
1168
+ },
1169
+ },
1170
+ required: ["sessionIds"],
1171
+ },
1172
+ response: {
1173
+ 200: {
1174
+ description: "Object `{ ok, removed, failed }`.",
1175
+ type: "object",
1176
+ additionalProperties: true,
1177
+ },
1178
+ },
1179
+ },
1180
+ }, async (req, reply) => {
1181
+ try {
1182
+ const sessionIds = normalizeBatchSessionIds(req.body?.sessionIds);
1183
+ if (!sessionIds)
1184
+ return sendBadBatch(reply);
1185
+ const agent = await agentForSlug(req.params.slug);
1186
+ const user = readStateUser(req);
1187
+ const removed = [];
1188
+ const failed = [];
1189
+ for (const sessionId of sessionIds) {
1190
+ try {
1191
+ await cleanupAttachments(agent, sessionId);
1192
+ const gone = await herdctl.deleteSession(agent, sessionId);
1193
+ if (gone)
1194
+ removed.push(sessionId);
1195
+ else
1196
+ failed.push(sessionId);
1197
+ }
1198
+ catch {
1199
+ failed.push(sessionId);
1200
+ }
1201
+ }
1202
+ // Drop the archived/starred/unread/detached flags of the chats that were
1203
+ // ACTUALLY removed, so a recycled session id can't inherit stale state.
1204
+ //
1205
+ // Deliberately scoped to `removed`, NOT the whole requested set: a chat in
1206
+ // `failed` still exists on disk, and the response has just told the user
1207
+ // so. Clearing its flags would quietly unarchive it (so it reappears in
1208
+ // the active list on the next refetch), unstar it, and re-attach it to the
1209
+ // parent it was detached from — a chat the user was told SURVIVED, mutated
1210
+ // by the delete that spared it.
1211
+ await archive.setManyArchived(agent, removed, false).catch(() => undefined);
1212
+ await unread.setManyUnread(user, agent, removed, false).catch(() => undefined);
1213
+ for (const sessionId of removed) {
1214
+ await star.setStarred(agent, sessionId, false).catch(() => undefined);
1215
+ await parentDetach.setDetached(agent, sessionId, false).catch(() => undefined);
1216
+ }
1217
+ return reply.code(200).send({ ok: true, removed, failed });
1218
+ }
1219
+ catch (err) {
1220
+ return sendProjectError(reply, err);
1221
+ }
1222
+ });
1223
+ // Promote a chat into a NEW project (issue #20): create the project + keeper,
1224
+ // then re-home the chat's transcript into it so it lists + resumes under the
1225
+ // new project. Returns { project, promoted } — `promoted:false` means the
1226
+ // project was created but the transcript couldn't be moved (e.g. an unknown
1227
+ // session id); the project is still usable.
1228
+ //
1229
+ // Nothing about it is root-specific: it takes a source workspace like every
1230
+ // other route in this file, and the root is a workspace.
1231
+ app.post("/chats/:sessionId/promote", {
1232
+ schema: {
1233
+ tags: ["Chats"],
1234
+ summary: "Promote a chat into a new project",
1235
+ description: "Promotes a chat into a NEW project: creates the project + its agent, then re-homes the chat's transcript into it so it lists and resumes under the new project. `name` is required (validated at runtime; a missing/blank name returns 400). Responds 201 with `{ project, promoted, sessionId }` — `promoted:false` means the project was created but the transcript couldn't be moved (e.g. an unknown session id); the project is still usable.",
1236
+ params: {
1237
+ type: "object",
1238
+ properties: {
1239
+ slug: { type: "string", description: "Slug of the project the chat currently lives in." },
1240
+ sessionId: { type: "string", description: "Chat (session) id to promote." },
1241
+ },
1242
+ required: ["slug", "sessionId"],
1243
+ },
1244
+ body: {
1245
+ type: ["object", "null"],
1246
+ additionalProperties: true,
1247
+ properties: {
1248
+ name: { description: "New project name (required; validated at runtime)." },
1249
+ slug: { description: "Optional explicit project slug." },
1250
+ group: { description: "Optional project group." },
1251
+ summary: { description: "Optional project summary." },
1252
+ domain: { description: "Optional project domain tags." },
1253
+ },
1254
+ required: [],
1255
+ },
1256
+ response: {
1257
+ 201: {
1258
+ description: "Object `{ project, promoted, sessionId }` describing the created project and whether the transcript was re-homed.",
1259
+ type: "object",
1260
+ additionalProperties: true,
1261
+ },
1262
+ },
1263
+ },
1264
+ }, async (req, reply) => {
1265
+ const body = req.body ?? {};
1266
+ if (!body.name || !body.name.trim()) {
1267
+ return reply.code(400).send({ error: "Project name is required", code: "invalid" });
1268
+ }
1269
+ try {
1270
+ const from = await projects.get(req.params.slug);
1271
+ const project = await projects.create({
1272
+ name: body.name,
1273
+ slug: body.slug,
1274
+ group: body.group,
1275
+ summary: body.summary,
1276
+ domain: Array.isArray(body.domain) ? body.domain : undefined,
1277
+ });
1278
+ // Register the keeper + sweeper (creates the project's .chats symlink)
1279
+ // BEFORE moving the transcript into it.
1280
+ try {
1281
+ await herdctl.ensureProjectAgent(project);
1282
+ }
1283
+ catch (err) {
1284
+ req.log.warn({ err }, "promote: keeper registration failed (project still created)");
1285
+ }
1286
+ let promoted = false;
1287
+ try {
1288
+ await herdctl.promoteSession(req.params.sessionId, from, project);
1289
+ promoted = true;
1290
+ }
1291
+ catch (err) {
1292
+ req.log.warn({ err }, "promote: could not re-home the transcript");
1293
+ }
1294
+ return reply.code(201).send({ project, promoted, sessionId: req.params.sessionId });
1295
+ }
1296
+ catch (err) {
1297
+ return sendProjectError(reply, err);
1298
+ }
1299
+ });
1300
+ // --- importing native Claude Code chats (#588) -------------------------
1301
+ //
1302
+ // A workspace is backed by a working directory, and the user very often
1303
+ // already has terminal `claude` history for it — or for their own checkout of
1304
+ // the same repo elsewhere on disk. These two routes surface that history and
1305
+ // pull it in. Detection lives in `../adoptable.ts`; the actual adoption is the
1306
+ // engine's primitive, threaded through `herdctl.adoptChats`.
1307
+ //
1308
+ // Both are workspace-scoped like everything else in this file, so the ROOT
1309
+ // workspace (key "") gets them from the same mount, unchanged.
1310
+ app.get("/adoptable-chats", {
1311
+ schema: {
1312
+ tags: ["Chats"],
1313
+ summary: "Count the native Claude Code chats this project could import",
1314
+ description: "Returns the terminal `claude` sessions that could be imported into this project but are not yet visible in it: the project's own working directory, plus any Claude transcript folder whose recorded working directory matches the project (by checkout name for a repo-backed project, by exact path for a notebook project). Sidechains, already-imported sessions and sessions belonging to a real Paddock run are excluded by the engine; empty and slash-command-only transcripts are additionally withheld as noise and reported under `filtered`, so a count lower than the raw total always has an explanation. Response: `{ count, sources: [{ sourceCwd, sessionIds }], filtered: [{ sessionId, sourceCwd, reason }] }`. The count is LIVE — re-read it after an import rather than remembering a dismissal.",
1315
+ params: {
1316
+ type: "object",
1317
+ properties: {
1318
+ slug: { type: "string", description: "Project slug." },
1319
+ },
1320
+ required: ["slug"],
1321
+ },
1322
+ response: {
1323
+ 200: {
1324
+ description: "Object `{ count, sources, filtered }` describing what this project could import.",
1325
+ type: "object",
1326
+ additionalProperties: true,
1327
+ },
1328
+ },
1329
+ },
1330
+ }, async (req, reply) => {
1331
+ try {
1332
+ const project = await projects.get(req.params.slug);
1333
+ return await herdctl.listAdoptable(project);
1334
+ }
1335
+ catch (err) {
1336
+ return sendProjectError(reply, err);
1337
+ }
1338
+ });
1339
+ app.post("/adopt-chats", {
1340
+ schema: {
1341
+ tags: ["Chats"],
1342
+ summary: "Import native Claude Code chats into this project",
1343
+ description: "Imports the terminal `claude` sessions reported by GET …/adoptable-chats. With no body (or no `sourceCwd`) every detected source is imported; with `sourceCwd` only that source is, and it must be one this project actually offers — an unrecognised path is a 400 rather than an invitation to scan arbitrary directories. Transcripts are COPIED, never moved: the user's own `~/.claude` history is left intact and the copies keep their original mtimes, so imported chats sort by when they really happened. Afterwards the session, run-record and detection caches are dropped, so the chats appear on the very next list request with no restart. Response: `{ adopted, skipped }` — `skipped` carries the engine's reason per session (`sidechain`, `already-adopted`, `destination-exists`, `attributed-to-run`, `unreadable`, `placement-failed`, `record-failed`).",
1344
+ params: {
1345
+ type: "object",
1346
+ properties: {
1347
+ slug: { type: "string", description: "Project slug." },
1348
+ },
1349
+ required: ["slug"],
1350
+ },
1351
+ body: {
1352
+ type: ["object", "null"],
1353
+ additionalProperties: true,
1354
+ properties: {
1355
+ sourceCwd: {
1356
+ description: "Optional single source working directory to import from. Must be one of the `sourceCwd` values reported by GET …/adoptable-chats (or the project's own working directory); anything else is rejected with 400.",
1357
+ },
1358
+ },
1359
+ required: [],
1360
+ },
1361
+ response: {
1362
+ 200: {
1363
+ description: "Object `{ adopted, skipped }` describing what was imported.",
1364
+ type: "object",
1365
+ additionalProperties: true,
1366
+ },
1367
+ },
1368
+ },
1369
+ }, async (req, reply) => {
1370
+ try {
1371
+ const project = await projects.get(req.params.slug);
1372
+ const sourceCwd = req.body?.sourceCwd;
1373
+ // `=== undefined`, never truthiness: a caller could legitimately post an
1374
+ // empty body, and the workspace key itself may be "" — falsiness tests
1375
+ // are how the root workspace gets silently dropped (#531).
1376
+ if (sourceCwd !== undefined) {
1377
+ const { sources } = await herdctl.listAdoptable(project);
1378
+ const known = sourceCwd === project.workingDir || sources.some((s) => s.sourceCwd === sourceCwd);
1379
+ if (!known) {
1380
+ return reply
1381
+ .code(400)
1382
+ .send({ error: `Not an importable source: ${sourceCwd}`, code: "invalid" });
1383
+ }
1384
+ }
1385
+ const result = await herdctl.adoptChats(project, { sourceCwd });
1386
+ // Stamp each imported chat's provenance so the list can badge it as
1387
+ // imported rather than as an ordinary human chat. `stampIfAbsent`, not
1388
+ // `stamp`: provenance describes how a chat came to exist and is never
1389
+ // clobbered — if a marker is somehow already there, it knows more than
1390
+ // the import does. Best-effort; a sidecar write must not fail an import
1391
+ // that already succeeded on disk.
1392
+ await Promise.all(result.adopted.map((sessionId) => runProvenance.stampIfAbsent(sessionId, ADOPTED_ROOT).catch(() => undefined)));
1393
+ return result;
1394
+ }
1395
+ catch (err) {
1396
+ // A session id that isn't path-safe is bad INPUT, not a server fault:
1397
+ // the engine refuses to build a state-file path from it and throws
1398
+ // PathTraversalError. Map it to 400 so it reads as "that id is not
1399
+ // importable" rather than an internal error.
1400
+ if (err instanceof PathTraversalError) {
1401
+ return reply.code(400).send({ error: err.message, code: "invalid" });
1402
+ }
1403
+ return sendProjectError(reply, err);
1404
+ }
1405
+ });
1406
+ }