@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,751 @@
1
+ import { createSDKMessageHandler } from "@herdctl/chat";
2
+ import { keeperAgentName, keeperSlugFromAgent } from "./herdctl.js";
3
+ import { isKnownDriveMode, getContextLimit } from "./models.js";
4
+ import { buildInjectedMcpServers, createWakeInjectionCache, } from "./wake-injection.js";
5
+ import { resolveMaxSpawnDepth } from "./spawn-capability.js";
6
+ import { RecoveryEngine } from "./recovery.js";
7
+ import { extractSubagentLaunches, subagentLaunchFields } from "./subagents.js";
8
+ import { noticeFromMessage, errorNotice, messageProducedReply, suppressNoticeAfterReply, turnEffectivelySucceeded, } from "./turn-notice.js";
9
+ import { buildSelfMcpServerDef } from "./ws-self-mcp.js";
10
+ import { makeTriggerCluster } from "./ws-triggers.js";
11
+ import { initTurnUsage, foldTurnUsage, resolveTurnUsage, } from "./ws-protocol.js";
12
+ /**
13
+ * The recovery nudge injected by a manual Continue (issue #301, Layer 2) — and,
14
+ * later, by Layer 3 auto re-drive. It tells the keeper the truth (its background
15
+ * task was KILLED AT THE TURN BOUNDARY, not "stopped by the user" — cf #216) so it
16
+ * reacts sensibly: re-run the work in the FOREGROUND this turn, or report what
17
+ * happened. Kept terse; the killed `<task-notification>` is already in its context.
18
+ */
19
+ export const RECOVERY_NUDGE = "[Paddock recovery] Your previous turn ended while a background task was still " +
20
+ "running, and that task was then KILLED at the turn boundary by the runtime — " +
21
+ "this is a known limitation (see herdctl#374), NOT a user cancellation. Nothing " +
22
+ "is running now. Please pick up where you left off: if you still need that work, " +
23
+ "re-run it in the FOREGROUND this turn (do not background it), otherwise summarise " +
24
+ "what happened and continue.";
25
+ /**
26
+ * Whether an SDK message is a sub-agent (sidechain) step — `parent_tool_use_id`
27
+ * points at the spawning `Task` tool_use. Present on the top-level SDK message
28
+ * and/or its nested `message` (assistant/user shapes differ). Sidechain steps are
29
+ * the sub-agent's own nested work, surfaced only via the subagents endpoint on
30
+ * card-expand — so no live stream renders them top-level, or the sub-agent's work
31
+ * scatters into phantom rows of the parent transcript.
32
+ *
33
+ * EVERY live turn path must call this. There are FIVE, each with its own
34
+ * `createSDKMessageHandler` translator, and for a long time only the first
35
+ * filtered:
36
+ * 1. {@link makeBackgroundTurnSink} — a backgrounded `Task`
37
+ * 2. this module's `startAgentTurn` onMessage — triggers / spawns / recovery
38
+ * 3. `ws.ts` onWakeMessage — a scheduled wake turn
39
+ * 4. `ws.ts` `chat:send` onMessage — a human turn (where it was seen)
40
+ * 5. `ws.ts` slash-command onMessage — `/foo` turns
41
+ *
42
+ * The gap survived because of a false premise recorded on {@link
43
+ * makeBackgroundTurnSink}: that only a BACKGROUND Task streams sidechain steps
44
+ * inline, a foreground one being routed to a separate session. It is not — under
45
+ * SDK streaming mode a foreground `Task` streams its steps inline on whichever
46
+ * turn stream launched it, so any unfiltered path duplicates them.
47
+ *
48
+ * Skipping is safe for session bookkeeping: a sidechain message carries the SAME
49
+ * `session_id` as the main chat (verified against on-disk transcripts), and the
50
+ * launching `tool_use` is itself main-lane, so the id always resolves first.
51
+ */
52
+ export function isSidechainMessage(m) {
53
+ const anym = m;
54
+ return Boolean(anym.parent_tool_use_id ?? anym.message?.parent_tool_use_id);
55
+ }
56
+ /**
57
+ * Build the turn-execution engine bound to the handler's deps + shared hub, wiring
58
+ * the herdctl schedule/wake resolvers and the trigger event listeners.
59
+ */
60
+ export function makeTurnEngine(engine) {
61
+ const { deps, hub } = engine;
62
+ // Trigger / schedule / event firing (ws-triggers.ts, #403). Built here so it can
63
+ // close over the shared startAgentTurn engine (a hoisted declaration below) and
64
+ // wire its herdctl schedule handler + onArchive/afterTurn listeners. ws.ts consumes
65
+ // emitAfterTurn / composePreloadedPrompt / fireTrigger from it.
66
+ const triggers = makeTriggerCluster(deps, startAgentTurn);
67
+ // The self-management MCP context (deps + shared closures), handed to the
68
+ // extracted buildSelfMcpServerDef (ws-self-mcp.ts, #403) for both the live
69
+ // injection path and the onChatCommand path. startAgentTurn / composePreloadedPrompt
70
+ // / fireTrigger are hoisted function declarations, so this can be assembled here.
71
+ const selfMcpCtx = {
72
+ deps,
73
+ hub,
74
+ startAgentTurn,
75
+ composePreloadedPrompt: triggers.composePreloadedPrompt,
76
+ fireTrigger: triggers.fireTrigger,
77
+ };
78
+ // ── Wake-time injected-MCP re-establishment (edspencer/herdctl#390) ──────────
79
+ // The single injection-policy context, shared by the live `startAgentTurn` path
80
+ // (below) AND the wake rebuild, so the two can never drift. See wake-injection.ts.
81
+ const injectionBuildCtx = {
82
+ cfg: deps.cfg,
83
+ saveAttachment: (bytes, name) => deps.attachments.save(bytes, name),
84
+ // Optional, mirroring the optional `runProvenance` dep: absent ⇒ the caller's
85
+ // `depth` is used unchanged (identical to the pre-extraction inline behaviour).
86
+ getProvenance: deps.runProvenance ? (id) => deps.runProvenance.get(id) : undefined,
87
+ getProjectHooksMcp: async (slug) => {
88
+ const tp = await deps.projects.get(slug).catch(() => null);
89
+ return tp?.hooksMcpEnabled;
90
+ },
91
+ buildSelfMcp: (p) => buildSelfMcpServerDef(selfMcpCtx, p),
92
+ };
93
+ /** Build one turn's injected servers via the shared policy (see wake-injection.ts). */
94
+ function buildInjection(args) {
95
+ return buildInjectedMcpServers(args, injectionBuildCtx);
96
+ }
97
+ // Rebuild a woken session's injection from scratch (cold-cache warm after a server
98
+ // restart, when the live-turn cache is empty). Resolves the project (a non-keeper
99
+ // agent or an unknown project ⇒ no injection) then delegates to the shared builder;
100
+ // the resume gates self-MCP on the chat's OWN recorded depth. Never throws (the
101
+ // cache also catches defensively).
102
+ const rebuildWakeInjection = async (entry) => {
103
+ const slug = keeperSlugFromAgent(entry.agent);
104
+ // `null` = not a keeper agent. `""` = the ROOT workspace, which is a perfectly
105
+ // good key — a falsy check here would drop every root chat.
106
+ if (slug === null)
107
+ return undefined;
108
+ let project;
109
+ try {
110
+ project = await deps.projects.get(slug);
111
+ }
112
+ catch {
113
+ return undefined; // unknown/deleted project — nothing to inject
114
+ }
115
+ return buildInjection({
116
+ projectSlug: slug,
117
+ workingDir: project.workingDir,
118
+ resume: entry.sessionId,
119
+ // `origin` only feeds child provenance via `childOf` (which forces "spawned"),
120
+ // so its value is behaviourally irrelevant here; "scheduled" matches a wake root.
121
+ origin: "scheduled",
122
+ depth: 0,
123
+ maxSpawnDepth: resolveMaxSpawnDepth(project.maxSpawnDepth, deps.cfg.maxSpawnDepth),
124
+ currentSessionId: () => entry.sessionId,
125
+ });
126
+ };
127
+ // The cache/resolver. `wakeInjection.remember` is called on every live turn (the
128
+ // human socket path and `startAgentTurn`), so a session that self-schedules a wake
129
+ // is warm when it fires. herdctl calls `wakeInjection.resolve` synchronously on each
130
+ // wake fire — paired with `onSessionWake` above (which streams the woken turn), this
131
+ // re-establishes the injected MCP servers the woken subprocess would otherwise spawn
132
+ // WITHOUT, closing the "MCP flap". Registered here (rather than at the onSessionWake
133
+ // call site) so it sits beside the builder + cache it depends on.
134
+ const wakeInjection = createWakeInjectionCache({ rebuild: rebuildWakeInjection });
135
+ deps.herdctl.setResolveInjectedMcpServers((entry) => wakeInjection.resolve(entry));
136
+ // ── Gap B: live delivery of autonomous background-completion turns ───────────
137
+ // A session-mode turn that launches continuous background work is held open by
138
+ // the reaper (decideReap keepAlives while backgroundTasks > 0). When a task
139
+ // completes, the SDK hands the parent the result as a fresh autonomous
140
+ // re-invocation turn on the SAME session stream — AFTER the primary turn. herdctl
141
+ // `chatSession` keeps consuming that stream (consumeBackgroundTurns) and forwards
142
+ // each subsequent message to this sink, which renders it onto the live hub — the
143
+ // same delivery the scheduler-wake path uses (ws.ts onSessionWake) — so it appears
144
+ // without a refresh.
145
+ //
146
+ // Grouping (the sub-agent fix): a background *sub-agent* (`Task` run_in_background)
147
+ // streams for minutes and its nested `isSidechain` steps arrive INLINE on this
148
+ // re-invocation stream. So we:
149
+ //
150
+ // CORRECTION: this comment used to claim a foreground synchronous Task was
151
+ // different — that herdctl routed its nested steps to "a SEPARATE sidechain
152
+ // session, never the main turn stream". That is FALSE under SDK streaming mode,
153
+ // and the belief is exactly why only this sink filtered sidechain steps: a
154
+ // foreground Task duplicated all of its steps into the parent transcript live.
155
+ // The primary handler now filters too (see its `isSidechainMessage` guard).
156
+ // 1. SKIP sidechain messages (`parent_tool_use_id` set) from RENDERING — matching
157
+ // the foreground/history path, which never draws them top-level (they surface
158
+ // only via the subagents endpoint on card-expand). We still CONSUME them
159
+ // (consumeBackgroundTurns forwards everything to keep the stream + reaper
160
+ // lifecycle signals advancing) — we just don't render them.
161
+ // 2. Use ONE persistent TurnHandle + ONE translator for the WHOLE background
162
+ // stream (not a fresh one per `result`), so a Task `tool_use`↔`tool_result`
163
+ // pair through the translator's persistent `pendingToolUses` into ONE
164
+ // reconciled card, and successive re-invocations render as boundary-separated
165
+ // bubbles under one turn group — instead of scattering into phantom untitled
166
+ // "Agent" cards. The turn is finalized once, when the stream ends ({@link
167
+ // onDone}), so the streaming indicator clears.
168
+ const makeBackgroundTurnSink = (projectSlug) => {
169
+ let turn = null;
170
+ let translate = null;
171
+ let resolvedSession = null;
172
+ let producedReply = false;
173
+ let noticeEmitted = false;
174
+ let sawError = false;
175
+ // #429: sub-agent launches recovered live from the tool_use input, keyed by
176
+ // toolUseId, so the enriched card renders without a refresh (see subagentLaunchFields).
177
+ const launches = new Map();
178
+ const routing = () => ({
179
+ projectSlug,
180
+ sessionId: resolvedSession,
181
+ jobId: turn?.jobId ?? null,
182
+ });
183
+ const emitNotice = (notice) => {
184
+ if (!turn || noticeEmitted)
185
+ return;
186
+ if (suppressNoticeAfterReply(notice, producedReply))
187
+ return;
188
+ noticeEmitted = true;
189
+ turn.emit({ type: "chat:notice", payload: { ...routing(), notice } });
190
+ };
191
+ // Lazily open the single hub turn on the first RENDERED (main-agent) message, so
192
+ // a background stretch that is nothing but sub-agent sidechain steps opens no
193
+ // empty turn.
194
+ const ensureTurn = (sid) => {
195
+ if (turn)
196
+ return;
197
+ resolvedSession = sid;
198
+ turn = hub.startTurn(projectSlug, null, sid);
199
+ const t = turn;
200
+ translate = createSDKMessageHandler({
201
+ onText: (chunk) => {
202
+ if (chunk)
203
+ t.emit({ type: "chat:response", payload: { ...routing(), chunk } });
204
+ },
205
+ onBoundary: () => {
206
+ t.emit({ type: "chat:message_boundary", payload: routing() });
207
+ },
208
+ onToolStart: (start) => {
209
+ t.emit({
210
+ type: "chat:tool_start",
211
+ payload: {
212
+ ...routing(),
213
+ toolName: start.toolName,
214
+ inputSummary: start.inputSummary,
215
+ toolUseId: start.toolUseId,
216
+ parentToolUseId: start.parentToolUseId,
217
+ ...subagentLaunchFields(launches, start.toolName, start.toolUseId),
218
+ },
219
+ });
220
+ },
221
+ onToolCall: (call) => {
222
+ t.emit({
223
+ type: "chat:tool_call",
224
+ payload: {
225
+ ...routing(),
226
+ toolName: call.toolName,
227
+ inputSummary: call.inputSummary,
228
+ output: call.output,
229
+ isError: call.isError,
230
+ durationMs: call.durationMs,
231
+ toolUseId: call.toolUseId,
232
+ ...subagentLaunchFields(launches, call.toolName, call.toolUseId),
233
+ },
234
+ });
235
+ },
236
+ });
237
+ };
238
+ const onMessage = async (m) => {
239
+ // (1) Skip sidechain sub-agent nested steps from rendering (see header). The
240
+ // attribution lives on the top-level SDK message OR its nested `message`.
241
+ if (isSidechainMessage(m))
242
+ return;
243
+ if (m.session_id)
244
+ resolvedSession = m.session_id;
245
+ // #429: recover any Task/Agent launch from this (main-agent) message's tool_use
246
+ // input so the enriched, expandable card renders live (before translate fires).
247
+ for (const l of extractSubagentLaunches(m))
248
+ launches.set(l.toolUseId, l);
249
+ // (2) One persistent turn+translator for the whole stream — never reset per
250
+ // `result`, so tool_use↔tool_result pairing and card reconciliation survive
251
+ // across re-invocation boundaries.
252
+ ensureTurn(m.session_id ?? resolvedSession);
253
+ if (m.session_id)
254
+ turn.setSession(m.session_id);
255
+ if (messageProducedReply(m))
256
+ producedReply = true;
257
+ const notice = noticeFromMessage(m);
258
+ if (notice)
259
+ emitNotice(notice);
260
+ if (m.type === "result" &&
261
+ ((typeof m.subtype === "string" && m.subtype.startsWith("error")) || m.success === false)) {
262
+ sawError = true;
263
+ }
264
+ await translate(m);
265
+ };
266
+ // Finalize the single turn once the background stream ends (reaper reap). No-op
267
+ // if nothing was ever rendered (a sidechain-only stretch opened no turn).
268
+ const onDone = () => {
269
+ if (!turn)
270
+ return;
271
+ turn.emit({
272
+ type: "chat:complete",
273
+ payload: { ...routing(), sessionId: resolvedSession, success: !sawError },
274
+ });
275
+ turn.end();
276
+ turn = null;
277
+ translate = null;
278
+ };
279
+ return { onMessage, onDone };
280
+ };
281
+ /**
282
+ * Kick off a keeper turn that is NOT driven by a socket — used by the
283
+ * self-management MCP write tools (issue #214 Phase 2: create_chat / fork_chat /
284
+ * send_message / fork_chat_batch fan-out). Routes the turn through the SAME
285
+ * shared {@link hub} as socket-driven turns, so a spawned chat streams live to
286
+ * any client viewing it, flips the sidebar running indicator (hub.onActive
287
+ * broadcast), and is re-attachable — full parity with a human-started turn,
288
+ * just with no originating socket (origin `null`).
289
+ *
290
+ * Returns a promise that resolves with the chat's sessionId AS SOON AS it is
291
+ * known (immediately for a resumed/forked id; on the first streamed session_id
292
+ * for a brand-new chat), while the turn itself runs to completion in the
293
+ * BACKGROUND (detached). So a fan-out can fire N of these and collect N ids
294
+ * without waiting for any child turn to finish; herdctl's own max-concurrency
295
+ * throttles how many actually run at once. Rejects if the turn errors (or a
296
+ * timeout elapses) before an id is known.
297
+ *
298
+ * The spawned turn always gets `send_file`, and — NEW in B1 (#262) — it ALSO
299
+ * gets the self-management MCP (including the WRITE tools, so `send_message`
300
+ * exists and a child can finally report back to its parent) when its depth is
301
+ * within `maxSpawnDepth`. The fork-bomb bound is now EXPLICIT: a turn running in
302
+ * a chat at depth `d` gets the tools iff `d <= maxSpawnDepth` (see
303
+ * spawn-capability.ts). `maxSpawnDepth = 0` reproduces the old behaviour exactly
304
+ * (send_file only). Every child spawned by a tool-equipped turn is stamped one
305
+ * hop deeper, so the bound descends and the tree can't run away. A resume runs
306
+ * in an EXISTING chat, so its capability is gated on THAT chat's own recorded
307
+ * depth (from {@link RunProvenanceStore}), not on the caller's describe-the-run
308
+ * `depth`. A human who later opens a spawned chat still gets the full tools via
309
+ * the regular socket path (any keeper chat may use them) — unchanged.
310
+ */
311
+ async function startAgentTurn(opts) {
312
+ const { projectSlug, agentName, workingDir, resume, prompt, driveMode, fallbackModel, origin, depth, parent, maxSpawnDepth, sender } = opts;
313
+ let resolvedSession = resume ?? null;
314
+ let jobId = null;
315
+ let attributed = false;
316
+ const isNewChat = resume === null;
317
+ const turn = hub.startTurn(projectSlug, null, resume ?? null);
318
+ const seen = initTurnUsage();
319
+ // #429: sub-agent launches recovered live from the tool_use input (keyed by
320
+ // toolUseId), so the launching card shows the real type/title + is expandable
321
+ // without a refresh. Populated in onMessage before each translate; read by the
322
+ // onToolStart/onToolCall closures below via subagentLaunchFields.
323
+ const subagentLaunches = new Map();
324
+ // #329: whether this turn already surfaced a dead-end notice (usage limit /
325
+ // max-turns / error). At most one per turn — a synthetic session-limit turn
326
+ // repeats the message several times, and a failed turn can carry both an error
327
+ // `result` and a `success:false` completion. Emit the first, suppress the rest.
328
+ let noticeEmitted = false;
329
+ // #380: whether a COMPLETE assistant reply already streamed this turn. In
330
+ // session mode the SDK's terminal `result` (streamed live, never persisted)
331
+ // can carry an `error_*` subtype / `success:false` AFTER a good `end_turn`
332
+ // reply, painting a false "turn failed" banner beneath the answer. Once a
333
+ // reply is seen we suppress the error/max_turns dead-end — the same guard the
334
+ // history path (`scanTranscriptNotice`) already applies on reload. A
335
+ // `usage_limit` dead-end still surfaces (a session-limit stop is real).
336
+ let producedReply = false;
337
+ const emitNotice = (notice) => {
338
+ if (noticeEmitted)
339
+ return;
340
+ if (suppressNoticeAfterReply(notice, producedReply))
341
+ return;
342
+ noticeEmitted = true;
343
+ turn.emit({ type: "chat:notice", payload: { ...routing(), notice } });
344
+ };
345
+ const routing = () => ({
346
+ projectSlug,
347
+ sessionId: resolvedSession,
348
+ jobId,
349
+ });
350
+ // Per-message provenance (issue #290). Once the TARGET session id is known,
351
+ // record the injected prompt + its sender so a later transcript load can
352
+ // attribute the machine-added user turn (see message-provenance.ts). For an
353
+ // injection into an EXISTING chat we ALSO emit a `chat:injected` frame so a
354
+ // client currently viewing the recipient renders the injected user bubble live
355
+ // (Part 2) — a fresh/new chat has no established viewer, and opening it later
356
+ // hydrates the labelled turn from history, so we skip the live emit there to
357
+ // avoid a replay double-add. Fires at most once per turn.
358
+ let injectionHandled = false;
359
+ const handleInjection = (id) => {
360
+ if (injectionHandled || !sender)
361
+ return;
362
+ injectionHandled = true;
363
+ void deps.messageProvenance?.record(id, sender, prompt).catch(() => undefined);
364
+ if (resume !== null) {
365
+ turn.emit({
366
+ type: "chat:injected",
367
+ payload: {
368
+ projectSlug,
369
+ sessionId: id,
370
+ jobId,
371
+ sender,
372
+ content: prompt,
373
+ timestamp: new Date().toISOString(),
374
+ },
375
+ });
376
+ }
377
+ };
378
+ // A resume already knows its target session id — stamp/emit up front so the
379
+ // injected bubble precedes the assistant's streamed reply.
380
+ if (resume !== null)
381
+ handleInjection(resume);
382
+ const translate = createSDKMessageHandler({
383
+ onText: (chunk) => {
384
+ if (chunk)
385
+ turn.emit({ type: "chat:response", payload: { ...routing(), chunk } });
386
+ },
387
+ onBoundary: () => {
388
+ turn.emit({ type: "chat:message_boundary", payload: routing() });
389
+ },
390
+ onToolStart: (start) => {
391
+ turn.emit({
392
+ type: "chat:tool_start",
393
+ payload: {
394
+ ...routing(),
395
+ toolName: start.toolName,
396
+ inputSummary: start.inputSummary,
397
+ toolUseId: start.toolUseId,
398
+ parentToolUseId: start.parentToolUseId,
399
+ ...subagentLaunchFields(subagentLaunches, start.toolName, start.toolUseId),
400
+ },
401
+ });
402
+ },
403
+ onToolCall: (call) => {
404
+ turn.emit({
405
+ type: "chat:tool_call",
406
+ payload: {
407
+ ...routing(),
408
+ toolName: call.toolName,
409
+ inputSummary: call.inputSummary,
410
+ output: call.output,
411
+ isError: call.isError,
412
+ durationMs: call.durationMs,
413
+ toolUseId: call.toolUseId,
414
+ ...subagentLaunchFields(subagentLaunches, call.toolName, call.toolUseId),
415
+ },
416
+ });
417
+ },
418
+ });
419
+ // Build this turn's injected MCP servers via the shared policy (send_file always;
420
+ // depth-gated self-MCP, #262/DD-3). Extracted so the wake resolver (#390) rebuilds
421
+ // the IDENTICAL set — see wake-injection.ts. A RESUME gates self-MCP on the chat's
422
+ // OWN recorded depth (resolved inside the builder); `currentSessionId` is late-bound
423
+ // to `resolvedSession` so the self-MCP write tools attribute against the live id.
424
+ const injectedMcpServers = await buildInjection({
425
+ projectSlug,
426
+ workingDir,
427
+ resume,
428
+ origin,
429
+ depth,
430
+ maxSpawnDepth,
431
+ currentSessionId: () => resolvedSession,
432
+ });
433
+ const drive = driveMode === "session"
434
+ ? deps.herdctl.chatSession.bind(deps.herdctl)
435
+ : deps.herdctl.chat.bind(deps.herdctl);
436
+ // Gap B sink (session mode): one persistent sink renders every
437
+ // background-completion re-invocation onto a single hub turn (skipping sidechain
438
+ // sub-agent steps). Built once so its state (turn/translator) spans the stream.
439
+ const bgSink = driveMode === "session" ? makeBackgroundTurnSink(projectSlug) : null;
440
+ // Resolve the sessionId early; the caller returns it while the turn continues.
441
+ let resolveId;
442
+ let rejectId;
443
+ const idKnown = new Promise((res, rej) => {
444
+ resolveId = res;
445
+ rejectId = rej;
446
+ });
447
+ if (resume)
448
+ resolveId(resume);
449
+ const drivePromise = drive(agentName, {
450
+ prompt,
451
+ // herdctl's TriggerTypeSchema is a strict enum (manual|schedule|webhook|
452
+ // chat|discord|slack|web|fork); "agent" is NOT a member, so it fails job
453
+ // validation and the whole spawn errors out — which is why a spawned child
454
+ // could never be created against this core version. Use "manual" (the
455
+ // documented API/CLI-initiated value) until herdctl adds a first-class
456
+ // `spawned` trigger type (DD-6 / herdctl#377); provenance is carried by
457
+ // RunProvenanceStore (origin/depth), not by this field.
458
+ triggerType: "manual",
459
+ resume,
460
+ injectedMcpServers,
461
+ // Gap B: deliver autonomous background-completion turns live (session mode
462
+ // only; batch `chat` ignores this). See makeBackgroundTurnSink above.
463
+ onBackgroundMessage: bgSink?.onMessage,
464
+ onBackgroundDone: bgSink?.onDone,
465
+ onJobCreated: (id) => {
466
+ jobId = id;
467
+ turn.setJobId(id);
468
+ },
469
+ onMessage: async (m) => {
470
+ // A FOREGROUND sub-agent's nested steps arrive INLINE on this primary turn
471
+ // stream (SDK streaming mode), tagged with `parent_tool_use_id`. Rendering
472
+ // them here scattered every step of a `Task`/`Agent` run into top-level rows
473
+ // of the PARENT transcript — duplicating the very steps the sub-agent card
474
+ // already shows on expand, and polluting the live context meter (a sidechain
475
+ // assistant block's `contextTokens` is the SUB-AGENT's window, which
476
+ // `foldTurnUsage`'s max-snapshot would latch onto — the #398 failure mode).
477
+ //
478
+ // Both were live-only: a reload re-derives from history, which has always
479
+ // filtered sidechain steps, so the transcript "healed" on refresh and the
480
+ // duplication read as a streaming glitch.
481
+ //
482
+ // Skipping is safe for session bookkeeping: a sidechain message carries the
483
+ // SAME `session_id` as the main chat (verified against on-disk transcripts),
484
+ // and the launching `tool_use` message is itself main-stream, so it always
485
+ // resolves the id before any sidechain step arrives.
486
+ if (isSidechainMessage(m))
487
+ return;
488
+ if (m.session_id) {
489
+ resolvedSession = m.session_id;
490
+ // #390: remember this turn's injected servers so a later wake of this
491
+ // session replays them (closes the flap on the common self-schedule case).
492
+ wakeInjection.remember(m.session_id, injectedMcpServers);
493
+ if (isNewChat && !attributed) {
494
+ attributed = true;
495
+ await deps.herdctl.attributeRunningSession(m.session_id, agentName).catch(() => undefined);
496
+ // A1 (#261): stamp the NEW chat's provenance (e.g. create_chat →
497
+ // spawned, depth = parent+1) so #262 can depth-gate and #267 can
498
+ // badge it. Only a new chat is stamped here; a resume/message target
499
+ // (fork kickoff, send_message) keeps its own creation provenance.
500
+ //
501
+ // #509: carry the creating chat through too. This used to rebuild the
502
+ // marker from `origin`/`depth` alone, silently dropping the parent on
503
+ // the create_chat path — the dominant way children are made — so the
504
+ // chat tree had to infer every edge from the kickoff sender instead.
505
+ await deps.runProvenance
506
+ ?.stamp(m.session_id, {
507
+ origin,
508
+ depth,
509
+ ...(parent ? { parentSessionId: parent.sessionId, parentProject: parent.project } : {}),
510
+ })
511
+ .catch(() => undefined);
512
+ // #290: record the kickoff's sender for the NEW chat now that its id
513
+ // is known (no live emit — see handleInjection; the labelled turn
514
+ // hydrates from history when the chat is opened).
515
+ handleInjection(m.session_id);
516
+ }
517
+ turn.setSession(m.session_id);
518
+ resolveId(m.session_id);
519
+ }
520
+ // Capture per-turn usage: assistant blocks feed the context snapshot; the
521
+ // terminal `result` block's CUMULATIVE usage is kept apart so it can't
522
+ // inflate the live context meter (#398).
523
+ foldTurnUsage(seen, m);
524
+ // #329: surface a dead-end BEFORE translating — a synthetic usage-limit
525
+ // message or a terminal error/`error_max_turns` result. The translator
526
+ // drops every synthetic message (so nothing would ever render), which is
527
+ // exactly why these turns look dead. `noticeFromMessage` returns null for
528
+ // ordinary output and for suppressed "No response requested." placeholders.
529
+ // #380/#394: track whether this turn showed the user real prose so a
530
+ // trailing error/max_turns result (which races in AFTER the reply in
531
+ // session mode) is suppressed below. OR-accumulated across ALL of the
532
+ // turn's messages — a tool-heavy turn's prose rides on a `tool_use`
533
+ // message and its terminal `end_turn` is often thinking-only (#394), so
534
+ // the flag must survive later text-less messages, not just the last one.
535
+ if (messageProducedReply(m))
536
+ producedReply = true;
537
+ const notice = noticeFromMessage(m);
538
+ if (notice)
539
+ emitNotice(notice);
540
+ // #429: recover any Task/Agent launch from this message's tool_use input so
541
+ // the enriched card renders live — must run before translate fires onToolStart.
542
+ for (const l of extractSubagentLaunches(m))
543
+ subagentLaunches.set(l.toolUseId, l);
544
+ await translate(m);
545
+ },
546
+ });
547
+ // Detached completion: emit the terminal frame + end the hub turn no matter
548
+ // what. Never throws to the event loop (guards the fan-out from one bad turn).
549
+ void drivePromise
550
+ .then((result) => {
551
+ // #404: mirror the human-turn path — a session-mode turn that produced a real
552
+ // reply routinely ends with a trailing `error_*` / `success:false` result
553
+ // frame, so gating the post-turn sweep / recovery arm on raw `result.success`
554
+ // silently skips them. Trust the same `producedReply` signal the banner does.
555
+ const effectiveSuccess = turnEffectivelySucceeded(result.success, producedReply);
556
+ const finalSession = (effectiveSuccess ? (result.sessionId ?? resolvedSession) : resolvedSession) ?? null;
557
+ if (finalSession)
558
+ turn.setSession(finalSession);
559
+ const completeModel = seen.model ?? fallbackModel;
560
+ const u = resolveTurnUsage(seen);
561
+ const completeUsage = u
562
+ ? {
563
+ inputTokens: u.inputTokens,
564
+ outputTokens: u.outputTokens,
565
+ cacheReadTokens: u.cacheReadTokens,
566
+ cacheCreationTokens: u.cacheCreationTokens,
567
+ contextTokens: u.inputTokens + u.cacheReadTokens + u.cacheCreationTokens,
568
+ contextLimit: getContextLimit(completeModel),
569
+ }
570
+ : undefined;
571
+ // #329: a turn that failed WITHOUT a terminal error `result` reaching the
572
+ // stream (a thrown/rejected drive: network/DNS reset, process crash) never
573
+ // triggered `noticeFromMessage`. Surface it here as an inline error notice
574
+ // so the failure is visible — before chat:complete flips streaming off.
575
+ if (!result.success)
576
+ emitNotice(errorNotice(result.error?.message));
577
+ turn.emit({
578
+ type: "chat:complete",
579
+ payload: {
580
+ ...routing(),
581
+ sessionId: finalSession,
582
+ jobId: result.jobId ?? jobId,
583
+ success: result.success,
584
+ error: result.error?.message,
585
+ ...(completeUsage ? { model: completeModel, usage: completeUsage } : {}),
586
+ },
587
+ });
588
+ turn.end();
589
+ try {
590
+ deps.herdctl.invalidateSessions(agentName);
591
+ }
592
+ catch {
593
+ /* non-fatal */
594
+ }
595
+ // T5: post-turn curation via the `afterTurn` event (folded-in sweeper).
596
+ if (effectiveSuccess)
597
+ triggers.emitAfterTurn(projectSlug, finalSession);
598
+ // Layer 3 (issue #301): arm a post-turn recovery watch for a session-mode
599
+ // keeper turn that stayed alive — including a recovery re-drive itself, so a
600
+ // re-drive that hangs again is caught (bounded by the per-session retry cap).
601
+ if (effectiveSuccess && finalSession && driveMode === "session") {
602
+ recoveryEngine.armWatch({ slug: projectSlug, sessionId: finalSession });
603
+ }
604
+ if (!resolvedSession) {
605
+ rejectId(new Error(result.error?.message ?? "turn ended with no session id"));
606
+ }
607
+ })
608
+ .catch((err) => {
609
+ const error = err instanceof Error ? err.message : String(err);
610
+ // #329: the drive itself rejected — surface the failure inline so the chat
611
+ // doesn't just look dead (deduped against any notice already emitted).
612
+ emitNotice(errorNotice(error));
613
+ turn.emit({
614
+ type: "chat:complete",
615
+ payload: { ...routing(), sessionId: resolvedSession, jobId, success: false, error },
616
+ });
617
+ turn.end();
618
+ rejectId(err instanceof Error ? err : new Error(error));
619
+ });
620
+ // Never hang the calling tool forever if the child never streams an id.
621
+ const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error("timed out waiting for spawned chat to start")), 60_000));
622
+ return Promise.race([idKnown, timeout]);
623
+ }
624
+ /**
625
+ * Sessions with a recovery nudge dispatch in flight (issue #352 double-dispatch
626
+ * guard). A resume of a still-in-flight session-mode chat interrupts and swallows
627
+ * the prior resume, so we never let two recovery/Continue dispatches for the same
628
+ * session overlap. Set synchronously the moment a nudge is dispatched (before the
629
+ * `sessionExists` await), cleared once the turn has started; `hub.isRunning` then
630
+ * carries the guard for the turn's lifetime.
631
+ */
632
+ const injectingRecovery = new Set();
633
+ /**
634
+ * Inject the keeper-chat recovery nudge into a still-alive session (issue #301) —
635
+ * the ONE shared path behind both the manual Layer 2 "Continue" (`chat:continue`)
636
+ * and the Layer 3 automatic re-drive ({@link RecoveryEngine}). Re-drives the
637
+ * hung keeper via {@link startAgentTurn} with the {@link RECOVERY_NUDGE} and a
638
+ * `recovery` sender, exactly the message a human sends by hand to unstick it.
639
+ * No-op for a session that no longer exists. The gate on
640
+ * WHICH layer may call this lives in each caller (surfaceKilledTask vs
641
+ * autoReDrive); this helper is layer-agnostic.
642
+ */
643
+ const injectRecoveryNudge = async (project, sessionId) => {
644
+ // Every workspace has a keeper, including the root — whose key is `""`. This
645
+ // guard used to read `if (!slug) return;`, which silently killed both the
646
+ // "Continue" nudge and auto-re-drive for every root chat.
647
+ const slug = project.slug;
648
+ // Single-flight double-dispatch guard (issue #352). Two dispatches resuming the
649
+ // SAME session at once is fatal under session-mode `chatSession(resume)`: the
650
+ // second resume interrupts the first, so one nudge is swallowed ("first message
651
+ // swallowed", #350/#347). A turn already running for this session (a human send,
652
+ // a queued-message drain, or a Continue click) means the keeper is not idle —
653
+ // yield to it. And `injectingRecovery` closes the async gap below (the
654
+ // `sessionExists` await) so two near-simultaneous recovery/Continue calls can't
655
+ // both get past this check before either registers its turn as running.
656
+ if (injectingRecovery.has(sessionId) || hub.isRunning(sessionId)) {
657
+ return;
658
+ }
659
+ injectingRecovery.add(sessionId);
660
+ try {
661
+ // Only re-drive a real, existing session (a live kept-alive keeper chat).
662
+ if (!(await deps.herdctl.sessionExists(project, sessionId).catch(() => false)))
663
+ return;
664
+ const driveMode = project.driveMode && isKnownDriveMode(project.driveMode)
665
+ ? project.driveMode
666
+ : deps.cfg.driveMode;
667
+ await startAgentTurn({
668
+ projectSlug: slug,
669
+ agentName: keeperAgentName(slug),
670
+ workingDir: project.workingDir,
671
+ resume: sessionId,
672
+ prompt: RECOVERY_NUDGE,
673
+ driveMode,
674
+ fallbackModel: project.model,
675
+ // A resume never re-stamps provenance (only new chats are stamped), so these
676
+ // describe-the-run values aren't persisted — the target keeps its own marker
677
+ // and its self-MCP is gated on THAT recorded depth. Describe it as a
678
+ // human-rooted run (matches the Layer 2 manual Continue path).
679
+ origin: "human",
680
+ depth: 0,
681
+ maxSpawnDepth: resolveMaxSpawnDepth(project.maxSpawnDepth, deps.cfg.maxSpawnDepth),
682
+ // #290 / #301: attribute the injected nudge to Paddock recovery so the history
683
+ // renders "⚠ continued after a background task was terminated" and emits a live
684
+ // chat:injected frame to any attached viewer.
685
+ sender: { kind: "recovery" },
686
+ });
687
+ }
688
+ finally {
689
+ // Clear the single-flight mark once the turn has STARTED (startAgentTurn
690
+ // resolves as soon as the session id is known — for a resume, immediately —
691
+ // by which point `hub.isRunning(sessionId)` is true and takes over the guard).
692
+ injectingRecovery.delete(sessionId);
693
+ }
694
+ };
695
+ /**
696
+ * Layer 3 automatic-recovery engine (issue #301). After each session-mode keeper
697
+ * turn completes (armed at the completion sites below), it tails the transcript;
698
+ * if a background task was killed at the turn boundary and the keeper doesn't wake
699
+ * on its own, it auto-injects the recovery nudge — guarded by the resolved
700
+ * `recovery.autoReDrive` flag (default OFF), a debounce window, and a per-session
701
+ * retry cap. Re-drive reuses the exact {@link injectRecoveryNudge} path as the
702
+ * manual Continue; a human message ({@link RecoveryEngine.onHumanMessage}) resets
703
+ * a session's guard so a later genuine hang recovers fresh.
704
+ */
705
+ const recoveryEngine = new RecoveryEngine({
706
+ cfg: { recovery: deps.cfg.recovery },
707
+ getProject: (slug) => deps.projects.get(slug),
708
+ reDrive: (project, sessionId) => injectRecoveryNudge(project, sessionId),
709
+ // #352: a live turn on this session means the keeper isn't idle — the watch
710
+ // defers rather than surface a stale "idle" banner or fire a re-drive that
711
+ // would interrupt (and be swallowed by) the in-flight turn. `injectRecoveryNudge`
712
+ // holds the same guard for the recovery path's own dispatch window.
713
+ isBusy: (sessionId) => hub.isRunning(sessionId),
714
+ // #397: `hub.isRunning` only sees turns PADDOCK started — it is BLIND to a
715
+ // session herdctl's reaper is keeping alive for the just-killed background task
716
+ // (or its ~15s re-invocation grace). Auto re-drive resumes via a FRESH
717
+ // subprocess, so firing while that prior subprocess is still live spawned a
718
+ // COMPETING resume the SDK interrupted (`[Request interrupted by user]`). Thread
719
+ // the reaper's true liveness in so the engine defers until the session is
720
+ // genuinely idle. Null-safe (batch mode / no reaper → false = pre-#397 path).
721
+ sdkSessionLive: (sessionId) => deps.herdctl.isSdkSessionLive(sessionId),
722
+ // #347: when a background task is killed at the turn boundary, its
723
+ // notification is trapped in the SDK input queue — the client would never
724
+ // render the "keeper is idle" affordance until a refresh flushed it. On
725
+ // detection, broadcast a live frame to any attached socket so the banner +
726
+ // Continue appear immediately. Out-of-band (no live turn), so hub.broadcast.
727
+ surface: (project, sessionId, summary) => {
728
+ hub.broadcast(sessionId, {
729
+ type: "chat:killed_task",
730
+ payload: {
731
+ projectSlug: project.slug,
732
+ sessionId,
733
+ summary: summary ?? "A background task was terminated at the turn boundary.",
734
+ timestamp: new Date().toISOString(),
735
+ },
736
+ });
737
+ },
738
+ });
739
+ return {
740
+ startAgentTurn,
741
+ buildInjection,
742
+ wakeInjection,
743
+ selfMcpCtx,
744
+ injectRecoveryNudge,
745
+ recoveryEngine,
746
+ emitAfterTurn: triggers.emitAfterTurn,
747
+ composePreloadedPrompt: triggers.composePreloadedPrompt,
748
+ fireTrigger: triggers.fireTrigger,
749
+ makeBackgroundTurnSink,
750
+ };
751
+ }