@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,35 @@
1
+ /** A queued message awaiting auto-send. */
2
+ export interface QueuedMessage {
3
+ text: string;
4
+ createdAtMs: number;
5
+ }
6
+ export declare class QueuedMessageStore {
7
+ private readonly stateFile;
8
+ /** In-memory map of key -> QueuedMessage (loaded once, written through on change). */
9
+ private state;
10
+ /** Serialises concurrent writes so the file never interleaves. */
11
+ private writing;
12
+ constructor(dataDir: string);
13
+ /** Load the persisted map (lazily; tolerant of a missing/corrupt file). */
14
+ private ensureLoaded;
15
+ /**
16
+ * Retrieve a queued message for a chat, or `null` if none / unavailable.
17
+ * Non-throwing: any load error reads as null.
18
+ */
19
+ get(agent: string, sessionId: string): Promise<QueuedMessage | null>;
20
+ /**
21
+ * Atomically read AND remove a chat's queued message (#245). Returns the entry
22
+ * (or null if none). The read+delete happen without an intervening `await`, so
23
+ * two concurrent drains — e.g. a turn-completion drain and an idle `set_queue`
24
+ * drain — can never both observe the same message and double-send it: whichever
25
+ * `take()` runs first gets it, the other gets null. Non-throwing.
26
+ */
27
+ take(agent: string, sessionId: string): Promise<QueuedMessage | null>;
28
+ /**
29
+ * Store (or clear, when `message` is null) a queued message for a chat.
30
+ * Setting `null` deletes the key, keeping the file compact.
31
+ */
32
+ set(agent: string, sessionId: string, message: QueuedMessage | null): Promise<void>;
33
+ /** Write-through, serialised so overlapping writes can't corrupt the file. */
34
+ private persist;
35
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * QueuedMessageStore — a Paddock-side sidecar for per-chat queued messages (#197).
3
+ *
4
+ * The message queue (#91) lets users stack a follow-up to auto-send when the
5
+ * current turn finishes. In PR #204, the queued text lived only in component-local
6
+ * React state, so closing the browser before the turn completed silently dropped it.
7
+ *
8
+ * This store persists queued messages server-side so they survive browser close,
9
+ * page reload, and server restart — enabling true server-driven auto-send.
10
+ *
11
+ * Clones the ReadStateStore pattern: a lightweight, write-through, serialised,
12
+ * corruption-tolerant JSON sidecar in the data dir, storing a `key -> queued text`
13
+ * map (persisted as a plain JSON object). Keyed by `agent \0 sessionId` (NUL-separated).
14
+ *
15
+ * When a turn completes, the server checks for a queued message, auto-sends it as
16
+ * the next message, and deletes the stored entry. The web client receives a
17
+ * `chat:queued_flushed` frame to clear its localStorage.
18
+ */
19
+ import { promises as fs } from "node:fs";
20
+ import path from "node:path";
21
+ const STATE_FILE = "queued-message.json";
22
+ /** Separator in a storage key; a NUL can't occur in an agent name or a UUID. */
23
+ const KEY_SEP = "\u0000";
24
+ /** Compose the storage key for an (agent, session) pair. */
25
+ function keyOf(agent, sessionId) {
26
+ return `${agent}${KEY_SEP}${sessionId}`;
27
+ }
28
+ export class QueuedMessageStore {
29
+ stateFile;
30
+ /** In-memory map of key -> QueuedMessage (loaded once, written through on change). */
31
+ state = null;
32
+ /** Serialises concurrent writes so the file never interleaves. */
33
+ writing = Promise.resolve();
34
+ constructor(dataDir) {
35
+ this.stateFile = path.join(dataDir, STATE_FILE);
36
+ }
37
+ /** Load the persisted map (lazily; tolerant of a missing/corrupt file). */
38
+ async ensureLoaded() {
39
+ if (this.state)
40
+ return this.state;
41
+ const map = new Map();
42
+ try {
43
+ const raw = await fs.readFile(this.stateFile, "utf8");
44
+ const parsed = JSON.parse(raw);
45
+ // A plain JSON object `{ [key]: QueuedMessage }` — NOT an array.
46
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
47
+ for (const [k, v] of Object.entries(parsed)) {
48
+ if (v &&
49
+ typeof v === "object" &&
50
+ typeof v.text === "string" &&
51
+ typeof v.createdAtMs === "number") {
52
+ map.set(k, v);
53
+ }
54
+ }
55
+ }
56
+ }
57
+ catch {
58
+ /* missing or unreadable — start empty */
59
+ }
60
+ this.state = map;
61
+ return this.state;
62
+ }
63
+ /**
64
+ * Retrieve a queued message for a chat, or `null` if none / unavailable.
65
+ * Non-throwing: any load error reads as null.
66
+ */
67
+ async get(agent, sessionId) {
68
+ const map = await this.ensureLoaded().catch(() => new Map());
69
+ return map.get(keyOf(agent, sessionId)) ?? null;
70
+ }
71
+ /**
72
+ * Atomically read AND remove a chat's queued message (#245). Returns the entry
73
+ * (or null if none). The read+delete happen without an intervening `await`, so
74
+ * two concurrent drains — e.g. a turn-completion drain and an idle `set_queue`
75
+ * drain — can never both observe the same message and double-send it: whichever
76
+ * `take()` runs first gets it, the other gets null. Non-throwing.
77
+ */
78
+ async take(agent, sessionId) {
79
+ const map = await this.ensureLoaded().catch(() => null);
80
+ if (!map)
81
+ return null;
82
+ const key = keyOf(agent, sessionId);
83
+ const v = map.get(key);
84
+ if (!v)
85
+ return null;
86
+ map.delete(key);
87
+ await this.persist(map);
88
+ return v;
89
+ }
90
+ /**
91
+ * Store (or clear, when `message` is null) a queued message for a chat.
92
+ * Setting `null` deletes the key, keeping the file compact.
93
+ */
94
+ async set(agent, sessionId, message) {
95
+ const map = await this.ensureLoaded();
96
+ const key = keyOf(agent, sessionId);
97
+ if (message === null) {
98
+ map.delete(key);
99
+ }
100
+ else {
101
+ map.set(key, message);
102
+ }
103
+ await this.persist(map);
104
+ }
105
+ /** Write-through, serialised so overlapping writes can't corrupt the file. */
106
+ persist(map) {
107
+ this.writing = this.writing.then(async () => {
108
+ const obj = {};
109
+ for (const [k, v] of map)
110
+ obj[k] = v;
111
+ const json = JSON.stringify(obj, null, 2);
112
+ await fs.writeFile(this.stateFile, json, "utf8").catch(() => undefined);
113
+ });
114
+ return this.writing;
115
+ }
116
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Compose the storage key for a (user, agent, session) triple. A null/empty
3
+ * username selects the SHARED bucket (no user segment) — so `none`-mode
4
+ * deployments get cross-device continuity for free without a per-user segment,
5
+ * and can never collide with a real user's entry.
6
+ */
7
+ export declare function keyOf(username: string | null, agent: string, sessionId: string): string;
8
+ export declare class ReadStateStore {
9
+ private readonly stateFile;
10
+ /** In-memory map of key -> lastSeenMs (loaded once, written through on change). */
11
+ private state;
12
+ /**
13
+ * The in-flight load, cached so concurrent first-callers share ONE read.
14
+ * Caching only the resolved `state` would let several marks that begin before
15
+ * the first `fs.readFile` resolves each build their own map and race on the
16
+ * assignment — a lost update (last writer's map wins, dropping the others'
17
+ * keys).
18
+ */
19
+ private loadPromise;
20
+ /** Serialises concurrent writes so the file never interleaves. */
21
+ private writing;
22
+ constructor(dataDir: string);
23
+ /** Load the persisted map (lazily, deduped; tolerant of a missing/corrupt file). */
24
+ private ensureLoaded;
25
+ /**
26
+ * The epoch-ms moment this user last viewed this chat, or 0 if never seen /
27
+ * unavailable. Non-throwing: any load error reads as 0. Pass `null` for
28
+ * `username` to read the shared bucket.
29
+ */
30
+ getLastSeen(username: string | null, agent: string, sessionId: string): Promise<number>;
31
+ /**
32
+ * Advance this chat's last-seen time to `whenMs` (monotonic: an older or equal
33
+ * timestamp is a no-op, avoiding a needless write and never moving read-state
34
+ * backwards). Pass `null` for `username` to write the shared bucket.
35
+ */
36
+ setLastSeen(username: string | null, agent: string, sessionId: string, whenMs: number): Promise<void>;
37
+ /**
38
+ * Advance MANY chats' last-seen times to `whenMs` in ONE write (#508) — the
39
+ * subtree "mark read" action. Per-key monotonic exactly like
40
+ * {@link setLastSeen}, so a chat already seen later than `whenMs` is skipped
41
+ * rather than moved backwards. Returns the ids that actually advanced.
42
+ */
43
+ setManyLastSeen(username: string | null, agent: string, sessionIds: readonly string[], whenMs: number): Promise<string[]>;
44
+ /** Write-through, serialised so overlapping marks can't corrupt the file. */
45
+ private persist;
46
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * ReadStateStore — a Paddock-side sidecar for per-chat "last seen" (read-state),
3
+ * moving the unread affordance (#160/#161) off the browser's localStorage and
4
+ * onto the server so it follows a user ACROSS DEVICES (#189).
5
+ *
6
+ * This clones the ArchiveStore pattern exactly — a lightweight, write-through,
7
+ * serialised, corruption-tolerant JSON sidecar in the data dir — but stores a
8
+ * `key -> lastSeenMs` MAP (persisted as a plain JSON object) rather than a set,
9
+ * since read-state is a timestamp per chat, not a boolean flag.
10
+ *
11
+ * Keying — by user WHEN PRESENT, else a single shared bucket:
12
+ * - Real identity (trusted-header / jwt): `username \0 agent \0 sessionId`
13
+ * - No user (`none` mode / anonymous): `agent \0 sessionId` (shared)
14
+ * NUL-separated, same rationale as ArchiveStore: a NUL can occur in neither a
15
+ * username, an agent name (`keeper-<slug>`), nor a UUID, so keys
16
+ * can't collide — and a user-keyed entry can never alias the shared bucket.
17
+ *
18
+ * `setLastSeen` is MONOTONIC (only ever advances a chat's last-seen time): the
19
+ * marker means "the most recent moment the user viewed this chat", so an older
20
+ * timestamp is a no-op and never moves read-state backwards.
21
+ *
22
+ * The file is written `0o600` (like github-auth.json) — read-state is
23
+ * low-sensitivity (which chats you viewed + when), but there's no reason for it
24
+ * to be world-readable.
25
+ */
26
+ import { promises as fs } from "node:fs";
27
+ import path from "node:path";
28
+ const STATE_FILE = "read-state.json";
29
+ /** Separator in a storage key; a NUL can't occur in a username, agent, or UUID. */
30
+ const KEY_SEP = "\u0000";
31
+ /**
32
+ * Compose the storage key for a (user, agent, session) triple. A null/empty
33
+ * username selects the SHARED bucket (no user segment) — so `none`-mode
34
+ * deployments get cross-device continuity for free without a per-user segment,
35
+ * and can never collide with a real user's entry.
36
+ */
37
+ export function keyOf(username, agent, sessionId) {
38
+ return username
39
+ ? `${username}${KEY_SEP}${agent}${KEY_SEP}${sessionId}`
40
+ : `${agent}${KEY_SEP}${sessionId}`;
41
+ }
42
+ export class ReadStateStore {
43
+ stateFile;
44
+ /** In-memory map of key -> lastSeenMs (loaded once, written through on change). */
45
+ state = null;
46
+ /**
47
+ * The in-flight load, cached so concurrent first-callers share ONE read.
48
+ * Caching only the resolved `state` would let several marks that begin before
49
+ * the first `fs.readFile` resolves each build their own map and race on the
50
+ * assignment — a lost update (last writer's map wins, dropping the others'
51
+ * keys).
52
+ */
53
+ loadPromise = null;
54
+ /** Serialises concurrent writes so the file never interleaves. */
55
+ writing = Promise.resolve();
56
+ constructor(dataDir) {
57
+ this.stateFile = path.join(dataDir, STATE_FILE);
58
+ }
59
+ /** Load the persisted map (lazily, deduped; tolerant of a missing/corrupt file). */
60
+ ensureLoaded() {
61
+ if (this.state)
62
+ return Promise.resolve(this.state);
63
+ if (!this.loadPromise) {
64
+ this.loadPromise = (async () => {
65
+ const map = new Map();
66
+ try {
67
+ const raw = await fs.readFile(this.stateFile, "utf8");
68
+ const parsed = JSON.parse(raw);
69
+ // A plain JSON object `{ [key]: number }` — NOT an array (unlike Archive).
70
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
71
+ for (const [k, v] of Object.entries(parsed)) {
72
+ if (typeof v === "number" && Number.isFinite(v))
73
+ map.set(k, v);
74
+ }
75
+ }
76
+ }
77
+ catch {
78
+ /* missing or unreadable — start empty */
79
+ }
80
+ this.state = map;
81
+ return map;
82
+ })();
83
+ }
84
+ return this.loadPromise;
85
+ }
86
+ /**
87
+ * The epoch-ms moment this user last viewed this chat, or 0 if never seen /
88
+ * unavailable. Non-throwing: any load error reads as 0. Pass `null` for
89
+ * `username` to read the shared bucket.
90
+ */
91
+ async getLastSeen(username, agent, sessionId) {
92
+ const map = await this.ensureLoaded().catch(() => new Map());
93
+ return map.get(keyOf(username, agent, sessionId)) ?? 0;
94
+ }
95
+ /**
96
+ * Advance this chat's last-seen time to `whenMs` (monotonic: an older or equal
97
+ * timestamp is a no-op, avoiding a needless write and never moving read-state
98
+ * backwards). Pass `null` for `username` to write the shared bucket.
99
+ */
100
+ async setLastSeen(username, agent, sessionId, whenMs) {
101
+ const map = await this.ensureLoaded();
102
+ const key = keyOf(username, agent, sessionId);
103
+ if (whenMs <= (map.get(key) ?? 0))
104
+ return; // monotonic — no-op
105
+ map.set(key, whenMs);
106
+ await this.persist(map);
107
+ }
108
+ /**
109
+ * Advance MANY chats' last-seen times to `whenMs` in ONE write (#508) — the
110
+ * subtree "mark read" action. Per-key monotonic exactly like
111
+ * {@link setLastSeen}, so a chat already seen later than `whenMs` is skipped
112
+ * rather than moved backwards. Returns the ids that actually advanced.
113
+ */
114
+ async setManyLastSeen(username, agent, sessionIds, whenMs) {
115
+ const map = await this.ensureLoaded();
116
+ const advanced = [];
117
+ for (const sessionId of sessionIds) {
118
+ const key = keyOf(username, agent, sessionId);
119
+ if (whenMs <= (map.get(key) ?? 0))
120
+ continue; // monotonic — no-op
121
+ map.set(key, whenMs);
122
+ advanced.push(sessionId);
123
+ }
124
+ if (advanced.length)
125
+ await this.persist(map);
126
+ return advanced;
127
+ }
128
+ /** Write-through, serialised so overlapping marks can't corrupt the file. */
129
+ persist(map) {
130
+ this.writing = this.writing.then(async () => {
131
+ const obj = {};
132
+ for (const [k, v] of map)
133
+ obj[k] = v;
134
+ const json = JSON.stringify(obj, null, 2);
135
+ await fs.writeFile(this.stateFile, json, { encoding: "utf8", mode: 0o600 }).catch(() => undefined);
136
+ });
137
+ return this.writing;
138
+ }
139
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Keeper-chat recovery config — the shared foundation for issue #301 (Phase 0).
3
+ *
4
+ * ── The problem (see edspencer/herdctl#374) ──────────────────────────────────
5
+ * When a keeper launches a BACKGROUND task (background `Bash`, or a background
6
+ * `Task`/`Agent`) and ends its turn while that task is still running, herdctl
7
+ * correctly keeps the session ALIVE at the turn boundary (it sees the running
8
+ * task in the Stop snapshot and does not reap). But the SDK/native binary kills
9
+ * the still-running background child ~2–8s later anyway. A COMPLETED task emits a
10
+ * re-invocation turn and the keeper continues; a KILLED task emits no wake at all
11
+ * — the `killed`/`stopped` `<task-notification>` is written to the transcript but
12
+ * nothing consumes it. The keeper is left alive-but-idle-forever, recovering only
13
+ * when a human sends the next message. Because the session stays injectable, the
14
+ * whole class of workarounds is "automate (or one-click) the nudge a human sends."
15
+ *
16
+ * ── The two configurable layers ──────────────────────────────────────────────
17
+ * This ticket adds an app-side recovery mechanism in two independently-toggleable
18
+ * layers, each resolved here:
19
+ *
20
+ * Layer 2 ({@link RecoveryConfig.surfaceKilledTask}, default ON) — pure
21
+ * visibility + a manual one-click "Continue". Low-risk, so on by default.
22
+ * Layer 3 ({@link RecoveryConfig.autoReDrive}, default OFF) — automatic
23
+ * detection + re-drive of a hung keeper. Costs a turn/tokens and acts on its
24
+ * own, so opt-in. The DETECTION/INJECT ENGINE is a FOLLOW-UP chat; this
25
+ * module only defines + resolves the flag so the config surface exists.
26
+ *
27
+ * ── Config discipline (the `driveMode`/`maxSpawnDepth` pattern) ───────────────
28
+ * Every field is an instance default (`PADDOCK_RECOVERY_*` env, YAML instance
29
+ * file beneath it) with an optional PER-PROJECT override (`project.yaml` →
30
+ * {@link import("./projects.js").ProjectYaml.recovery}). An absent/invalid
31
+ * override inherits the instance default, resolved at dispatch by
32
+ * {@link resolveRecoveryConfig} — never baked into the DTO (mirrors how
33
+ * `driveMode` resolves against `cfg.driveMode` and `maxSpawnDepth` via
34
+ * `resolveMaxSpawnDepth`). A malformed override is ignored rather than fatal so a
35
+ * hand-edited `project.yaml` can't wedge dispatch.
36
+ */
37
+ /**
38
+ * Resolved recovery config — all fields concrete. Held on {@link
39
+ * import("./config.js").PaddockConfig.recovery} (instance defaults) and produced
40
+ * per-dispatch by {@link resolveRecoveryConfig} (project override else instance).
41
+ */
42
+ export interface RecoveryConfig {
43
+ /**
44
+ * Layer 2 — render the killed/stopped `<task-notification>` as a distinct
45
+ * "keeper is idle" affordance with a one-click Continue button, instead of
46
+ * silently hiding it. Default ON (pure visibility + a manual button is
47
+ * low-risk). Env `PADDOCK_RECOVERY_SURFACE`.
48
+ */
49
+ surfaceKilledTask: boolean;
50
+ /**
51
+ * Layer 3 — automatically re-drive a keeper whose background task was killed at
52
+ * the turn boundary (detect + inject a nudge so it wakes on its own). Default
53
+ * OFF (auto-acts and costs a turn/tokens → opt-in). Env
54
+ * `PADDOCK_RECOVERY_AUTODRIVE`. NOTE: this chat only plumbs the flag; the
55
+ * detection/inject engine lands in a follow-up.
56
+ */
57
+ autoReDrive: boolean;
58
+ /**
59
+ * Layer 3 guard — minimum ms of quiet after a killed-task notification before
60
+ * the auto re-drive fires, so a genuinely-finishing keeper isn't poked. Default
61
+ * 5000. Env `PADDOCK_RECOVERY_DEBOUNCE_MS`.
62
+ */
63
+ debounceMs: number;
64
+ /**
65
+ * Layer 3 guard — how many times a single hung session may be auto re-driven
66
+ * before giving up, so a wedged keeper isn't poked in a loop. Default 1. Env
67
+ * `PADDOCK_RECOVERY_MAX_RETRIES`.
68
+ */
69
+ maxRetries: number;
70
+ /**
71
+ * Layer 2 backstop — if a kept-alive keeper session shows no activity for this
72
+ * many ms after a killed-task notification, surface it as stuck. `0` disables
73
+ * the backstop (the default). Env `PADDOCK_RECOVERY_LIMBO_MS`.
74
+ */
75
+ limboTimeoutMs: number;
76
+ }
77
+ /**
78
+ * A per-project recovery override as stored in `project.yaml` — every field
79
+ * optional (an absent field inherits the instance default at dispatch).
80
+ */
81
+ export type RecoveryOverride = Partial<RecoveryConfig>;
82
+ /**
83
+ * The built-in recovery defaults (beneath env + YAML + per-project override).
84
+ * Layer 2 ON, Layer 3 OFF, guards at their documented values.
85
+ */
86
+ export declare const DEFAULT_RECOVERY: RecoveryConfig;
87
+ /**
88
+ * True when a `<task-notification>` `<status>` value denotes a task that was
89
+ * TERMINATED (killed/stopped) — the turn-boundary-kill case #301 recovers from —
90
+ * rather than a clean `completed` (or a still-`running`/`timed out` Monitor).
91
+ * Case-insensitive; a missing/blank/other status is not terminated.
92
+ */
93
+ export declare function isTerminatedTaskStatus(status: string | null | undefined): boolean;
94
+ /**
95
+ * Validate + normalise an untrusted value (from `project.yaml` or a PATCH body)
96
+ * into a {@link RecoveryOverride}, dropping any field that is missing or invalid,
97
+ * and returning `undefined` when nothing valid remains (so an empty override is
98
+ * never persisted). Booleans must be real booleans; the numeric knobs must be
99
+ * non-negative integers. Defensive by design — a malformed hand-edit degrades to
100
+ * "inherit the instance default", never a startup/dispatch crash.
101
+ */
102
+ export declare function sanitizeRecoveryOverride(value: unknown): RecoveryOverride | undefined;
103
+ /**
104
+ * Resolve the effective recovery config for a dispatch: a valid per-project
105
+ * override wins field-by-field; every absent field inherits the instance default.
106
+ * Mirrors how `driveMode`/`maxSpawnDepth` resolve — the override is carried on
107
+ * disk only for the fields explicitly set, so an absent value transparently
108
+ * inherits the instance (env/YAML) default. The override is re-sanitised so a
109
+ * corrupt on-disk value can't leak through.
110
+ */
111
+ export declare function resolveRecoveryConfig(override: RecoveryOverride | undefined, instanceDefault: RecoveryConfig): RecoveryConfig;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Keeper-chat recovery config — the shared foundation for issue #301 (Phase 0).
3
+ *
4
+ * ── The problem (see edspencer/herdctl#374) ──────────────────────────────────
5
+ * When a keeper launches a BACKGROUND task (background `Bash`, or a background
6
+ * `Task`/`Agent`) and ends its turn while that task is still running, herdctl
7
+ * correctly keeps the session ALIVE at the turn boundary (it sees the running
8
+ * task in the Stop snapshot and does not reap). But the SDK/native binary kills
9
+ * the still-running background child ~2–8s later anyway. A COMPLETED task emits a
10
+ * re-invocation turn and the keeper continues; a KILLED task emits no wake at all
11
+ * — the `killed`/`stopped` `<task-notification>` is written to the transcript but
12
+ * nothing consumes it. The keeper is left alive-but-idle-forever, recovering only
13
+ * when a human sends the next message. Because the session stays injectable, the
14
+ * whole class of workarounds is "automate (or one-click) the nudge a human sends."
15
+ *
16
+ * ── The two configurable layers ──────────────────────────────────────────────
17
+ * This ticket adds an app-side recovery mechanism in two independently-toggleable
18
+ * layers, each resolved here:
19
+ *
20
+ * Layer 2 ({@link RecoveryConfig.surfaceKilledTask}, default ON) — pure
21
+ * visibility + a manual one-click "Continue". Low-risk, so on by default.
22
+ * Layer 3 ({@link RecoveryConfig.autoReDrive}, default OFF) — automatic
23
+ * detection + re-drive of a hung keeper. Costs a turn/tokens and acts on its
24
+ * own, so opt-in. The DETECTION/INJECT ENGINE is a FOLLOW-UP chat; this
25
+ * module only defines + resolves the flag so the config surface exists.
26
+ *
27
+ * ── Config discipline (the `driveMode`/`maxSpawnDepth` pattern) ───────────────
28
+ * Every field is an instance default (`PADDOCK_RECOVERY_*` env, YAML instance
29
+ * file beneath it) with an optional PER-PROJECT override (`project.yaml` →
30
+ * {@link import("./projects.js").ProjectYaml.recovery}). An absent/invalid
31
+ * override inherits the instance default, resolved at dispatch by
32
+ * {@link resolveRecoveryConfig} — never baked into the DTO (mirrors how
33
+ * `driveMode` resolves against `cfg.driveMode` and `maxSpawnDepth` via
34
+ * `resolveMaxSpawnDepth`). A malformed override is ignored rather than fatal so a
35
+ * hand-edited `project.yaml` can't wedge dispatch.
36
+ */
37
+ /**
38
+ * The built-in recovery defaults (beneath env + YAML + per-project override).
39
+ * Layer 2 ON, Layer 3 OFF, guards at their documented values.
40
+ */
41
+ export const DEFAULT_RECOVERY = Object.freeze({
42
+ surfaceKilledTask: true,
43
+ autoReDrive: false,
44
+ debounceMs: 5000,
45
+ maxRetries: 1,
46
+ limboTimeoutMs: 0,
47
+ });
48
+ /** The `<status>` values a killed-at-turn-boundary task notification carries. */
49
+ const TERMINATED_STATUSES = new Set(["killed", "stopped"]);
50
+ /**
51
+ * True when a `<task-notification>` `<status>` value denotes a task that was
52
+ * TERMINATED (killed/stopped) — the turn-boundary-kill case #301 recovers from —
53
+ * rather than a clean `completed` (or a still-`running`/`timed out` Monitor).
54
+ * Case-insensitive; a missing/blank/other status is not terminated.
55
+ */
56
+ export function isTerminatedTaskStatus(status) {
57
+ return typeof status === "string" && TERMINATED_STATUSES.has(status.trim().toLowerCase());
58
+ }
59
+ /** True when `n` is a valid non-negative-integer ms/count knob. */
60
+ function isNonNegativeInt(n) {
61
+ return typeof n === "number" && Number.isInteger(n) && n >= 0 && Number.isFinite(n);
62
+ }
63
+ /**
64
+ * Validate + normalise an untrusted value (from `project.yaml` or a PATCH body)
65
+ * into a {@link RecoveryOverride}, dropping any field that is missing or invalid,
66
+ * and returning `undefined` when nothing valid remains (so an empty override is
67
+ * never persisted). Booleans must be real booleans; the numeric knobs must be
68
+ * non-negative integers. Defensive by design — a malformed hand-edit degrades to
69
+ * "inherit the instance default", never a startup/dispatch crash.
70
+ */
71
+ export function sanitizeRecoveryOverride(value) {
72
+ if (!value || typeof value !== "object" || Array.isArray(value))
73
+ return undefined;
74
+ const o = value;
75
+ const out = {};
76
+ if (typeof o.surfaceKilledTask === "boolean")
77
+ out.surfaceKilledTask = o.surfaceKilledTask;
78
+ if (typeof o.autoReDrive === "boolean")
79
+ out.autoReDrive = o.autoReDrive;
80
+ if (isNonNegativeInt(o.debounceMs))
81
+ out.debounceMs = o.debounceMs;
82
+ if (isNonNegativeInt(o.maxRetries))
83
+ out.maxRetries = o.maxRetries;
84
+ if (isNonNegativeInt(o.limboTimeoutMs))
85
+ out.limboTimeoutMs = o.limboTimeoutMs;
86
+ return Object.keys(out).length > 0 ? out : undefined;
87
+ }
88
+ /**
89
+ * Resolve the effective recovery config for a dispatch: a valid per-project
90
+ * override wins field-by-field; every absent field inherits the instance default.
91
+ * Mirrors how `driveMode`/`maxSpawnDepth` resolve — the override is carried on
92
+ * disk only for the fields explicitly set, so an absent value transparently
93
+ * inherits the instance (env/YAML) default. The override is re-sanitised so a
94
+ * corrupt on-disk value can't leak through.
95
+ */
96
+ export function resolveRecoveryConfig(override, instanceDefault) {
97
+ const clean = sanitizeRecoveryOverride(override) ?? {};
98
+ return {
99
+ surfaceKilledTask: clean.surfaceKilledTask ?? instanceDefault.surfaceKilledTask,
100
+ autoReDrive: clean.autoReDrive ?? instanceDefault.autoReDrive,
101
+ debounceMs: clean.debounceMs ?? instanceDefault.debounceMs,
102
+ maxRetries: clean.maxRetries ?? instanceDefault.maxRetries,
103
+ limboTimeoutMs: clean.limboTimeoutMs ?? instanceDefault.limboTimeoutMs,
104
+ };
105
+ }