@edspencer/paddock 0.0.1 → 0.56.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 (294) 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 +512 -0
  33. package/packages/server/dist/config.js +593 -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/event-bus.d.ts +87 -0
  37. package/packages/server/dist/event-bus.js +48 -0
  38. package/packages/server/dist/git.d.ts +128 -0
  39. package/packages/server/dist/git.js +446 -0
  40. package/packages/server/dist/github-auth.d.ts +44 -0
  41. package/packages/server/dist/github-auth.js +169 -0
  42. package/packages/server/dist/herdctl-agent-config.d.ts +98 -0
  43. package/packages/server/dist/herdctl-agent-config.js +331 -0
  44. package/packages/server/dist/herdctl-agent-names.d.ts +172 -0
  45. package/packages/server/dist/herdctl-agent-names.js +228 -0
  46. package/packages/server/dist/herdctl-jobs-index.d.ts +65 -0
  47. package/packages/server/dist/herdctl-jobs-index.js +225 -0
  48. package/packages/server/dist/herdctl-jobs.d.ts +98 -0
  49. package/packages/server/dist/herdctl-jobs.js +225 -0
  50. package/packages/server/dist/herdctl.d.ts +572 -0
  51. package/packages/server/dist/herdctl.js +1218 -0
  52. package/packages/server/dist/hook-config.d.ts +217 -0
  53. package/packages/server/dist/hook-config.js +224 -0
  54. package/packages/server/dist/http-bytes.d.ts +24 -0
  55. package/packages/server/dist/http-bytes.js +52 -0
  56. package/packages/server/dist/index.d.ts +1 -0
  57. package/packages/server/dist/index.js +14 -0
  58. package/packages/server/dist/instance-config.d.ts +135 -0
  59. package/packages/server/dist/instance-config.js +345 -0
  60. package/packages/server/dist/localcommand.d.ts +23 -0
  61. package/packages/server/dist/localcommand.js +140 -0
  62. package/packages/server/dist/management-auth.d.ts +99 -0
  63. package/packages/server/dist/management-auth.js +165 -0
  64. package/packages/server/dist/management-config.d.ts +164 -0
  65. package/packages/server/dist/management-config.js +286 -0
  66. package/packages/server/dist/management-mcp-server.d.ts +20 -0
  67. package/packages/server/dist/management-mcp-server.js +95 -0
  68. package/packages/server/dist/management-metadata.d.ts +67 -0
  69. package/packages/server/dist/management-metadata.js +56 -0
  70. package/packages/server/dist/management-ops.d.ts +110 -0
  71. package/packages/server/dist/management-ops.js +449 -0
  72. package/packages/server/dist/management-policy.d.ts +172 -0
  73. package/packages/server/dist/management-policy.js +259 -0
  74. package/packages/server/dist/message-provenance.d.ts +112 -0
  75. package/packages/server/dist/message-provenance.js +260 -0
  76. package/packages/server/dist/models.d.ts +164 -0
  77. package/packages/server/dist/models.js +208 -0
  78. package/packages/server/dist/openapi.d.ts +66 -0
  79. package/packages/server/dist/openapi.js +183 -0
  80. package/packages/server/dist/parent-detach.d.ts +30 -0
  81. package/packages/server/dist/parent-detach.js +109 -0
  82. package/packages/server/dist/preload.d.ts +43 -0
  83. package/packages/server/dist/preload.js +55 -0
  84. package/packages/server/dist/project-files.d.ts +76 -0
  85. package/packages/server/dist/project-files.js +191 -0
  86. package/packages/server/dist/project-mime.d.ts +35 -0
  87. package/packages/server/dist/project-mime.js +55 -0
  88. package/packages/server/dist/project-paths.d.ts +88 -0
  89. package/packages/server/dist/project-paths.js +126 -0
  90. package/packages/server/dist/project-types.d.ts +316 -0
  91. package/packages/server/dist/project-types.js +48 -0
  92. package/packages/server/dist/projects.d.ts +202 -0
  93. package/packages/server/dist/projects.js +894 -0
  94. package/packages/server/dist/queued-message.d.ts +35 -0
  95. package/packages/server/dist/queued-message.js +116 -0
  96. package/packages/server/dist/read-state.d.ts +46 -0
  97. package/packages/server/dist/read-state.js +139 -0
  98. package/packages/server/dist/recovery-config.d.ts +111 -0
  99. package/packages/server/dist/recovery-config.js +105 -0
  100. package/packages/server/dist/recovery.d.ts +262 -0
  101. package/packages/server/dist/recovery.js +490 -0
  102. package/packages/server/dist/resume-drain.d.ts +122 -0
  103. package/packages/server/dist/resume-drain.js +158 -0
  104. package/packages/server/dist/route-context.d.ts +168 -0
  105. package/packages/server/dist/route-context.js +39 -0
  106. package/packages/server/dist/route-errors.d.ts +8 -0
  107. package/packages/server/dist/route-errors.js +13 -0
  108. package/packages/server/dist/routes/chats.d.ts +12 -0
  109. package/packages/server/dist/routes/chats.js +1406 -0
  110. package/packages/server/dist/routes/git.d.ts +13 -0
  111. package/packages/server/dist/routes/git.js +236 -0
  112. package/packages/server/dist/routes/mcp.d.ts +83 -0
  113. package/packages/server/dist/routes/mcp.js +250 -0
  114. package/packages/server/dist/routes/meta.d.ts +15 -0
  115. package/packages/server/dist/routes/meta.js +447 -0
  116. package/packages/server/dist/routes/projects.d.ts +25 -0
  117. package/packages/server/dist/routes/projects.js +736 -0
  118. package/packages/server/dist/routes/triggers.d.ts +21 -0
  119. package/packages/server/dist/routes/triggers.js +324 -0
  120. package/packages/server/dist/routes/workspace-mount.d.ts +48 -0
  121. package/packages/server/dist/routes/workspace-mount.js +32 -0
  122. package/packages/server/dist/routes.d.ts +26 -0
  123. package/packages/server/dist/routes.js +29 -0
  124. package/packages/server/dist/run-provenance.d.ts +109 -0
  125. package/packages/server/dist/run-provenance.js +206 -0
  126. package/packages/server/dist/runs.d.ts +92 -0
  127. package/packages/server/dist/runs.js +72 -0
  128. package/packages/server/dist/schedule-config.d.ts +68 -0
  129. package/packages/server/dist/schedule-config.js +75 -0
  130. package/packages/server/dist/schedule-session.d.ts +34 -0
  131. package/packages/server/dist/schedule-session.js +120 -0
  132. package/packages/server/dist/self-mcp-descriptions.d.ts +20 -0
  133. package/packages/server/dist/self-mcp-descriptions.js +145 -0
  134. package/packages/server/dist/self-mcp-read.d.ts +15 -0
  135. package/packages/server/dist/self-mcp-read.js +97 -0
  136. package/packages/server/dist/self-mcp-triggers.d.ts +30 -0
  137. package/packages/server/dist/self-mcp-triggers.js +144 -0
  138. package/packages/server/dist/self-mcp-types.d.ts +248 -0
  139. package/packages/server/dist/self-mcp-types.js +10 -0
  140. package/packages/server/dist/self-mcp-util.d.ts +73 -0
  141. package/packages/server/dist/self-mcp-util.js +157 -0
  142. package/packages/server/dist/self-mcp-write.d.ts +35 -0
  143. package/packages/server/dist/self-mcp-write.js +215 -0
  144. package/packages/server/dist/self-mcp.d.ts +95 -0
  145. package/packages/server/dist/self-mcp.js +448 -0
  146. package/packages/server/dist/send-file-mcp.d.ts +46 -0
  147. package/packages/server/dist/send-file-mcp.js +281 -0
  148. package/packages/server/dist/session-hub.d.ts +186 -0
  149. package/packages/server/dist/session-hub.js +291 -0
  150. package/packages/server/dist/spawn-capability.d.ts +99 -0
  151. package/packages/server/dist/spawn-capability.js +106 -0
  152. package/packages/server/dist/star.d.ts +26 -0
  153. package/packages/server/dist/star.js +94 -0
  154. package/packages/server/dist/start.d.ts +1 -0
  155. package/packages/server/dist/start.js +21 -0
  156. package/packages/server/dist/subagents.d.ts +337 -0
  157. package/packages/server/dist/subagents.js +626 -0
  158. package/packages/server/dist/sweep.d.ts +122 -0
  159. package/packages/server/dist/sweep.js +696 -0
  160. package/packages/server/dist/tooldetails.d.ts +33 -0
  161. package/packages/server/dist/tooldetails.js +466 -0
  162. package/packages/server/dist/transcribe.d.ts +69 -0
  163. package/packages/server/dist/transcribe.js +119 -0
  164. package/packages/server/dist/transcripts.d.ts +42 -0
  165. package/packages/server/dist/transcripts.js +164 -0
  166. package/packages/server/dist/trigger-config.d.ts +278 -0
  167. package/packages/server/dist/trigger-config.js +417 -0
  168. package/packages/server/dist/trigger-runtime.d.ts +94 -0
  169. package/packages/server/dist/trigger-runtime.js +82 -0
  170. package/packages/server/dist/trigger-session.d.ts +34 -0
  171. package/packages/server/dist/trigger-session.js +122 -0
  172. package/packages/server/dist/triggers.d.ts +83 -0
  173. package/packages/server/dist/triggers.js +124 -0
  174. package/packages/server/dist/trusted-proxy.d.ts +57 -0
  175. package/packages/server/dist/trusted-proxy.js +157 -0
  176. package/packages/server/dist/turn-notice.d.ts +154 -0
  177. package/packages/server/dist/turn-notice.js +271 -0
  178. package/packages/server/dist/unread.d.ts +36 -0
  179. package/packages/server/dist/unread.js +123 -0
  180. package/packages/server/dist/usage.d.ts +66 -0
  181. package/packages/server/dist/usage.js +243 -0
  182. package/packages/server/dist/wake-injection.d.ts +156 -0
  183. package/packages/server/dist/wake-injection.js +136 -0
  184. package/packages/server/dist/whisper-local.d.ts +9 -0
  185. package/packages/server/dist/whisper-local.js +122 -0
  186. package/packages/server/dist/ws-context.d.ts +158 -0
  187. package/packages/server/dist/ws-context.js +1 -0
  188. package/packages/server/dist/ws-protocol.d.ts +423 -0
  189. package/packages/server/dist/ws-protocol.js +251 -0
  190. package/packages/server/dist/ws-self-mcp.d.ts +52 -0
  191. package/packages/server/dist/ws-self-mcp.js +28 -0
  192. package/packages/server/dist/ws-triggers.d.ts +29 -0
  193. package/packages/server/dist/ws-triggers.js +261 -0
  194. package/packages/server/dist/ws-turn.d.ts +86 -0
  195. package/packages/server/dist/ws-turn.js +751 -0
  196. package/packages/server/dist/ws.d.ts +62 -0
  197. package/packages/server/dist/ws.js +1024 -0
  198. package/packages/server/package.json +6 -0
  199. package/packages/web/dist/assets/InstanceConfigPage-4cQGTj9Y.js +2 -0
  200. package/packages/web/dist/assets/MarkdownRenderer-CJZFLj_8.js +30 -0
  201. package/packages/web/dist/assets/ProjectRedirect-BHwVbbqB.js +2 -0
  202. package/packages/web/dist/assets/ProjectView-BOvuAITg.js +13 -0
  203. package/packages/web/dist/assets/ProjectsGrid-BCojZOGY.js +2 -0
  204. package/packages/web/dist/assets/arc-BVyxvnkl.js +2 -0
  205. package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-Cd9Puqg1.js +37 -0
  206. package/packages/web/dist/assets/bash-I8pq0VWm.js +2 -0
  207. package/packages/web/dist/assets/blockDiagram-GPEHLZMM-B_AQt_Rx.js +133 -0
  208. package/packages/web/dist/assets/c-D2F1sQUL.js +2 -0
  209. package/packages/web/dist/assets/c4Diagram-AAUBKEIU-7e0TQtXB.js +11 -0
  210. package/packages/web/dist/assets/channel-C_1aISNn.js +2 -0
  211. package/packages/web/dist/assets/chunk-2J33WTMH-Dz4WZmUZ.js +2 -0
  212. package/packages/web/dist/assets/chunk-4BX2VUAB-DVWYldo6.js +2 -0
  213. package/packages/web/dist/assets/chunk-55IACEB6-BIMrizQd.js +2 -0
  214. package/packages/web/dist/assets/chunk-727SXJPM-CL2q9fF1.js +207 -0
  215. package/packages/web/dist/assets/chunk-AQP2D5EJ-BSq3HlQ9.js +232 -0
  216. package/packages/web/dist/assets/chunk-FMBD7UC4-Bgt64pLE.js +16 -0
  217. package/packages/web/dist/assets/chunk-ND2GUHAM-C8Pr2A38.js +2 -0
  218. package/packages/web/dist/assets/chunk-QZHKN3VN-BAOqh-MN.js +2 -0
  219. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-B8JE7cHJ.js +2 -0
  220. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-B8JE7cHJ.js +2 -0
  221. package/packages/web/dist/assets/core-lJeGACQg.js +4 -0
  222. package/packages/web/dist/assets/cose-bilkent-S5V4N54A-BF4tameA.js +2 -0
  223. package/packages/web/dist/assets/cpp-Bm3ii6kY.js +2 -0
  224. package/packages/web/dist/assets/csharp-BNpuYZMG.js +2 -0
  225. package/packages/web/dist/assets/css-DazXZka4.js +2 -0
  226. package/packages/web/dist/assets/cytoscape.esm-CUqq0XTU.js +332 -0
  227. package/packages/web/dist/assets/dagre-BM42HDAG-DdsLM6ql.js +5 -0
  228. package/packages/web/dist/assets/defaultLocale-DX6XiGOO.js +2 -0
  229. package/packages/web/dist/assets/diagram-2AECGRRQ-BQ78S_RM.js +44 -0
  230. package/packages/web/dist/assets/diagram-5GNKFQAL-CAKE6nHE.js +11 -0
  231. package/packages/web/dist/assets/diagram-KO2AKTUF-fEvHRXgr.js +4 -0
  232. package/packages/web/dist/assets/diagram-LMA3HP47-CtWsgeSm.js +25 -0
  233. package/packages/web/dist/assets/diagram-OG6HWLK6-Buog8k7_.js +25 -0
  234. package/packages/web/dist/assets/erDiagram-TEJ5UH35-CWvVVPZS.js +86 -0
  235. package/packages/web/dist/assets/flowDiagram-I6XJVG4X-DsCGXtp4.js +163 -0
  236. package/packages/web/dist/assets/format-DamJQ9LO.js +2 -0
  237. package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-CsdwIvvC.js +293 -0
  238. package/packages/web/dist/assets/gitGraphDiagram-PVQCEYII-hZvrX1sD.js +107 -0
  239. package/packages/web/dist/assets/go-BPx_y7j8.js +2 -0
  240. package/packages/web/dist/assets/graph-BlwPajkw.js +2 -0
  241. package/packages/web/dist/assets/index-CGbZdrm8.js +71 -0
  242. package/packages/web/dist/assets/index-DJDQ210-.css +1 -0
  243. package/packages/web/dist/assets/infoDiagram-5YYISTIA-nE_Vtz_l.js +3 -0
  244. package/packages/web/dist/assets/ini-B3BVgEsG.js +2 -0
  245. package/packages/web/dist/assets/init-Gi6I4Gst.js +2 -0
  246. package/packages/web/dist/assets/ishikawaDiagram-YF4QCWOH-2Nlqs0oH.js +71 -0
  247. package/packages/web/dist/assets/java-B5VKqtH2.js +2 -0
  248. package/packages/web/dist/assets/javascript-BKRaQes9.js +2 -0
  249. package/packages/web/dist/assets/journeyDiagram-JHISSGLW-mjU1fMc4.js +140 -0
  250. package/packages/web/dist/assets/json-DIYVocXf.js +2 -0
  251. package/packages/web/dist/assets/kanban-definition-UN3LZRKU-C_9xNaa3.js +90 -0
  252. package/packages/web/dist/assets/katex-C5jXJg4s.js +258 -0
  253. package/packages/web/dist/assets/kotlin-Cw-eF_bm.js +3 -0
  254. package/packages/web/dist/assets/lastTab-CU1zsF0j.js +2 -0
  255. package/packages/web/dist/assets/layout-Boy8vhSP.js +2 -0
  256. package/packages/web/dist/assets/linear-BxKd91ho.js +2 -0
  257. package/packages/web/dist/assets/mermaid.core-C-CuhD8I.js +310 -0
  258. package/packages/web/dist/assets/mindmap-definition-RKZ34NQL-Bs6yfcZv.js +97 -0
  259. package/packages/web/dist/assets/ordinal-Cboi1Yqb.js +2 -0
  260. package/packages/web/dist/assets/php-CRdd4dfS.js +3 -0
  261. package/packages/web/dist/assets/pieDiagram-4H26LBE5-DNdqX7O5.js +31 -0
  262. package/packages/web/dist/assets/python-kSlZHhJb.js +2 -0
  263. package/packages/web/dist/assets/quadrantDiagram-W4KKPZXB-ZMJl9OdZ.js +8 -0
  264. package/packages/web/dist/assets/queued-LamEU0GH.js +2 -0
  265. package/packages/web/dist/assets/requirementDiagram-4Y6WPE33-RmxMdm2q.js +85 -0
  266. package/packages/web/dist/assets/ruby-7T4ktxyv.js +2 -0
  267. package/packages/web/dist/assets/rust-HC3MBUhY.js +2 -0
  268. package/packages/web/dist/assets/sankeyDiagram-5OEKKPKP-OAAhWcfK.js +41 -0
  269. package/packages/web/dist/assets/scss-DZLA7YXz.js +2 -0
  270. package/packages/web/dist/assets/sequenceDiagram-3UESZ5HK-BWF7rCyN.js +163 -0
  271. package/packages/web/dist/assets/sql-BC88IN-V.js +2 -0
  272. package/packages/web/dist/assets/stateDiagram-AJRCARHV-M3b_zqqo.js +2 -0
  273. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CI7saFDa.js +2 -0
  274. package/packages/web/dist/assets/swift-Dn8kKO_1.js +2 -0
  275. package/packages/web/dist/assets/timeline-definition-PNZ67QCA-BpOCh63N.js +121 -0
  276. package/packages/web/dist/assets/typescript-C2FFdlUC.js +2 -0
  277. package/packages/web/dist/assets/vennDiagram-CIIHVFJN-D_iPH45Q.js +35 -0
  278. package/packages/web/dist/assets/wardley-L42UT6IY-DVD9I2Om.js +174 -0
  279. package/packages/web/dist/assets/wardleyDiagram-YWT4CUSO-Bn1IKwjn.js +79 -0
  280. package/packages/web/dist/assets/xml-BXBhIUeX.js +2 -0
  281. package/packages/web/dist/assets/xychartDiagram-2RQKCTM6-DwtI0bkR.js +8 -0
  282. package/packages/web/dist/assets/yaml-drsDnIKT.js +2 -0
  283. package/packages/web/dist/favicon.ico +0 -0
  284. package/packages/web/dist/fonts/inter-latin.woff2 +0 -0
  285. package/packages/web/dist/fonts/jetbrains-mono-latin.woff2 +0 -0
  286. package/packages/web/dist/icons/apple-touch-icon.png +0 -0
  287. package/packages/web/dist/icons/favicon-16.png +0 -0
  288. package/packages/web/dist/icons/favicon-32.png +0 -0
  289. package/packages/web/dist/icons/icon-192.png +0 -0
  290. package/packages/web/dist/icons/icon-512.png +0 -0
  291. package/packages/web/dist/icons/maskable-512.png +0 -0
  292. package/packages/web/dist/index.html +56 -0
  293. package/packages/web/dist/manifest.webmanifest +32 -0
  294. package/packages/web/dist/sw.js +139 -0
@@ -0,0 +1,1024 @@
1
+ import { createSDKMessageHandler } from "@herdctl/chat";
2
+ import { extractSubagentLaunches, subagentLaunchFields, } from "./subagents.js";
3
+ import { keeperAgentName, keeperSlugFromAgent, } from "./herdctl.js";
4
+ import { consumeResumedTurn } from "./resume-drain.js";
5
+ import { isKnownModel, getContextLimit, DEFAULT_MODEL, isKnownDriveMode, } from "./models.js";
6
+ import { SessionHub } from "./session-hub.js";
7
+ import { resolveAttachmentsConfig } from "./attachments-config.js";
8
+ import { wrapAttachments, inferAttachmentKind } from "./attachments-hint.js";
9
+ import { sendFileServerDef, SEND_FILE_SERVER_KEY } from "./send-file-mcp.js";
10
+ import { SELF_MCP_SERVER_KEY } from "./self-mcp.js";
11
+ import { HUMAN_ROOT } from "./run-provenance.js";
12
+ import { resolveRecoveryConfig } from "./recovery-config.js";
13
+ import { noticeFromMessage, errorNotice, messageProducedReply, suppressNoticeAfterReply, turnEffectivelySucceeded, } from "./turn-notice.js";
14
+ import { resolveHooksMcpEnabled } from "./hook-config.js";
15
+ // --- protocol + usage math (extracted #403) ---------------------------------
16
+ // The wire protocol (message interfaces + unions + `isClientMessage`) and the
17
+ // per-turn token/usage helpers now live in ws-protocol.ts. Re-export the whole
18
+ // surface so external importers (and tests) that `import … from "./ws.js"`
19
+ // resolve unchanged, then pull in the handful this module uses directly.
20
+ export * from "./ws-protocol.js";
21
+ // forkKickoffPrompt + buildSelfMcpServerDef now live in ws-self-mcp.ts; re-export
22
+ // forkKickoffPrompt so external importers (and its test) resolve it via ws.js.
23
+ export { forkKickoffPrompt } from "./ws-self-mcp.js";
24
+ import { buildSelfMcpServerDef } from "./ws-self-mcp.js";
25
+ import { makeTurnEngine, isSidechainMessage } from "./ws-turn.js";
26
+ // RECOVERY_NUDGE now lives in ws-turn.ts; re-export so its test resolves via ws.js.
27
+ export { RECOVERY_NUDGE } from "./ws-turn.js";
28
+ import { extractLocalCommandOutput, initTurnUsage, foldTurnUsage, resolveTurnUsage, isClientMessage, } from "./ws-protocol.js";
29
+ /**
30
+ * Register the /ws route handler. Pure transport: it validates messages,
31
+ * resolves the target agent, and streams a real trigger back to the socket.
32
+ */
33
+ // Server-side keepalive: how often to ping each client and, if the previous
34
+ // ping went unanswered, reap the dead socket. Protocol-level ping frames also
35
+ // keep intermediaries (proxies/NAT) from evicting an otherwise-idle connection.
36
+ // See issue #46.
37
+ const SERVER_PING_INTERVAL_MS = 30_000;
38
+ // Separator in a composite in-memory key; a NUL can't occur in an agent name or
39
+ // a UUID. Spelled as an escape, never a raw 0x00 byte — a literal NUL makes
40
+ // ripgrep/grep treat this file as binary and skip it during directory traversal
41
+ // (silently, exit 1, indistinguishable from "no matches"). See issue #570.
42
+ const KEY_SEP = "\u0000";
43
+ export function makeChatHandler(deps) {
44
+ // ONE hub shared across every socket this handler serves: it tracks each
45
+ // session's in-flight turn and fans its frames out to whichever socket(s) are
46
+ // currently attached, so a turn survives the death of the socket that started
47
+ // it (issue #54). See session-hub.ts.
48
+ const hub = new SessionHub();
49
+ // Per-session marker of the last queued message the server has already drained
50
+ // (#245), keyed `agent \0 sessionId` and stamped with that message's client
51
+ // timestamp. Lets an idle-drain skip a message it already sent — e.g. a stale
52
+ // localStorage copy a reloaded client re-asserts — instead of double-sending.
53
+ // In-memory (shared across this handler's sockets); a rare double-send only
54
+ // survives a server restart, when the persisted store is already empty anyway.
55
+ const lastFlushedTs = new Map();
56
+ // Every currently-connected socket, so a turn's start/stop transition can be
57
+ // broadcast to all clients — powering the per-chat sidebar streaming dots that
58
+ // must update even for chats whose pane isn't mounted (issue #53).
59
+ const clients = new Set();
60
+ const activeFrame = (info) => ({
61
+ type: "chat:active",
62
+ payload: {
63
+ projectSlug: info.projectSlug,
64
+ sessionId: info.sessionId,
65
+ jobId: info.jobId,
66
+ running: info.running,
67
+ },
68
+ });
69
+ hub.onActive = (info) => {
70
+ const data = JSON.stringify(activeFrame(info));
71
+ for (const c of clients) {
72
+ if (c.readyState === c.OPEN) {
73
+ try {
74
+ c.send(data);
75
+ }
76
+ catch {
77
+ /* a socket that throws on send is effectively gone */
78
+ }
79
+ }
80
+ }
81
+ };
82
+ // Drive scheduler-fired session wakes onto the hub (Paddock#111 gap 3). When a
83
+ // keeper scheduled a `ScheduleWakeup` / `/loop`, herdctl reaps the idle session
84
+ // and later resumes it at fire time, handing us the live (managed) session with
85
+ // NO client watching. We stream it exactly like a human turn — same translator,
86
+ // same hub — so the autonomous work lands in the transcript, drives the sidebar
87
+ // streaming dot (via `hub.onActive`), and is replayable by a client that opens
88
+ // the chat later. We do NOT close the session: it's managed, so the reaper tears
89
+ // it down when it goes idle again (and re-captures any fresh wakeups).
90
+ deps.herdctl.onSessionWake(async (session, entry) => {
91
+ // Only KEEPER agents own chats. A wake from a sweeper/trigger/hook agent has
92
+ // no chat to drive, so there is nothing to route.
93
+ const slug = keeperSlugFromAgent(entry.agent);
94
+ // `null` = not a keeper. `""` = the ROOT workspace's own key, so this must
95
+ // stay an explicit null check or every root chat's wake is dropped.
96
+ if (slug === null)
97
+ return;
98
+ let resolvedSession = entry.sessionId ?? null;
99
+ const turn = hub.startTurn(slug, null, entry.sessionId);
100
+ // #353: DON'T stamp a creation origin here. A session wake is a *resume*, not
101
+ // a *creation* — `onSessionWake` only ever resumes an already-existing chat
102
+ // (a human's, or a spawn's, that armed a `ScheduleWakeup`/`/loop`), it never
103
+ // creates one. Genuinely schedule-*created* chats are already stamped
104
+ // `scheduled` at creation by `fireTriggerForProject` → `startAgentTurn`, so
105
+ // dropping the wake stamp loses nothing for them. The old
106
+ // `stampIfAbsent(SCHEDULED_ROOT)` here was a category error: a chat that
107
+ // predates provenance stamping (empty slot) and later arms a ScheduleWakeup
108
+ // would get falsely labelled `scheduled` on its first wake — mislabelling a
109
+ // human-rooted chat. Leaving a legacy/blank chat unstamped renders no badge
110
+ // (the correct outcome for a human chat) instead of a wrong one.
111
+ const routing = () => ({
112
+ projectSlug: slug,
113
+ sessionId: resolvedSession,
114
+ jobId: turn.jobId,
115
+ });
116
+ // #429: sub-agent launches recovered live from the tool_use input, keyed by
117
+ // toolUseId, so the wake turn's launching card renders enriched + expandable
118
+ // without a refresh (see subagentLaunchFields).
119
+ const wakeLaunches = new Map();
120
+ const translate = createSDKMessageHandler({
121
+ onText: (chunk) => {
122
+ if (chunk)
123
+ turn.emit({ type: "chat:response", payload: { ...routing(), chunk } });
124
+ },
125
+ onBoundary: () => {
126
+ turn.emit({ type: "chat:message_boundary", payload: routing() });
127
+ },
128
+ onToolStart: (start) => {
129
+ turn.emit({
130
+ type: "chat:tool_start",
131
+ payload: {
132
+ ...routing(),
133
+ toolName: start.toolName,
134
+ inputSummary: start.inputSummary,
135
+ toolUseId: start.toolUseId,
136
+ parentToolUseId: start.parentToolUseId,
137
+ ...subagentLaunchFields(wakeLaunches, start.toolName, start.toolUseId),
138
+ },
139
+ });
140
+ },
141
+ onToolCall: (call) => {
142
+ turn.emit({
143
+ type: "chat:tool_call",
144
+ payload: {
145
+ ...routing(),
146
+ toolName: call.toolName,
147
+ inputSummary: call.inputSummary,
148
+ output: call.output,
149
+ isError: call.isError,
150
+ durationMs: call.durationMs,
151
+ toolUseId: call.toolUseId,
152
+ ...subagentLaunchFields(wakeLaunches, call.toolName, call.toolUseId),
153
+ },
154
+ });
155
+ },
156
+ });
157
+ // #329: surface a dead-end (usage limit / max-turns / error) on this wake
158
+ // turn too, at most once. Same rationale as the interactive path.
159
+ // #380/#394: once the turn has shown the user real prose, an error/max_turns
160
+ // dead-end is a false alarm — suppress it (matching the history path).
161
+ // `wakeProducedReply` is OR-accumulated across every message (a tool-heavy
162
+ // turn's prose rides on a `tool_use` message, not the terminal one). A
163
+ // `usage_limit` still surfaces.
164
+ let wakeNoticeEmitted = false;
165
+ let wakeProducedReply = false;
166
+ const emitWakeNotice = (notice) => {
167
+ if (wakeNoticeEmitted)
168
+ return;
169
+ if (suppressNoticeAfterReply(notice, wakeProducedReply))
170
+ return;
171
+ wakeNoticeEmitted = true;
172
+ turn.emit({ type: "chat:notice", payload: { ...routing(), notice } });
173
+ };
174
+ // Per-message wake handling, shared by the fast path and the drain path.
175
+ const onWakeSessionId = (id) => {
176
+ resolvedSession = id;
177
+ turn.setSession(id);
178
+ // #353: no provenance stamp on wake — see the note at the top of this
179
+ // handler. A resume must never write a creation origin.
180
+ };
181
+ const onWakeMessage = async (m) => {
182
+ // A sub-agent's nested steps never render top-level ({@link isSidechainMessage}).
183
+ if (isSidechainMessage(m))
184
+ return;
185
+ if (messageProducedReply(m))
186
+ wakeProducedReply = true;
187
+ const notice = noticeFromMessage(m);
188
+ if (notice)
189
+ emitWakeNotice(notice);
190
+ // #429: recover any Task/Agent launch before translate fires onToolStart.
191
+ for (const l of extractSubagentLaunches(m))
192
+ wakeLaunches.set(l.toolUseId, l);
193
+ await translate(m);
194
+ };
195
+ try {
196
+ // Resume self-interrupt fix. A woken session that still holds a stale
197
+ // async-input backlog replays it as its OWN turn ahead of the wake turn; the
198
+ // old break-after-first-result then closed the CLI on the backlog turn's
199
+ // result, killing the wake turn. A wake is always a resume, so consume with
200
+ // the queue-drain break rule — break on a `result` only once the async queue
201
+ // has drained (after the LAST/real wake turn). No backlog → breaks after the
202
+ // first result (fast). See ./resume-drain.ts.
203
+ if (entry.sessionId) {
204
+ await consumeResumedTurn(session, {
205
+ residueProbe: () => deps.herdctl.residueDepthFor(entry.agent, entry.sessionId),
206
+ onMessage: onWakeMessage,
207
+ onSessionId: onWakeSessionId,
208
+ log: (msg) =>
209
+ // eslint-disable-next-line no-console
210
+ console.log(`[resume-drain] wake ${entry.agent} (${entry.sessionId}): ${msg}`),
211
+ });
212
+ }
213
+ else {
214
+ for await (const m of session.messages) {
215
+ if (m.session_id)
216
+ onWakeSessionId(m.session_id);
217
+ await onWakeMessage(m);
218
+ if (m.type === "result")
219
+ break;
220
+ }
221
+ }
222
+ turn.emit({
223
+ type: "chat:complete",
224
+ payload: { ...routing(), sessionId: resolvedSession, success: true },
225
+ });
226
+ }
227
+ catch (err) {
228
+ const error = err instanceof Error ? err.message : String(err);
229
+ emitWakeNotice(errorNotice(error));
230
+ turn.emit({ type: "chat:complete", payload: { ...routing(), success: false, error } });
231
+ }
232
+ finally {
233
+ turn.end();
234
+ }
235
+ // Post-wake curation sweep, same as a human turn. Fires for EVERY workspace,
236
+ // the root included (its sweeper is `sweeper-_root`, registered at boot) — the
237
+ // emit is unconditional and skips nothing. T5: routed through the `afterTurn`
238
+ // event so the folded-in curator dispatches once.
239
+ emitAfterTurn(slug, resolvedSession ?? null);
240
+ });
241
+ // Turn-execution engine (ws-turn.ts, #403): the mutually-recursive core —
242
+ // trigger cluster, self-MCP injection context, per-turn injected-MCP builders +
243
+ // wake cache, the shared startAgentTurn engine, and Layer-2/3 recovery. Relocated
244
+ // as one scope so its cross-references stay intact; the socket layer below
245
+ // consumes the returned surface.
246
+ const engine = makeTurnEngine({ deps, hub });
247
+ const { startAgentTurn, wakeInjection, selfMcpCtx, injectRecoveryNudge, recoveryEngine, emitAfterTurn, composePreloadedPrompt, fireTrigger, makeBackgroundTurnSink, } = engine;
248
+ const handle = async function handle(socket) {
249
+ const send = (m) => {
250
+ if (socket.readyState === socket.OPEN)
251
+ socket.send(JSON.stringify(m));
252
+ };
253
+ // Register this socket for active-turn broadcasts, and immediately catch it
254
+ // up on which sessions are currently running (so the sidebar dots and a
255
+ // returning pane's Stop button reflect reality from the first paint).
256
+ clients.add(socket);
257
+ for (const info of hub.runningSessions())
258
+ send(activeFrame(info));
259
+ // Heartbeat: browsers auto-answer protocol ping frames with a pong, so a
260
+ // client whose TCP has silently died (idle drop, sleep) fails to pong and is
261
+ // terminated on the next tick — freeing server resources and letting the
262
+ // client's own reconnect take over. Cleared when the socket closes.
263
+ let isAlive = true;
264
+ socket.on("pong", () => {
265
+ isAlive = true;
266
+ });
267
+ const heartbeat = setInterval(() => {
268
+ if (!isAlive) {
269
+ socket.terminate();
270
+ return;
271
+ }
272
+ isAlive = false;
273
+ try {
274
+ socket.ping();
275
+ }
276
+ catch {
277
+ socket.terminate();
278
+ }
279
+ }, SERVER_PING_INTERVAL_MS);
280
+ socket.on("close", () => {
281
+ clearInterval(heartbeat);
282
+ // Drop this socket from every session fan-out set so the hub stops trying
283
+ // to write to it (a running turn keeps going for other attached sockets).
284
+ hub.unsubscribeSocket(socket);
285
+ clients.delete(socket);
286
+ });
287
+ socket.on("message", (raw) => {
288
+ let parsed;
289
+ try {
290
+ parsed = JSON.parse(raw.toString());
291
+ }
292
+ catch {
293
+ send({ type: "chat:error", payload: { projectSlug: "?", error: "Invalid JSON" } });
294
+ return;
295
+ }
296
+ if (!isClientMessage(parsed)) {
297
+ send({ type: "chat:error", payload: { projectSlug: "?", error: "Unknown message" } });
298
+ return;
299
+ }
300
+ if (parsed.type === "ping") {
301
+ send({ type: "pong" });
302
+ return;
303
+ }
304
+ if (parsed.type === "chat:cancel") {
305
+ void deps.herdctl.cancel(parsed.payload.jobId).catch(() => undefined);
306
+ return;
307
+ }
308
+ if (parsed.type === "chat:subscribe") {
309
+ onSubscribe(parsed);
310
+ return;
311
+ }
312
+ if (parsed.type === "chat:set_queue") {
313
+ void onSetQueue(parsed);
314
+ return;
315
+ }
316
+ if (parsed.type === "chat:command") {
317
+ void onChatCommand(parsed);
318
+ return;
319
+ }
320
+ if (parsed.type === "chat:continue") {
321
+ void onChatContinue(parsed);
322
+ return;
323
+ }
324
+ void onChatSend(parsed);
325
+ });
326
+ /**
327
+ * Manual keeper recovery (issue #301, Layer 2). Re-drive a hung keeper whose
328
+ * background task was killed at the turn boundary by injecting the recovery
329
+ * nudge into its still-alive session via {@link startAgentTurn} — the same
330
+ * workhorse the self-MCP `send_message` / schedule fires use, so the injected
331
+ * turn streams live, lists in the sidebar, and is attributable
332
+ * (`sender: recovery`). Server-authoritative gate: no-op unless the resolved
333
+ * `recovery.surfaceKilledTask` is on for this project, so a stale/rogue client
334
+ * can't re-drive when the operator disabled Layer 2.
335
+ */
336
+ const onChatContinue = async (msg) => {
337
+ // `""` addresses the ROOT workspace, so only a MISSING key is a no-op.
338
+ const slug = msg.payload.projectSlug;
339
+ if (slug === undefined || slug === null)
340
+ return;
341
+ const sessionId = msg.payload.sessionId;
342
+ if (!sessionId)
343
+ return;
344
+ let project;
345
+ try {
346
+ project = await deps.projects.get(slug);
347
+ }
348
+ catch {
349
+ return; // unknown project — nothing to recover
350
+ }
351
+ // Gate on the resolved Layer 2 flag (per-project override else instance).
352
+ const recovery = resolveRecoveryConfig(project.recovery, deps.cfg.recovery);
353
+ if (!recovery.surfaceKilledTask)
354
+ return;
355
+ // Re-drive via the shared recovery path (also used by Layer 3 auto-recovery).
356
+ await injectRecoveryNudge(project, sessionId).catch((err) => {
357
+ send({
358
+ type: "chat:error",
359
+ payload: { projectSlug: slug, error: `Recovery failed: ${String(err)}` },
360
+ });
361
+ });
362
+ };
363
+ const onSubscribe = (msg) => {
364
+ const { sessionId, wantReplay, lastSeq } = msg.payload;
365
+ const result = hub.attach(sessionId, socket, {
366
+ wantReplay: wantReplay === true,
367
+ afterSeq: typeof lastSeq === "number" ? lastSeq : -1,
368
+ });
369
+ if (result.status === "resync") {
370
+ send({
371
+ type: "chat:resync",
372
+ payload: { projectSlug: result.projectSlug, sessionId },
373
+ });
374
+ }
375
+ // Tell a (re)attaching pane whether its session has a live turn, so a chat
376
+ // the user navigated back to restores its Stop button + jobId and streaming
377
+ // indicator immediately — not only once the next frame happens to arrive
378
+ // (issues #52/#53).
379
+ const active = hub.activeInfo(sessionId);
380
+ if (active)
381
+ send(activeFrame(active));
382
+ };
383
+ // Server-authoritative queue drain (#245): auto-send a persisted queued
384
+ // message as the next turn, exactly once. Called (a) when a turn completes
385
+ // successfully and (b) when a queue is set while the session is idle — a queue
386
+ // that arrived (e.g. via the reconnect outbox) after the turn it was meant to
387
+ // follow already ended. `take` makes the read+clear atomic so the two callers
388
+ // can never both send it; the `lastFlushedTs` marker skips a message already
389
+ // drained (a stale client re-assert on reload) so it isn't sent twice.
390
+ const drainQueue = async (slug, sessionId) => {
391
+ if (!deps.queuedMessage)
392
+ return;
393
+ const agent = keeperAgentName(slug);
394
+ const queued = await deps.queuedMessage.take(agent, sessionId).catch(() => null);
395
+ if (!queued?.text)
396
+ return;
397
+ const markerKey = `${agent}${KEY_SEP}${sessionId}`;
398
+ const already = (lastFlushedTs.get(markerKey) ?? 0) >= queued.createdAtMs;
399
+ // Tell every attached client (origin + reconnected sockets) to clear its copy
400
+ // of this message. When we're really sending it, carry the text so the client
401
+ // renders the sent bubble in-transcript; on a stale re-assert we only clear.
402
+ hub.broadcast(sessionId, {
403
+ type: "chat:queued_flushed",
404
+ payload: {
405
+ projectSlug: slug,
406
+ sessionId,
407
+ ...(already ? {} : { text: queued.text }),
408
+ },
409
+ });
410
+ if (already)
411
+ return;
412
+ lastFlushedTs.set(markerKey, queued.createdAtMs);
413
+ // Broadcast the flush frame BEFORE kicking the turn so the user bubble renders
414
+ // above the reply. Run it detached, like a human send. A leading-slash queued
415
+ // message is a slash command (e.g. "/compact"): route it through the command
416
+ // path so the CLI dispatches it — matching how the composer sends one live.
417
+ if (queued.text.startsWith("/")) {
418
+ void onChatCommand({
419
+ type: "chat:command",
420
+ payload: { projectSlug: slug, command: queued.text, sessionId },
421
+ });
422
+ }
423
+ else {
424
+ void onChatSend({
425
+ type: "chat:send",
426
+ payload: { projectSlug: slug, sessionId, message: queued.text },
427
+ });
428
+ }
429
+ };
430
+ const onSetQueue = async (msg) => {
431
+ if (!deps.queuedMessage)
432
+ return; // feature disabled
433
+ // `""` addresses the ROOT workspace, so only a MISSING key is a no-op.
434
+ const slug = msg.payload.projectSlug;
435
+ if (slug === undefined || slug === null)
436
+ return;
437
+ const sessionId = msg.payload.sessionId ?? null;
438
+ const text = msg.payload.text ?? null;
439
+ // Every chat belongs to a workspace keeper.
440
+ const agent = keeperAgentName(slug);
441
+ if (!sessionId) {
442
+ // New chat: queue isn't stored until the session id exists. The client
443
+ // re-asserts it (with the same ts) once the id resolves, so it persists then.
444
+ return;
445
+ }
446
+ // Store or clear the queued message.
447
+ if (text && text.trim().length > 0) {
448
+ await deps.queuedMessage
449
+ .set(agent, sessionId, { text, createdAtMs: msg.payload.ts ?? Date.now() })
450
+ .catch(() => undefined);
451
+ // If no turn is running, this queue arrived after the turn it was meant to
452
+ // follow already ended — drain it now rather than wait for a completion
453
+ // that won't come (the reported stranding bug, #245).
454
+ if (!hub.isRunning(sessionId))
455
+ await drainQueue(slug, sessionId);
456
+ }
457
+ else {
458
+ await deps.queuedMessage.set(agent, sessionId, null).catch(() => undefined);
459
+ }
460
+ };
461
+ const onChatSend = async (msg) => {
462
+ const slug = msg.payload.projectSlug;
463
+ const { message, sessionId, preloadContext, attachments: sentAttachments } = msg.payload;
464
+ const isNewChat = sessionId === undefined || sessionId === null;
465
+ // A genuine human message resets this session's Layer 3 recovery guard (issue
466
+ // #301) and cancels any in-flight watch, so the retry cap counts auto re-drives
467
+ // BETWEEN human messages and a later real hang is recovered fresh.
468
+ if (sessionId)
469
+ recoveryEngine.onHumanMessage(sessionId);
470
+ let jobId = null;
471
+ let resolvedSession = sessionId ?? null;
472
+ // One-shot guard: a brand-new chat is attributed to its agent the instant
473
+ // its session id first streams back, so it lists in the sidebar mid-turn
474
+ // instead of only after the turn completes (issue #100). Resumed chats are
475
+ // already attributed, so this only runs for a new chat.
476
+ let attributed = false;
477
+ // Track this turn in the session hub so its frames fan out to whichever
478
+ // socket(s) are attached — not just this one — and a reconnecting client
479
+ // can re-attach + replay the missed gap (issue #54). A resumed chat's id is
480
+ // known now (re-attachable from frame 0); a new chat registers once the id
481
+ // arrives mid-stream (see turn.setSession below).
482
+ const turn = hub.startTurn(slug, socket, sessionId ?? null);
483
+ // Per-turn usage + model captured off the SDK stream (last non-null wins).
484
+ // Held on a mutable record (not bare `let`s) so the values assigned inside
485
+ // the streaming callback are visible to control-flow analysis afterwards.
486
+ const seen = initTurnUsage();
487
+ // The model the turn will run on; resolved below once we know the target.
488
+ let effectiveModel = DEFAULT_MODEL;
489
+ // How this turn is driven (Paddock#111): the global default unless the
490
+ // workspace overrides it (resolved in the workspace branch below).
491
+ let driveMode = deps.cfg.driveMode;
492
+ // The agent's working directory, so the send_file tool can resolve a real
493
+ // `file_path` (and sandbox it). Resolved alongside the agent below.
494
+ let sendFileWorkingDir;
495
+ const routing = () => ({
496
+ projectSlug: slug,
497
+ sessionId: resolvedSession,
498
+ jobId,
499
+ });
500
+ // #329: surface a turn dead-end (usage limit / max-turns / error) inline, at
501
+ // most once per turn — a synthetic session-limit reply repeats several times,
502
+ // and a failed turn can carry both an error `result` and a `success:false`
503
+ // completion. Emit the first, suppress the rest.
504
+ let noticeEmitted = false;
505
+ // #380: whether a COMPLETE assistant reply already streamed this turn. In
506
+ // session mode the SDK's terminal `result` (streamed live, never persisted)
507
+ // can carry an `error_*` subtype / `success:false` AFTER a good `end_turn`
508
+ // reply, painting a false "turn failed" banner beneath the answer. Once a
509
+ // reply is seen we suppress the error/max_turns dead-end — the same guard the
510
+ // history path (`scanTranscriptNotice`) already applies on reload. A
511
+ // `usage_limit` dead-end still surfaces (a session-limit stop is real).
512
+ let producedReply = false;
513
+ const emitNotice = (notice) => {
514
+ if (noticeEmitted)
515
+ return;
516
+ if (suppressNoticeAfterReply(notice, producedReply))
517
+ return;
518
+ noticeEmitted = true;
519
+ turn.emit({ type: "chat:notice", payload: { ...routing(), notice } });
520
+ };
521
+ // #429: sub-agent launches recovered live from the tool_use input, keyed by
522
+ // toolUseId, so a launching card shows the real type/title + is expandable into
523
+ // its (streaming) steps without a refresh. Populated in onMessage before each
524
+ // translate; read by the onToolStart/onToolCall closures via subagentLaunchFields.
525
+ const subagentLaunches = new Map();
526
+ // @herdctl/chat's shared translator turns the SDKMessage stream into the
527
+ // three UI events we forward over the socket. Created fresh per turn (it
528
+ // holds per-turn tool-pairing state).
529
+ const translate = createSDKMessageHandler({
530
+ onText: (chunk) => {
531
+ if (chunk)
532
+ turn.emit({ type: "chat:response", payload: { ...routing(), chunk } });
533
+ },
534
+ onBoundary: () => {
535
+ turn.emit({ type: "chat:message_boundary", payload: routing() });
536
+ },
537
+ onToolStart: (start) => {
538
+ turn.emit({
539
+ type: "chat:tool_start",
540
+ payload: {
541
+ ...routing(),
542
+ toolName: start.toolName,
543
+ inputSummary: start.inputSummary,
544
+ toolUseId: start.toolUseId,
545
+ parentToolUseId: start.parentToolUseId,
546
+ ...subagentLaunchFields(subagentLaunches, start.toolName, start.toolUseId),
547
+ },
548
+ });
549
+ },
550
+ onToolCall: (call) => {
551
+ turn.emit({
552
+ type: "chat:tool_call",
553
+ payload: {
554
+ ...routing(),
555
+ toolName: call.toolName,
556
+ inputSummary: call.inputSummary,
557
+ output: call.output,
558
+ isError: call.isError,
559
+ durationMs: call.durationMs,
560
+ toolUseId: call.toolUseId,
561
+ ...subagentLaunchFields(subagentLaunches, call.toolName, call.toolUseId),
562
+ },
563
+ });
564
+ },
565
+ });
566
+ try {
567
+ // Every workspace key resolves to a keeper (`keeper-<slug>`), including
568
+ // the root's `keeper-_root` (its key is `""`, encoded via `agentKeyFor`).
569
+ let agentName;
570
+ // Effective prompt — may be augmented with the project overview below.
571
+ let prompt = message;
572
+ // Whether this project agent gets the T3 unified trigger-management tools
573
+ // (resolved from the project's REUSED hooks-MCP `hooksMcpEnabled` override
574
+ // else the instance default).
575
+ let includeTriggers = false;
576
+ const requested = msg.payload.model;
577
+ {
578
+ // Verifies the project exists (throws if not); we keep the object so
579
+ // we can resolve its model + re-register the keeper for an override.
580
+ const project = await deps.projects.get(slug);
581
+ agentName = keeperAgentName(slug);
582
+ sendFileWorkingDir = project.dir;
583
+ // Project chat: a valid override wins, else the project's model. Then
584
+ // ensure the (shared) keeper is registered at that model before the
585
+ // trigger. NOTE single-user last-write-wins caveat (see herdctl.ts).
586
+ effectiveModel =
587
+ requested && isKnownModel(requested) ? requested : project.model;
588
+ await deps.herdctl.ensureAgentModel(project, effectiveModel);
589
+ // Per-project driveMode override wins over the global default
590
+ // (Paddock#111). An absent/invalid value inherits the global.
591
+ driveMode =
592
+ project.driveMode && isKnownDriveMode(project.driveMode)
593
+ ? project.driveMode
594
+ : deps.cfg.driveMode;
595
+ // T3 trigger-management gate (REUSES the hooks-MCP gate): the per-project
596
+ // override wins, else the instance default. Only takes effect when the
597
+ // write tools are also on (the trigger tools live in the write block).
598
+ includeTriggers =
599
+ deps.cfg.selfMcpWriteEnabled &&
600
+ resolveHooksMcpEnabled(project.hooksMcpEnabled, deps.cfg.hooksMcpEnabled);
601
+ // Composer attachments (issue #328): validate the uploaded files against
602
+ // this project's effective attachment config, then prepend a
603
+ // `<paddock-attachments>` hint block pointing the keeper's Read tool at
604
+ // the stored files' absolute paths. Wrapped BEFORE preload so the whole
605
+ // thing nests inside the preload block. Invalid/missing/over-count
606
+ // attachments are dropped (defensive — the endpoint already gated them).
607
+ if (sentAttachments && sentAttachments.length > 0) {
608
+ const acfg = resolveAttachmentsConfig(project.attachments, deps.cfg.attachments);
609
+ if (acfg.enabled) {
610
+ const promptAtts = [];
611
+ for (const a of sentAttachments.slice(0, acfg.maxFilesPerMessage)) {
612
+ const abs = deps.attachments.absolutePath(a.id);
613
+ if (!abs || !(await deps.attachments.exists(a.id)))
614
+ continue;
615
+ promptAtts.push({
616
+ id: a.id,
617
+ filename: a.filename,
618
+ kind: inferAttachmentKind(a.filename),
619
+ path: abs,
620
+ });
621
+ }
622
+ prompt = wrapAttachments(promptAtts, prompt);
623
+ }
624
+ }
625
+ // Context preload (issues #1/#188): only for a NEW chat, only when
626
+ // asked. Shared with the self-MCP create_chat path (C2 / #264):
627
+ // injects BOTH OVERVIEW.md and CHANGELOG.md when the project has
628
+ // curated state, else leaves the prompt untouched. Wraps the (possibly
629
+ // attachment-wrapped) `prompt`, not the bare `message`.
630
+ if (isNewChat && preloadContext) {
631
+ prompt = await composePreloadedPrompt(slug, prompt);
632
+ }
633
+ }
634
+ // Inject the Paddock send_file MCP tool for this turn. The tool returns a
635
+ // JSON envelope as its result `output`; the web renders it off the tool
636
+ // call itself (live + on reload), so no bespoke WS frame is needed. The
637
+ // working dir resolves a relative `file_path`; a real file's bytes are
638
+ // copied into the attachment store at send time (immutable snapshot).
639
+ const sendFile = sendFileServerDef({
640
+ workingDirectory: sendFileWorkingDir,
641
+ saveAttachment: (bytes, name) => deps.attachments.save(bytes, name),
642
+ });
643
+ const injectedMcpServers = {
644
+ [SEND_FILE_SERVER_KEY]: sendFile,
645
+ };
646
+ // Self-management MCP (issue #214): only on keeper turns, and only when
647
+ // the instance opts in via PADDOCK_SELF_MCP. A HUMAN turn is
648
+ // the ROOT of any spawn tree (origin human, depth 0), so its children are
649
+ // depth 1 — the same builder the spawned path uses, just seeded with
650
+ // HUMAN_ROOT. Write tools follow the instance write opt-in (B1 #262: the
651
+ // shared builder is extracted so both paths agree). Depth-0 human gating is
652
+ // unchanged from before B1 — the depth bound governs the spawned path only.
653
+ if (deps.cfg.selfMcpEnabled) {
654
+ injectedMcpServers[SELF_MCP_SERVER_KEY] = buildSelfMcpServerDef(selfMcpCtx, {
655
+ currentProjectSlug: slug,
656
+ currentSessionId: () => resolvedSession ?? sessionId ?? null,
657
+ parentProvenance: HUMAN_ROOT,
658
+ includeWrite: deps.cfg.selfMcpWriteEnabled,
659
+ includeTriggers,
660
+ // Project provisioning (#467) rides on the write block behind its own
661
+ // instance flag — no per-project override, so nothing to resolve here.
662
+ includeProjects: deps.cfg.selfMcpWriteEnabled && deps.cfg.selfMcpProjectsEnabled,
663
+ });
664
+ }
665
+ // Session mode drives a persistent, herdctl-managed openChatSession so
666
+ // cross-turn autonomy (ScheduleWakeup / `/loop`) survives the turn
667
+ // boundary; batch mode keeps the legacy one-shot trigger. Both stream
668
+ // through the identical onMessage/onJobCreated contract (Paddock#111).
669
+ const drive = driveMode === "session"
670
+ ? deps.herdctl.chatSession.bind(deps.herdctl)
671
+ : deps.herdctl.chat.bind(deps.herdctl);
672
+ // Gap B sink (session mode): one persistent sink groups every background
673
+ // re-invocation onto a single hub turn (skipping sidechain sub-agent
674
+ // steps). Built once so its turn/translator state spans the stream.
675
+ const bgSink = driveMode === "session" ? makeBackgroundTurnSink(slug) : null;
676
+ const result = await drive(agentName, {
677
+ prompt,
678
+ // omit -> agent-level fallback; explicit null -> new chat; id -> resume.
679
+ resume: sessionId ?? null,
680
+ triggerType: "web",
681
+ injectedMcpServers,
682
+ // Gap B: deliver autonomous background-completion turns live (session
683
+ // mode only; batch `chat` ignores this). The human turn holds the
684
+ // session open when it launches background work; the persistent sink
685
+ // renders each later re-invocation onto ONE hub turn (skipping sidechain
686
+ // sub-agent steps).
687
+ onBackgroundMessage: bgSink?.onMessage,
688
+ onBackgroundDone: bgSink?.onDone,
689
+ onJobCreated: (id) => {
690
+ jobId = id;
691
+ turn.setJobId(id);
692
+ },
693
+ onMessage: async (m) => {
694
+ // A FOREGROUND sub-agent streams its nested steps INLINE on this very
695
+ // stream, tagged `parent_tool_use_id`. They belong inside the sub-agent
696
+ // card (served by the subagents endpoint), never as top-level rows of
697
+ // the parent transcript — see {@link isSidechainMessage}. This is the
698
+ // human `chat:send` path, so it is where the duplication was actually
699
+ // seen. Skipping also keeps a sub-agent's context out of the parent's
700
+ // live meter (`foldTurnUsage` below would latch its max — the #398 shape).
701
+ if (isSidechainMessage(m))
702
+ return;
703
+ // Capture the session id as it arrives mid-stream (the translator
704
+ // only surfaces text/boundary/tool events, not routing metadata).
705
+ // Registering it with the hub makes the turn re-attachable by session.
706
+ if (m.session_id) {
707
+ resolvedSession = m.session_id;
708
+ // #390: remember this human turn's injected servers so a wake this
709
+ // chat self-schedules can replay them (the common flap scenario).
710
+ wakeInjection.remember(m.session_id, injectedMcpServers);
711
+ // For a NEW chat, attribute the session to its agent BEFORE the
712
+ // hub broadcasts `chat:active`, so any client refetching its chat
713
+ // list in response is guaranteed to see the now-listed chat — it
714
+ // no longer waits for the turn to complete (issue #100). Awaited
715
+ // once (a quick local job-record write); never fatal to the turn.
716
+ if (isNewChat && !attributed) {
717
+ attributed = true;
718
+ // Non-fatal: on failure the chat simply falls back to appearing
719
+ // once its turn completes (the prior behavior), never breaking
720
+ // the live stream.
721
+ await deps.herdctl
722
+ .attributeRunningSession(m.session_id, agentName)
723
+ .catch(() => undefined);
724
+ // A1 (#261): a human-started chat is the ROOT of any spawn tree —
725
+ // origin human, depth 0. Stamped once at creation; later turns on
726
+ // this chat never change its recorded provenance.
727
+ await deps.runProvenance?.stamp(m.session_id, HUMAN_ROOT).catch(() => undefined);
728
+ }
729
+ turn.setSession(m.session_id);
730
+ }
731
+ // Capture per-turn usage + model defensively. Assistant blocks feed
732
+ // the context snapshot (keeping the largest — issue #165); the terminal
733
+ // `result` block's CUMULATIVE usage is stashed separately so it can't
734
+ // inflate the live context meter (#398).
735
+ foldTurnUsage(seen, m);
736
+ // #329: surface a dead-end BEFORE translating — a synthetic usage-limit
737
+ // message or a terminal error/`error_max_turns` result. The translator
738
+ // silently drops every synthetic message (which is exactly why these
739
+ // turns look dead); `noticeFromMessage` returns null for ordinary output
740
+ // and for suppressed "No response requested." placeholders.
741
+ // #380: track a completed reply so a trailing error/max_turns result
742
+ // (which races in AFTER the reply in session mode) is suppressed below.
743
+ if (messageProducedReply(m))
744
+ producedReply = true;
745
+ const notice = noticeFromMessage(m);
746
+ if (notice)
747
+ emitNotice(notice);
748
+ // #429: recover any Task/Agent launch from this message's tool_use input
749
+ // so the enriched card renders live — before translate fires onToolStart.
750
+ for (const l of extractSubagentLaunches(m))
751
+ subagentLaunches.set(l.toolUseId, l);
752
+ // @herdctl/core's SDKMessage types `message` as `unknown` (wider);
753
+ // @herdctl/chat's translator declares a structurally narrower
754
+ // SDKMessage. Same runtime object — cast across the package boundary.
755
+ // (@herdctl/chat@0.4.1 pairs CLI tool results, so no shim needed.)
756
+ await translate(m);
757
+ },
758
+ });
759
+ // #404: session-mode turns that produced a real reply routinely end with a
760
+ // trailing `error_*` / `success:false` result frame (the #380/#394 banner
761
+ // asymmetry). Raw `result.success` is reply-unaware, so gating the post-turn
762
+ // side effects below on it silently strands a queued follow-up, skips the
763
+ // sweep, and drops the recovery arm. Trust the same `producedReply` signal
764
+ // the banner already does — a real reply supersedes a benign trailing failure.
765
+ const effectiveSuccess = turnEffectivelySucceeded(result.success, producedReply);
766
+ // Post-turn sweep (issues #2/#6): on a successful USER turn, enqueue a
767
+ // coalesced/debounced curation sweep. Out of band — never blocks or breaks
768
+ // chat, and can't recurse (the sweep uses a separate agent triggered off
769
+ // the user-chat path). Turn success is the ONLY gate: every workspace is
770
+ // swept, the root included (`sweeper-_root`).
771
+ // T5: routed through the `afterTurn` event so the folded-in `curate-overview`
772
+ // trigger is the single dispatch (no double-curation).
773
+ if (effectiveSuccess)
774
+ emitAfterTurn(slug, result.sessionId ?? resolvedSession ?? null);
775
+ // Force a session-list refresh so a brand-new chat surfaces immediately
776
+ // (rather than waiting out the discovery cache). Non-fatal.
777
+ try {
778
+ deps.herdctl.invalidateSessions(agentName);
779
+ }
780
+ catch {
781
+ /* non-fatal: stale-by-30s at worst */
782
+ }
783
+ // Surface the model + usage so the UI can render the context meter.
784
+ // Omit both if no usage was observed this turn (§7).
785
+ const completeModel = seen.model ?? effectiveModel;
786
+ const seenUsage = resolveTurnUsage(seen);
787
+ const completeUsage = seenUsage
788
+ ? {
789
+ inputTokens: seenUsage.inputTokens,
790
+ outputTokens: seenUsage.outputTokens,
791
+ cacheReadTokens: seenUsage.cacheReadTokens,
792
+ cacheCreationTokens: seenUsage.cacheCreationTokens,
793
+ contextTokens: seenUsage.inputTokens +
794
+ seenUsage.cacheReadTokens +
795
+ seenUsage.cacheCreationTokens,
796
+ contextLimit: getContextLimit(completeModel),
797
+ }
798
+ : undefined;
799
+ // Ensure the turn is keyed under its final session id before the terminal
800
+ // frame, so a client re-attaching right at the end gets the completion.
801
+ const finalSession = effectiveSuccess ? (result.sessionId ?? resolvedSession) : resolvedSession;
802
+ if (finalSession)
803
+ turn.setSession(finalSession);
804
+ // #329: a turn that failed WITHOUT a terminal error `result` reaching the
805
+ // stream (a thrown/rejected drive: network reset, process crash) never hit
806
+ // noticeFromMessage. Surface it inline so the failure is visible — before
807
+ // chat:complete flips streaming off (deduped against any notice already sent).
808
+ if (!result.success)
809
+ emitNotice(errorNotice(result.error?.message));
810
+ turn.emit({
811
+ type: "chat:complete",
812
+ payload: {
813
+ ...routing(),
814
+ sessionId: finalSession,
815
+ jobId: result.jobId ?? jobId,
816
+ success: result.success,
817
+ error: result.error?.message,
818
+ ...(completeUsage ? { model: completeModel, usage: completeUsage } : {}),
819
+ },
820
+ });
821
+ turn.end();
822
+ // After a SUCCESSFUL turn, auto-send any queued follow-up (#197/#245). A
823
+ // Stop/failed turn holds the queue for the user (no drain). drainQueue owns
824
+ // the take + client notify + next-turn kickoff, shared with the idle path.
825
+ if (effectiveSuccess && finalSession) {
826
+ await drainQueue(slug, finalSession);
827
+ }
828
+ // Layer 3 (issue #301): arm a post-turn recovery watch for a session-mode
829
+ // keeper turn. If this turn launched a background task that the runtime kills
830
+ // at the turn boundary (herdctl#374) and the keeper doesn't wake on its own,
831
+ // the engine auto-injects the recovery nudge — gated on the resolved
832
+ // `recovery.autoReDrive` (default OFF), a debounce window, and a retry cap.
833
+ if (effectiveSuccess && finalSession && driveMode === "session") {
834
+ recoveryEngine.armWatch({ slug, sessionId: finalSession });
835
+ }
836
+ }
837
+ catch (err) {
838
+ const error = err instanceof Error ? err.message : String(err);
839
+ // The origin socket always gets the plain chat:error (its shape predates
840
+ // the hub and existing clients/tests rely on it). If the turn had already
841
+ // resolved a session, ALSO emit a terminal chat:complete through the hub
842
+ // so a client that re-attached after a mid-turn socket drop stops showing
843
+ // "streaming" instead of hanging with no terminal frame.
844
+ send({ type: "chat:error", payload: { projectSlug: slug, error } });
845
+ if (resolvedSession) {
846
+ // #329: also surface the failure as an inline notice on the hub so a
847
+ // re-attached client renders WHY the turn died (not just the origin
848
+ // socket's chat:error toast).
849
+ emitNotice(errorNotice(error));
850
+ turn.emit({
851
+ type: "chat:complete",
852
+ payload: { ...routing(), success: false, error },
853
+ });
854
+ }
855
+ turn.end();
856
+ }
857
+ };
858
+ /**
859
+ * Handle a slash command (`chat:command`) by driving herdctl's streaming
860
+ * session. Output is streamed back over the same events as a normal turn;
861
+ * a `compact_boundary` is surfaced as a synthetic assistant note so the UI
862
+ * shows a visible confirmation, and post-command usage refreshes the meter.
863
+ */
864
+ const onChatCommand = async (msg) => {
865
+ const slug = msg.payload.projectSlug;
866
+ const { command, sessionId } = msg.payload;
867
+ let resolvedSession = sessionId ?? null;
868
+ const seen = initTurnUsage();
869
+ // Same hub-tracked turn as onChatSend so a slash-command turn also survives
870
+ // a mid-turn socket drop (issue #54). A command always targets an existing
871
+ // session, so it's re-attachable from its first frame.
872
+ const turn = hub.startTurn(slug, socket, sessionId ?? null);
873
+ const routing = () => ({
874
+ projectSlug: slug,
875
+ sessionId: resolvedSession,
876
+ jobId: null,
877
+ });
878
+ // #429: sub-agent launches recovered live from the tool_use input (a command
879
+ // like a custom slash-command can still spawn a Task/Agent), keyed by toolUseId.
880
+ const subagentLaunches = new Map();
881
+ const translate = createSDKMessageHandler({
882
+ onText: (chunk) => {
883
+ if (chunk)
884
+ turn.emit({ type: "chat:response", payload: { ...routing(), chunk } });
885
+ },
886
+ onBoundary: () => {
887
+ turn.emit({ type: "chat:message_boundary", payload: routing() });
888
+ },
889
+ onToolStart: (start) => {
890
+ turn.emit({
891
+ type: "chat:tool_start",
892
+ payload: {
893
+ ...routing(),
894
+ toolName: start.toolName,
895
+ inputSummary: start.inputSummary,
896
+ toolUseId: start.toolUseId,
897
+ parentToolUseId: start.parentToolUseId,
898
+ ...subagentLaunchFields(subagentLaunches, start.toolName, start.toolUseId),
899
+ },
900
+ });
901
+ },
902
+ onToolCall: (call) => {
903
+ turn.emit({
904
+ type: "chat:tool_call",
905
+ payload: {
906
+ ...routing(),
907
+ toolName: call.toolName,
908
+ inputSummary: call.inputSummary,
909
+ output: call.output,
910
+ isError: call.isError,
911
+ durationMs: call.durationMs,
912
+ toolUseId: call.toolUseId,
913
+ ...subagentLaunchFields(subagentLaunches, call.toolName, call.toolUseId),
914
+ },
915
+ });
916
+ },
917
+ });
918
+ try {
919
+ // Commands need an existing chat to act on; the slug resolves to its
920
+ // project's keeper (verifying the project exists).
921
+ await deps.projects.get(slug); // throws if the project is unknown
922
+ const agentName = keeperAgentName(slug);
923
+ const { sessionId: finalSession } = await deps.herdctl.runCommand(agentName, {
924
+ command,
925
+ resume: resolvedSession,
926
+ onMessage: async (m) => {
927
+ // A slash-command turn can spawn a Task too; its nested steps never
928
+ // render top-level ({@link isSidechainMessage}).
929
+ if (isSidechainMessage(m))
930
+ return;
931
+ if (m.session_id) {
932
+ resolvedSession = m.session_id;
933
+ turn.setSession(m.session_id);
934
+ }
935
+ // Keep the largest context snapshot from assistant blocks (issue #165);
936
+ // the terminal `result` block's CUMULATIVE usage is stashed separately
937
+ // so it can't inflate the live context meter (#398).
938
+ foldTurnUsage(seen, m);
939
+ // Surface a compaction as a visible assistant note (the SDK reports
940
+ // it as a system/compact_boundary, which the text translator skips).
941
+ if (m.type === "system" && m.subtype === "compact_boundary") {
942
+ const pre = m.compact_metadata?.pre_tokens;
943
+ const detail = typeof pre === "number" ? ` (was ${pre.toLocaleString()} tokens)` : "";
944
+ turn.emit({
945
+ type: "chat:response",
946
+ payload: { ...routing(), chunk: `🗜️ Context compacted${detail}.` },
947
+ });
948
+ turn.emit({ type: "chat:message_boundary", payload: routing() });
949
+ return;
950
+ }
951
+ // Surface a client-local command's output (`/context`, `/usage`, …). CC
952
+ // returns it as a `model:"<synthetic>"` assistant message the translator
953
+ // drops as a placeholder (and, on disk, a `system`/`local_command` entry
954
+ // the history parser drops), so re-emit its text as an assistant note
955
+ // (issue #158) instead of the turn reading as a silent no-op. Trivial
956
+ // placeholders (e.g. `/compact`'s "No response requested.") are filtered out.
957
+ const localOut = extractLocalCommandOutput(m);
958
+ if (localOut) {
959
+ turn.emit({ type: "chat:response", payload: { ...routing(), chunk: localOut } });
960
+ turn.emit({ type: "chat:message_boundary", payload: routing() });
961
+ return;
962
+ }
963
+ // #429: recover any Task/Agent launch before translate fires onToolStart.
964
+ for (const l of extractSubagentLaunches(m))
965
+ subagentLaunches.set(l.toolUseId, l);
966
+ await translate(m);
967
+ },
968
+ });
969
+ if (finalSession)
970
+ resolvedSession = finalSession;
971
+ // Refresh the session list (a command can change history) — non-fatal.
972
+ try {
973
+ deps.herdctl.invalidateSessions(agentName);
974
+ }
975
+ catch {
976
+ /* non-fatal */
977
+ }
978
+ const completeModel = seen.model ?? DEFAULT_MODEL;
979
+ const seenUsage = resolveTurnUsage(seen);
980
+ const completeUsage = seenUsage
981
+ ? {
982
+ inputTokens: seenUsage.inputTokens,
983
+ outputTokens: seenUsage.outputTokens,
984
+ cacheReadTokens: seenUsage.cacheReadTokens,
985
+ cacheCreationTokens: seenUsage.cacheCreationTokens,
986
+ contextTokens: seenUsage.inputTokens + seenUsage.cacheReadTokens + seenUsage.cacheCreationTokens,
987
+ contextLimit: getContextLimit(completeModel),
988
+ }
989
+ : undefined;
990
+ if (resolvedSession)
991
+ turn.setSession(resolvedSession);
992
+ turn.emit({
993
+ type: "chat:complete",
994
+ payload: {
995
+ ...routing(),
996
+ success: true,
997
+ ...(completeUsage ? { model: completeModel, usage: completeUsage } : {}),
998
+ },
999
+ });
1000
+ turn.end();
1001
+ }
1002
+ catch (err) {
1003
+ const error = err instanceof Error ? err.message : String(err);
1004
+ send({ type: "chat:error", payload: { projectSlug: slug, error } });
1005
+ if (resolvedSession) {
1006
+ turn.emit({ type: "chat:complete", payload: { ...routing(), success: false, error } });
1007
+ }
1008
+ turn.end();
1009
+ }
1010
+ };
1011
+ };
1012
+ // The socket handler PLUS the manual trigger-fire entrypoint: a "trigger now"
1013
+ // action fires a schedule-type trigger on demand through the exact same hub path a
1014
+ // cron fire uses, so the resulting chat is indistinguishable from a scheduled one.
1015
+ //
1016
+ // `managementOpsContext` (#312 M1) completes the hoist: it is the SAME shared
1017
+ // context the in-process self-MCP is built from (deps + hub + startAgentTurn +
1018
+ // composePreloadedPrompt + fireTrigger), handed out so a NON-socket caller can
1019
+ // build the management ops for itself — the `/mcp` transport in M2, and the
1020
+ // turn-spawning REST endpoints in #465. Exposing the CONTEXT rather than a
1021
+ // pre-built ops bag is deliberate: ops are built PER CALLER, because they close
1022
+ // over that caller's project/session/provenance and its policy principal.
1023
+ return { handle, fireTrigger, managementOpsContext: selfMcpCtx };
1024
+ }