@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,490 @@
1
+ /**
2
+ * Keeper-chat recovery ENGINE — Layer 3 (automatic re-drive) for issue #301.
3
+ *
4
+ * ── What it does ──────────────────────────────────────────────────────────────
5
+ * Phase 0 (recovery-config.ts) defined the config surface and Phase 1 (Layer 2)
6
+ * shipped the manual "Continue" button. This module is Layer 3: it AUTOMATICALLY
7
+ * unsticks a keeper whose background task was killed at the turn boundary, with no
8
+ * human in the loop — the same nudge a human sends by hand (or clicks Continue for),
9
+ * fired on its own.
10
+ *
11
+ * ── How it detects a hang (approach A — post-turn transcript tail) ────────────
12
+ * The one thing we can rely on (see edspencer/herdctl#374) is that herdctl keeps
13
+ * the session ALIVE at the turn boundary and the transcript is the source of truth.
14
+ * So after a SESSION-mode keeper turn completes, {@link RecoveryEngine.armWatch}
15
+ * tails that session's transcript JSONL for a bounded window:
16
+ *
17
+ * 1. A COMPLETED background task emits a follow-up that WAKES the keeper — more
18
+ * `assistant` entries append. A KILLED/STOPPED task writes a terminated
19
+ * `<task-notification>` (a `user` entry) that wakes NOTHING — no assistant
20
+ * entry follows it.
21
+ * 2. So the hung signature is: the tail ends (logically) with a terminated
22
+ * task-notification and NO `assistant` activity after it. That's exactly what
23
+ * {@link detectHang} computes by reducing the appended entries in order.
24
+ * 3. The kill lands ~2–8s AFTER the turn ends, so the watch polls for
25
+ * `debounceMs + KILL_GRACE_MS`, and only fires once the notification has sat
26
+ * un-answered for the full `debounceMs` quiet window — a keeper that wakes
27
+ * itself inside that window resets the pending state and is never poked.
28
+ *
29
+ * ── Guards (issue #301) ───────────────────────────────────────────────────────
30
+ * - Only fires when the resolved `recovery.autoReDrive` (per-project override
31
+ * else instance default) is ON — Layer 3 is opt-in.
32
+ * - Per-session `{ lastNudgeAt, retryCount }`: a wedged keeper is re-driven at
33
+ * most `maxRetries` times, then left alone (no poke-loop).
34
+ * - `debounceMs`: never fires until the notification has been quiet that long.
35
+ * - A HUMAN message ({@link RecoveryEngine.onHumanMessage}) clears the session's
36
+ * entry, so a genuinely-new later hang re-arms fresh.
37
+ *
38
+ * The engine is deliberately narrow + injectable (clock, scheduler, transcript
39
+ * reader) so the timing-sensitive orchestration is unit-testable without real
40
+ * timers or a real filesystem — same discipline as the sidecar-store modules.
41
+ */
42
+ import { promises as fsp } from "node:fs";
43
+ import path from "node:path";
44
+ import { projectChatsDir } from "./transcripts.js";
45
+ import { isTerminatedTaskStatus, resolveRecoveryConfig } from "./recovery-config.js";
46
+ /** Flatten a transcript message `content` (string, or an array of blocks) to text. */
47
+ function contentText(content) {
48
+ if (typeof content === "string")
49
+ return content;
50
+ if (!Array.isArray(content))
51
+ return "";
52
+ return content
53
+ .map((b) => {
54
+ const blk = b;
55
+ return blk?.type === "text" && typeof blk.text === "string" ? blk.text : "";
56
+ })
57
+ .join("");
58
+ }
59
+ /** Extract a `<task-notification>` `<status>` value, or undefined. */
60
+ function notificationStatus(text) {
61
+ return /<status>([\s\S]*?)<\/status>/.exec(text)?.[1];
62
+ }
63
+ /** True when `text` is a `<task-notification>` carrying a terminated (killed/
64
+ * stopped) status — the turn-boundary-kill signature this recovers from. */
65
+ function isTerminatedNotificationText(text) {
66
+ return text.includes("<task-notification>") && isTerminatedTaskStatus(notificationStatus(text));
67
+ }
68
+ /**
69
+ * Classify one already-parsed transcript entry as the hang detector sees it.
70
+ *
71
+ * A terminated `<task-notification>` reaches the transcript in TWO shapes, and we
72
+ * must catch both (issue #347):
73
+ * - As a materialised `type:"user"` entry — but that only appears once a LATER
74
+ * turn flushes the SDK's input queue, often tens of seconds after the kill.
75
+ * - At the moment of the kill, as a `type:"queue-operation"` `enqueue` whose
76
+ * `content` IS the notification. This is the one that lands inside the watch
77
+ * window; missing it (the original bug) let every real hang go undetected.
78
+ *
79
+ * A keeper reply is `assistant`; a killed/stopped notification in either shape is
80
+ * `terminated-notification`; everything else (system lines, attachments, a
81
+ * `dequeue`, a completed/running notification, plain user text) is `other`.
82
+ */
83
+ export function classifyEntry(entry) {
84
+ if (entry.type === "assistant")
85
+ return "assistant";
86
+ if (entry.type === "user" && isTerminatedNotificationText(contentText(entry.message?.content))) {
87
+ return "terminated-notification";
88
+ }
89
+ // The kill's notification is delivered to the input queue as an `enqueue`
90
+ // whose `content` is the `<task-notification>`; a `dequeue` carries no payload.
91
+ if (entry.type === "queue-operation" && entry.operation === "enqueue") {
92
+ if (isTerminatedNotificationText(contentText(entry.content)))
93
+ return "terminated-notification";
94
+ }
95
+ return "other";
96
+ }
97
+ /** Extract a `<task-notification>` `<summary>` value (trimmed), or undefined. */
98
+ function notificationSummary(text) {
99
+ return /<summary>([\s\S]*?)<\/summary>/.exec(text)?.[1]?.trim() || undefined;
100
+ }
101
+ /** The notification payload carried by an entry, whichever shape it took: a
102
+ * `user` message's content, or a `queue-operation` enqueue's `content`. */
103
+ function notificationText(entry) {
104
+ if (entry.type === "user")
105
+ return contentText(entry.message?.content);
106
+ if (entry.type === "queue-operation")
107
+ return contentText(entry.content);
108
+ return "";
109
+ }
110
+ /**
111
+ * Parse one raw JSONL line into its {@link TailEvent} plus, for a terminated
112
+ * notification, its human-readable `<summary>` (used by the live surface frame).
113
+ * Unparseable/blank → `other`.
114
+ */
115
+ export function parseTailLine(line) {
116
+ const trimmed = line.trim();
117
+ if (!trimmed)
118
+ return { event: "other" };
119
+ let entry;
120
+ try {
121
+ entry = JSON.parse(trimmed);
122
+ }
123
+ catch {
124
+ return { event: "other" };
125
+ }
126
+ const event = classifyEntry(entry);
127
+ return event === "terminated-notification"
128
+ ? { event, summary: notificationSummary(notificationText(entry)) }
129
+ : { event };
130
+ }
131
+ /** Classify one raw JSONL line (unparseable/blank → `other`). */
132
+ export function classifyLine(line) {
133
+ return parseTailLine(line).event;
134
+ }
135
+ /**
136
+ * Reduce an ordered stream of tail events to whether the transcript currently ends
137
+ * (logically) with an un-answered terminated task-notification — the hung
138
+ * signature. An `assistant` entry clears any pending notification (the keeper
139
+ * woke); a terminated notification sets it; `other` entries are inert. True iff a
140
+ * terminated notification is still pending after the last event.
141
+ */
142
+ export function detectHang(events) {
143
+ let pending = false;
144
+ for (const ev of events) {
145
+ if (ev === "assistant")
146
+ pending = false;
147
+ else if (ev === "terminated-notification")
148
+ pending = true;
149
+ }
150
+ return pending;
151
+ }
152
+ /** Default poll cadence — brisk enough to catch the kill+debounce, cheap enough to ignore. */
153
+ export const DEFAULT_POLL_MS = 750;
154
+ /**
155
+ * How long past `debounceMs` we keep watching for the kill to land. herdctl#374's
156
+ * teardown fires ~2–8s after the turn boundary, so the notification can appear late;
157
+ * this grace ensures its full `debounceMs` quiet window still fits inside the watch.
158
+ */
159
+ export const DEFAULT_KILL_GRACE_MS = 12_000;
160
+ /**
161
+ * Cadence (ms) at which a deferred fire re-checks whether the session has gone
162
+ * idle (issue #397). Brisk enough to fire promptly once the reaper releases,
163
+ * cheap enough to ignore.
164
+ */
165
+ export const DEFAULT_SETTLE_POLL_MS = 1_000;
166
+ /**
167
+ * How long (ms) the engine keeps DEFERRING an otherwise-ready fire while the
168
+ * session is still live (hub or reaper) before standing down (issue #397). Must
169
+ * comfortably exceed herdctl's `DEFAULT_REINVOCATION_GRACE_MS` (15s) — the reaper
170
+ * holds a just-killed-task session live for that grace — plus margin, so the
171
+ * deferred nudge lands once the reaper reaps rather than giving up early. Bounds
172
+ * the defer so a session the reaper never releases can't retry forever.
173
+ */
174
+ export const DEFAULT_SETTLE_WINDOW_MS = 30_000;
175
+ /**
176
+ * Cheap current size (bytes) of the transcript via `stat` — the arm-time baseline,
177
+ * so we never slurp a multi-MB transcript just to learn where its EOF is. A missing
178
+ * file (the turn wrote no transcript yet) → 0, so the watch starts from the top.
179
+ */
180
+ async function defaultFileSize(file) {
181
+ try {
182
+ return (await fsp.stat(file)).size;
183
+ }
184
+ catch {
185
+ return 0;
186
+ }
187
+ }
188
+ /**
189
+ * Read the transcript bytes appended since `offset` using a single positioned read
190
+ * (not a whole-file slurp), so tailing a large transcript stays cheap. A missing
191
+ * file yields no new text at the same offset (the turn simply never wrote one).
192
+ */
193
+ async function defaultReadTail(file, offset) {
194
+ let fh = null;
195
+ try {
196
+ fh = await fsp.open(file, "r");
197
+ const { size } = await fh.stat();
198
+ if (size <= offset)
199
+ return { text: "", size };
200
+ const len = size - offset;
201
+ const buf = Buffer.allocUnsafe(len);
202
+ await fh.read(buf, 0, len, offset);
203
+ return { text: buf.toString("utf8"), size };
204
+ }
205
+ catch {
206
+ return { text: "", size: offset };
207
+ }
208
+ finally {
209
+ await fh?.close().catch(() => undefined);
210
+ }
211
+ }
212
+ /**
213
+ * Layer 3 automatic-recovery engine (issue #301). Arm a post-turn transcript watch
214
+ * after each session-mode keeper turn; if a killed-at-turn-boundary notification
215
+ * appears with no keeper response, auto-inject a recovery nudge — guarded by
216
+ * debounce + a per-session retry cap, and cleared when a human next messages.
217
+ */
218
+ export class RecoveryEngine {
219
+ deps;
220
+ now;
221
+ setTimer;
222
+ clearTimer;
223
+ readTail;
224
+ fileSize;
225
+ pollMs;
226
+ killGraceMs;
227
+ settlePollMs;
228
+ settleWindowMs;
229
+ log;
230
+ surface;
231
+ isBusy;
232
+ sdkSessionLive;
233
+ /** Per-session retry/debounce bookkeeping — cleared on a human message. */
234
+ guards = new Map();
235
+ /** In-flight watches keyed by session id — re-arming or a human message cancels one. */
236
+ watches = new Map();
237
+ constructor(deps) {
238
+ this.deps = deps;
239
+ this.now = deps.now ?? Date.now;
240
+ this.setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
241
+ this.clearTimer = deps.clearTimer ?? ((h) => clearTimeout(h));
242
+ this.readTail = deps.readTail ?? defaultReadTail;
243
+ this.fileSize = deps.fileSize ?? defaultFileSize;
244
+ this.pollMs = deps.pollMs ?? DEFAULT_POLL_MS;
245
+ this.killGraceMs = deps.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
246
+ this.settlePollMs = deps.settlePollMs ?? DEFAULT_SETTLE_POLL_MS;
247
+ this.settleWindowMs = deps.settleWindowMs ?? DEFAULT_SETTLE_WINDOW_MS;
248
+ this.log = deps.log ?? (() => undefined);
249
+ this.surface = deps.surface ?? (() => undefined);
250
+ this.isBusy = deps.isBusy ?? (() => false);
251
+ this.sdkSessionLive = deps.sdkSessionLive ?? (() => false);
252
+ }
253
+ /**
254
+ * Arm (or re-arm) a post-turn watch for a just-completed SESSION-mode keeper turn.
255
+ * No-op unless SOMETHING wants it for this project: Layer 2 `surfaceKilledTask`
256
+ * (surface the kill live) or Layer 3 `autoReDrive` (auto re-drive, while the
257
+ * session has retries left). Re-arming cancels any prior watch for the same
258
+ * session. Safe to call fire-and-forget; never throws (a lookup/IO failure just
259
+ * declines to watch).
260
+ */
261
+ armWatch(input) {
262
+ void this.armWatchAsync(input).catch((err) => {
263
+ this.log("recovery: armWatch failed", { err: String(err), ...input });
264
+ });
265
+ }
266
+ async armWatchAsync({ slug, sessionId }) {
267
+ let project;
268
+ try {
269
+ project = await this.deps.getProject(slug);
270
+ }
271
+ catch {
272
+ return; // unknown/deleted project — nothing to recover
273
+ }
274
+ const recovery = resolveRecoveryConfig(project.recovery, this.deps.cfg.recovery);
275
+ // Two independent actions can want this watch: Layer 2 `surfaceKilledTask`
276
+ // (push the "keeper is idle" affordance live) and Layer 3 `autoReDrive`
277
+ // (re-drive automatically). Re-drive is additionally bounded by the retry cap
278
+ // — each armed watch fires at most once, so gating on `retryCount >= maxRetries`
279
+ // bounds total auto re-drives to exactly maxRetries (and `maxRetries: 0`
280
+ // disables it). Surfacing has no such cap: a re-drive that hangs again is a new
281
+ // event worth showing. If neither wants in, don't arm at all.
282
+ const retryCount = this.guards.get(sessionId)?.retryCount ?? 0;
283
+ const wantSurface = recovery.surfaceKilledTask;
284
+ const wantReDrive = recovery.autoReDrive && retryCount < recovery.maxRetries;
285
+ if (!wantSurface && !wantReDrive) {
286
+ this.log("recovery: nothing to arm (surface off, re-drive off or capped)", {
287
+ slug,
288
+ sessionId,
289
+ retryCount,
290
+ maxRetries: recovery.maxRetries,
291
+ });
292
+ return;
293
+ }
294
+ // Re-arming supersedes any prior watch for this session.
295
+ this.cancelWatch(sessionId);
296
+ const file = path.join(projectChatsDir(project.dir), `${sessionId}.jsonl`);
297
+ // Baseline = the transcript's current EOF via a cheap `stat` (NOT a full read):
298
+ // this runs on EVERY session-mode turn once autoReDrive is on, and transcripts
299
+ // here are multi-MB. Start the tail from EOF so we only ever see entries appended
300
+ // AFTER this turn (the kill's notification), never a stale one from earlier.
301
+ let offset = await this.fileSize(file);
302
+ let carry = "";
303
+ let pending = false;
304
+ let pendingSummary;
305
+ let notifiedAt = null;
306
+ const deadline = this.now() + recovery.debounceMs + this.killGraceMs;
307
+ const watch = { timer: null, cancelled: false };
308
+ this.watches.set(sessionId, watch);
309
+ const tick = async () => {
310
+ if (watch.cancelled)
311
+ return;
312
+ let tail;
313
+ try {
314
+ tail = await this.readTail(file, offset);
315
+ }
316
+ catch {
317
+ tail = { text: "", size: offset };
318
+ }
319
+ if (watch.cancelled)
320
+ return;
321
+ offset = tail.size;
322
+ const combined = carry + tail.text;
323
+ const lines = combined.split("\n");
324
+ // The last element is an incomplete (unterminated) line — hold it for next read.
325
+ carry = lines.pop() ?? "";
326
+ for (const line of lines) {
327
+ const { event, summary } = parseTailLine(line);
328
+ if (event === "assistant") {
329
+ pending = false;
330
+ pendingSummary = undefined;
331
+ notifiedAt = null;
332
+ }
333
+ else if (event === "terminated-notification" && !pending) {
334
+ pending = true;
335
+ pendingSummary = summary;
336
+ notifiedAt = this.now();
337
+ }
338
+ }
339
+ const t = this.now();
340
+ if (pending && notifiedAt !== null && t - notifiedAt >= recovery.debounceMs) {
341
+ // Hung: a terminated notification has sat un-answered for the full debounce.
342
+ this.cancelWatch(sessionId);
343
+ await this.fire(project, sessionId, recovery, pendingSummary);
344
+ return;
345
+ }
346
+ if (t >= deadline) {
347
+ // The turn ended cleanly (no kill) or the keeper woke — stop watching.
348
+ this.cancelWatch(sessionId);
349
+ return;
350
+ }
351
+ if (!watch.cancelled)
352
+ watch.timer = this.setTimer(() => void tick(), this.pollMs);
353
+ };
354
+ watch.timer = this.setTimer(() => void tick(), this.pollMs);
355
+ }
356
+ /**
357
+ * A detected turn-boundary kill is ready to recover — kick off the fire, giving
358
+ * it a bounded {@link settleWindowMs} budget to wait out any still-live session.
359
+ * See {@link fireWhenIdle} for the surface/re-drive actions and the defer guard.
360
+ */
361
+ async fire(project, sessionId, recovery, summary) {
362
+ await this.fireWhenIdle(project, sessionId, recovery, summary, this.now() + this.settleWindowMs);
363
+ }
364
+ /**
365
+ * Handle a detected turn-boundary kill. Two independent, resolved-config-gated
366
+ * actions, in order:
367
+ * 1. `surfaceKilledTask` — push the kill to attached clients LIVE so the
368
+ * "keeper is idle / Continue" affordance appears without a refresh. Never
369
+ * throws; a bad sink can't wedge the engine.
370
+ * 2. `autoReDrive` (under the retry cap) — bump the session's retry bookkeeping
371
+ * FIRST (so a re-drive that itself hangs can't exceed the cap), then inject
372
+ * the recovery nudge via the shared Layer 2 path. A failed inject is logged.
373
+ *
374
+ * Liveness guard + defer (issues #352 / #397). Two busy signals mean the session
375
+ * is NOT idle and firing would collide with a live `claude` subprocess:
376
+ * - {@link isBusy} (hub) — a live turn PADDOCK itself started (a human message,
377
+ * a queued-message drain, or a prior recovery nudge).
378
+ * - {@link sdkSessionLive} (reaper) — a subprocess herdctl is keeping alive for
379
+ * the just-killed background task, or holding through its ~15s re-invocation
380
+ * grace. `isBusy` is BLIND to this, which is what let the auto re-drive fire a
381
+ * COMPETING resume and get interrupted (`[Request interrupted by user]`, #397).
382
+ *
383
+ * When EITHER is set we must NOT fire — but standing down *permanently* leaves
384
+ * recovery incomplete: the reaper reaps SILENTLY (no turn completes, so nothing
385
+ * re-arms a watch), and the user stays stuck. So instead we DEFER: re-check after
386
+ * {@link settlePollMs} and fire exactly once the session is genuinely idle
387
+ * (reaper reaped, hub not running). The deferral is bounded by
388
+ * {@link settleWindowMs} so a session that never releases can't retry forever, and
389
+ * is tracked in {@link watches} so a completing turn ({@link armWatch}) or a human
390
+ * message ({@link onHumanMessage}) supersedes/cancels it — no double fire.
391
+ */
392
+ async fireWhenIdle(project, sessionId, recovery, summary, settleDeadline) {
393
+ const hubBusy = this.isBusy(sessionId);
394
+ const reaperLive = this.sdkSessionLive(sessionId);
395
+ if (hubBusy || reaperLive) {
396
+ if (this.now() >= settleDeadline) {
397
+ // The session never went idle within the settle budget — give up rather
398
+ // than defer forever. A later genuine hang re-arms a fresh watch.
399
+ this.log("recovery: session still live at settle deadline — standing down", {
400
+ slug: project.slug,
401
+ sessionId,
402
+ hubBusy,
403
+ reaperLive,
404
+ });
405
+ return;
406
+ }
407
+ // Defer: re-check liveness after the settle poll. Register as a watch so a
408
+ // fresh armWatch (a completing turn) or a human message cancels this retry.
409
+ const watch = { timer: null, cancelled: false };
410
+ this.watches.set(sessionId, watch);
411
+ watch.timer = this.setTimer(() => {
412
+ if (watch.cancelled)
413
+ return;
414
+ // Drop this settle-watch from the map before re-attempting; fireWhenIdle
415
+ // re-registers a fresh one if it still needs to defer.
416
+ this.cancelWatch(sessionId);
417
+ void this.fireWhenIdle(project, sessionId, recovery, summary, settleDeadline).catch((err) => {
418
+ this.log("recovery: deferred fire failed", { sessionId, err: String(err) });
419
+ });
420
+ }, this.settlePollMs);
421
+ this.log("recovery: session still live (hub or reaper) — deferring nudge until idle", {
422
+ slug: project.slug,
423
+ sessionId,
424
+ hubBusy,
425
+ reaperLive,
426
+ });
427
+ return;
428
+ }
429
+ if (recovery.surfaceKilledTask) {
430
+ try {
431
+ this.surface(project, sessionId, summary);
432
+ this.log("recovery: surfaced killed task", { slug: project.slug, sessionId });
433
+ }
434
+ catch (err) {
435
+ this.log("recovery: surface failed", { sessionId, err: String(err) });
436
+ }
437
+ }
438
+ const retryCount = this.guards.get(sessionId)?.retryCount ?? 0;
439
+ if (!recovery.autoReDrive || retryCount >= recovery.maxRetries)
440
+ return;
441
+ const guard = this.guards.get(sessionId) ?? { lastNudgeAt: 0, retryCount: 0 };
442
+ guard.retryCount += 1;
443
+ guard.lastNudgeAt = this.now();
444
+ this.guards.set(sessionId, guard);
445
+ this.log("recovery: auto re-driving hung keeper", {
446
+ slug: project.slug,
447
+ sessionId,
448
+ retryCount: guard.retryCount,
449
+ maxRetries: recovery.maxRetries,
450
+ });
451
+ try {
452
+ await this.deps.reDrive(project, sessionId);
453
+ }
454
+ catch (err) {
455
+ this.log("recovery: auto re-drive inject failed", { sessionId, err: String(err) });
456
+ }
457
+ }
458
+ /**
459
+ * A human just messaged this session — clear its retry/debounce bookkeeping and
460
+ * cancel any in-flight watch, so a genuinely-new later hang is recovered fresh
461
+ * (the retry cap counts auto re-drives BETWEEN human messages).
462
+ */
463
+ onHumanMessage(sessionId) {
464
+ this.guards.delete(sessionId);
465
+ this.cancelWatch(sessionId);
466
+ }
467
+ /** Cancel the in-flight watch for a session, if any (idempotent). */
468
+ cancelWatch(sessionId) {
469
+ const w = this.watches.get(sessionId);
470
+ if (!w)
471
+ return;
472
+ w.cancelled = true;
473
+ if (w.timer !== null)
474
+ this.clearTimer(w.timer);
475
+ this.watches.delete(sessionId);
476
+ }
477
+ /** Cancel every in-flight watch (e.g. on shutdown). */
478
+ stopAll() {
479
+ for (const id of [...this.watches.keys()])
480
+ this.cancelWatch(id);
481
+ }
482
+ /** Test/inspection helper: the current retry count for a session (0 if untracked). */
483
+ retryCountFor(sessionId) {
484
+ return this.guards.get(sessionId)?.retryCount ?? 0;
485
+ }
486
+ /** Test/inspection helper: whether a watch is currently armed for a session. */
487
+ isWatching(sessionId) {
488
+ return this.watches.has(sessionId);
489
+ }
490
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Resume self-interrupt fix — drain a stale async-input backlog before the real turn.
3
+ *
4
+ * ## The bug this fixes
5
+ *
6
+ * When a keeper session is resumed and the `claude` CLI still holds a stale
7
+ * async-input backlog (leftover `killed`/orphaned `<task-notification>` messages
8
+ * from background shells/subagents a previous process left running), the CLI's
9
+ * drain loop replays that backlog as its **own turn**, emitting that turn's
10
+ * `result` FIRST — ahead of the human/wake/command turn.
11
+ *
12
+ * Every resume consumer used to `break` its `for await (const m of session.messages)`
13
+ * loop on the FIRST `result`. Breaking a `for await` invokes the async generator's
14
+ * `.return()`, which tears the SDK query / `claude` subprocess down (with only a
15
+ * ~2s SIGTERM grace). So the backlog turn's result closed the subprocess out from
16
+ * under the still-running real turn — killing it (`[Request interrupted by user]` /
17
+ * `interruptedByShutdown:true`, the human message lost). It is intermittent because
18
+ * it races the real turn against the ~2s teardown grace: a fast reply survives; a
19
+ * slow (heavy-keeper) reply is killed.
20
+ *
21
+ * ## The fix
22
+ *
23
+ * {@link consumeResumedTurn} consumes the resumed stream and breaks on a `result`
24
+ * only once the on-disk async-input queue has DRAINED (depth 0) — i.e. after the
25
+ * LAST (real) turn, never after a backlog turn. So a backlog turn's result can no
26
+ * longer tear the real turn down.
27
+ *
28
+ * The gate is **dynamic** (the queue depth is probed after each turn's result), not
29
+ * a one-shot pre-probe. This is deliberate: a backlog can materialize DURING the
30
+ * resume — a crashed process that left background work running writes no `killed`
31
+ * notification, so the CLI reconciles those orphans into the queue at resume time,
32
+ * *after* any pre-open probe would have read 0. Dynamic probing catches both the
33
+ * already-on-disk residue and the reconciled-at-resume residue.
34
+ *
35
+ * Zero added latency when there is no backlog: the real prompt is not enqueued into
36
+ * the async queue when the CLI is idle, so the queue depth is 0 the moment the first
37
+ * (and only) turn completes → we break after the first result exactly like before.
38
+ *
39
+ * Robustness:
40
+ * - The per-turn wait is bounded only for the wait for a turn's FIRST message (so a
41
+ * not-yet-flushed dequeue can't hang us forever waiting on a backlog turn that
42
+ * never arrives). Once a turn starts producing, it is consumed to its `result`
43
+ * UNBOUNDED — a slow real turn is never cut off mid-flight.
44
+ * - An overall deadline is the ultimate safety net.
45
+ * - A single in-flight `next()` is threaded throughout, so the iterator is never
46
+ * advanced twice concurrently.
47
+ */
48
+ import type { SDKMessage } from "@herdctl/core";
49
+ import type { RuntimeSession } from "@herdctl/core";
50
+ export interface ConsumeResumedTurnOptions {
51
+ /** Current on-disk async-input-queue depth for this session (0 = drained). */
52
+ residueProbe: () => Promise<number>;
53
+ /** Surface a message to the client (translate/emit). */
54
+ onMessage?: (m: SDKMessage) => void | Promise<void>;
55
+ /** Called with the session id the moment it first appears in the stream. */
56
+ onSessionId?: (id: string) => void;
57
+ /** Overall deadline (ms). Default {@link DEFAULT_DEADLINE_MS}. */
58
+ deadlineMs?: number;
59
+ /** Optional trace hook — invoked only when a backlog was actually drained. */
60
+ log?: (msg: string) => void;
61
+ }
62
+ export interface ConsumeResumedTurnResult {
63
+ /** True unless the real turn's terminal `result` was an error/`success:false`. */
64
+ success: boolean;
65
+ /** The session id seen on the stream, if any. */
66
+ sessionId: string | null;
67
+ /**
68
+ * The live iterator this consume drove, handed back so the caller can KEEP
69
+ * consuming the SAME stream after the primary turn — without opening a second
70
+ * iterator over the SDK query (which would split messages unpredictably). Used
71
+ * by the Gap B background-turn delivery ({@link consumeBackgroundTurns}): the
72
+ * session is left open by the reaper for in-flight background work, and the
73
+ * autonomous re-invocation turns it later produces arrive on this iterator.
74
+ */
75
+ iterator: AsyncIterator<SDKMessage>;
76
+ /**
77
+ * The single in-flight `iterator.next()` left outstanding when we stopped
78
+ * consuming (awaiting the message AFTER the primary turn's terminal `result`).
79
+ * Handing it back — rather than dropping it and re-pulling — means no message
80
+ * between the primary result and the first background re-invocation is lost.
81
+ */
82
+ pending: Promise<Step>;
83
+ }
84
+ type Step = IteratorResult<SDKMessage>;
85
+ /**
86
+ * Consume a resumed session's stream to the caller's real turn, first draining any
87
+ * stale async-input backlog that replays ahead of it. See the module doc.
88
+ *
89
+ * The session must already have the caller's prompt seeded (chatSession opens with
90
+ * the prompt; runCommand `send`s the command; the wake path is opened with its
91
+ * prompt by herdctl) — this only governs WHEN we stop consuming, never what runs.
92
+ */
93
+ export declare function consumeResumedTurn(session: RuntimeSession, opts: ConsumeResumedTurnOptions): Promise<ConsumeResumedTurnResult>;
94
+ /**
95
+ * Gap B — deliver autonomous background-completion turns.
96
+ *
97
+ * After {@link consumeResumedTurn} returns, the primary turn is done but the
98
+ * session may be held OPEN by the reaper because the turn launched continuous
99
+ * background work (`decideReap` keepAlives while `backgroundTasks.length > 0`).
100
+ * The `claude` subprocess keeps running and, when a background task completes,
101
+ * the SDK hands the parent the result as a fresh autonomous re-invocation turn —
102
+ * emitted on the SAME message stream. Paddock used to stop consuming at the
103
+ * primary `result`, so those messages buffered undelivered: the CLI still
104
+ * PERSISTED the re-invocation to the transcript (the subprocess writes JSONL
105
+ * directly), but the live UI never saw it until a refresh replayed history.
106
+ *
107
+ * This keeps consuming the handed-back {@link ConsumeResumedTurnResult.iterator}
108
+ * (never a second iterator — that would split the stream), forwarding every
109
+ * subsequent message to `onMessage`. The caller's `onMessage` renders each
110
+ * autonomous turn to the hub exactly like the scheduler-wake path does, so it
111
+ * appears live. Consumption ALSO keeps `tapLifecycleStream` advancing, so the
112
+ * reaper keeps receiving its mid-turn `activity` / `background_tasks_changed`
113
+ * signals (they fire as a side effect of the consumer pulling messages) — the
114
+ * behaviour the reaper was designed around.
115
+ *
116
+ * The loop ends cleanly when the reaper reaps the idle session: `close()` ends
117
+ * the SDK query, the iterator yields `done`, and we return. A stream error is
118
+ * swallowed (the turn is already over). Detached by the caller (`void`), it
119
+ * never throws to the event loop.
120
+ */
121
+ export declare function consumeBackgroundTurns(iterator: AsyncIterator<SDKMessage>, pending: Promise<Step>, onMessage: (m: SDKMessage) => void | Promise<void>, onDone?: () => void | Promise<void>): Promise<void>;
122
+ export {};