@edspencer/paddock 0.0.1 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (296) hide show
  1. package/README.md +69 -0
  2. package/install-notice.mjs +29 -0
  3. package/package.json +58 -5
  4. package/packages/server/dist/adoptable.d.ts +127 -0
  5. package/packages/server/dist/adoptable.js +315 -0
  6. package/packages/server/dist/app.d.ts +67 -0
  7. package/packages/server/dist/app.js +325 -0
  8. package/packages/server/dist/archive.d.ts +42 -0
  9. package/packages/server/dist/archive.js +125 -0
  10. package/packages/server/dist/attachments-config.d.ts +114 -0
  11. package/packages/server/dist/attachments-config.js +165 -0
  12. package/packages/server/dist/attachments-hint.d.ts +53 -0
  13. package/packages/server/dist/attachments-hint.js +140 -0
  14. package/packages/server/dist/attachments.d.ts +33 -0
  15. package/packages/server/dist/attachments.js +117 -0
  16. package/packages/server/dist/auth.d.ts +84 -0
  17. package/packages/server/dist/auth.js +247 -0
  18. package/packages/server/dist/background.d.ts +36 -0
  19. package/packages/server/dist/background.js +137 -0
  20. package/packages/server/dist/bind-safety.d.ts +56 -0
  21. package/packages/server/dist/bind-safety.js +51 -0
  22. package/packages/server/dist/brand.d.ts +44 -0
  23. package/packages/server/dist/brand.js +88 -0
  24. package/packages/server/dist/chat-dto.d.ts +210 -0
  25. package/packages/server/dist/chat-dto.js +308 -0
  26. package/packages/server/dist/cli/import-chats.d.ts +21 -0
  27. package/packages/server/dist/cli/import-chats.js +197 -0
  28. package/packages/server/dist/cli/paddock.d.ts +2 -0
  29. package/packages/server/dist/cli/paddock.js +247 -0
  30. package/packages/server/dist/concurrency.d.ts +57 -0
  31. package/packages/server/dist/concurrency.js +71 -0
  32. package/packages/server/dist/config.d.ts +538 -0
  33. package/packages/server/dist/config.js +625 -0
  34. package/packages/server/dist/curation-config.d.ts +55 -0
  35. package/packages/server/dist/curation-config.js +64 -0
  36. package/packages/server/dist/environment-prompt.d.ts +52 -0
  37. package/packages/server/dist/environment-prompt.js +61 -0
  38. package/packages/server/dist/event-bus.d.ts +87 -0
  39. package/packages/server/dist/event-bus.js +48 -0
  40. package/packages/server/dist/git.d.ts +128 -0
  41. package/packages/server/dist/git.js +446 -0
  42. package/packages/server/dist/github-auth.d.ts +44 -0
  43. package/packages/server/dist/github-auth.js +169 -0
  44. package/packages/server/dist/herdctl-agent-config.d.ts +98 -0
  45. package/packages/server/dist/herdctl-agent-config.js +331 -0
  46. package/packages/server/dist/herdctl-agent-names.d.ts +172 -0
  47. package/packages/server/dist/herdctl-agent-names.js +228 -0
  48. package/packages/server/dist/herdctl-jobs-index.d.ts +65 -0
  49. package/packages/server/dist/herdctl-jobs-index.js +225 -0
  50. package/packages/server/dist/herdctl-jobs.d.ts +98 -0
  51. package/packages/server/dist/herdctl-jobs.js +225 -0
  52. package/packages/server/dist/herdctl.d.ts +623 -0
  53. package/packages/server/dist/herdctl.js +1279 -0
  54. package/packages/server/dist/hook-config.d.ts +217 -0
  55. package/packages/server/dist/hook-config.js +224 -0
  56. package/packages/server/dist/http-bytes.d.ts +24 -0
  57. package/packages/server/dist/http-bytes.js +52 -0
  58. package/packages/server/dist/index.d.ts +1 -0
  59. package/packages/server/dist/index.js +14 -0
  60. package/packages/server/dist/instance-config.d.ts +143 -0
  61. package/packages/server/dist/instance-config.js +385 -0
  62. package/packages/server/dist/localcommand.d.ts +23 -0
  63. package/packages/server/dist/localcommand.js +140 -0
  64. package/packages/server/dist/management-auth.d.ts +99 -0
  65. package/packages/server/dist/management-auth.js +165 -0
  66. package/packages/server/dist/management-config.d.ts +164 -0
  67. package/packages/server/dist/management-config.js +286 -0
  68. package/packages/server/dist/management-mcp-server.d.ts +20 -0
  69. package/packages/server/dist/management-mcp-server.js +95 -0
  70. package/packages/server/dist/management-metadata.d.ts +67 -0
  71. package/packages/server/dist/management-metadata.js +56 -0
  72. package/packages/server/dist/management-ops.d.ts +110 -0
  73. package/packages/server/dist/management-ops.js +449 -0
  74. package/packages/server/dist/management-policy.d.ts +172 -0
  75. package/packages/server/dist/management-policy.js +259 -0
  76. package/packages/server/dist/message-provenance.d.ts +112 -0
  77. package/packages/server/dist/message-provenance.js +260 -0
  78. package/packages/server/dist/models.d.ts +164 -0
  79. package/packages/server/dist/models.js +208 -0
  80. package/packages/server/dist/openapi.d.ts +66 -0
  81. package/packages/server/dist/openapi.js +183 -0
  82. package/packages/server/dist/parent-detach.d.ts +30 -0
  83. package/packages/server/dist/parent-detach.js +109 -0
  84. package/packages/server/dist/preload.d.ts +43 -0
  85. package/packages/server/dist/preload.js +55 -0
  86. package/packages/server/dist/project-files.d.ts +76 -0
  87. package/packages/server/dist/project-files.js +191 -0
  88. package/packages/server/dist/project-mime.d.ts +35 -0
  89. package/packages/server/dist/project-mime.js +55 -0
  90. package/packages/server/dist/project-paths.d.ts +88 -0
  91. package/packages/server/dist/project-paths.js +126 -0
  92. package/packages/server/dist/project-types.d.ts +316 -0
  93. package/packages/server/dist/project-types.js +48 -0
  94. package/packages/server/dist/projects.d.ts +202 -0
  95. package/packages/server/dist/projects.js +894 -0
  96. package/packages/server/dist/queued-message.d.ts +35 -0
  97. package/packages/server/dist/queued-message.js +116 -0
  98. package/packages/server/dist/read-state.d.ts +46 -0
  99. package/packages/server/dist/read-state.js +139 -0
  100. package/packages/server/dist/recovery-config.d.ts +111 -0
  101. package/packages/server/dist/recovery-config.js +105 -0
  102. package/packages/server/dist/recovery.d.ts +262 -0
  103. package/packages/server/dist/recovery.js +490 -0
  104. package/packages/server/dist/resume-drain.d.ts +122 -0
  105. package/packages/server/dist/resume-drain.js +158 -0
  106. package/packages/server/dist/route-context.d.ts +168 -0
  107. package/packages/server/dist/route-context.js +39 -0
  108. package/packages/server/dist/route-errors.d.ts +8 -0
  109. package/packages/server/dist/route-errors.js +13 -0
  110. package/packages/server/dist/routes/chats.d.ts +12 -0
  111. package/packages/server/dist/routes/chats.js +1406 -0
  112. package/packages/server/dist/routes/git.d.ts +13 -0
  113. package/packages/server/dist/routes/git.js +236 -0
  114. package/packages/server/dist/routes/mcp.d.ts +83 -0
  115. package/packages/server/dist/routes/mcp.js +250 -0
  116. package/packages/server/dist/routes/meta.d.ts +15 -0
  117. package/packages/server/dist/routes/meta.js +447 -0
  118. package/packages/server/dist/routes/projects.d.ts +25 -0
  119. package/packages/server/dist/routes/projects.js +736 -0
  120. package/packages/server/dist/routes/triggers.d.ts +21 -0
  121. package/packages/server/dist/routes/triggers.js +324 -0
  122. package/packages/server/dist/routes/workspace-mount.d.ts +48 -0
  123. package/packages/server/dist/routes/workspace-mount.js +32 -0
  124. package/packages/server/dist/routes.d.ts +26 -0
  125. package/packages/server/dist/routes.js +29 -0
  126. package/packages/server/dist/run-provenance.d.ts +109 -0
  127. package/packages/server/dist/run-provenance.js +206 -0
  128. package/packages/server/dist/runs.d.ts +92 -0
  129. package/packages/server/dist/runs.js +72 -0
  130. package/packages/server/dist/schedule-config.d.ts +68 -0
  131. package/packages/server/dist/schedule-config.js +75 -0
  132. package/packages/server/dist/schedule-session.d.ts +34 -0
  133. package/packages/server/dist/schedule-session.js +120 -0
  134. package/packages/server/dist/self-mcp-descriptions.d.ts +20 -0
  135. package/packages/server/dist/self-mcp-descriptions.js +145 -0
  136. package/packages/server/dist/self-mcp-read.d.ts +15 -0
  137. package/packages/server/dist/self-mcp-read.js +97 -0
  138. package/packages/server/dist/self-mcp-triggers.d.ts +30 -0
  139. package/packages/server/dist/self-mcp-triggers.js +144 -0
  140. package/packages/server/dist/self-mcp-types.d.ts +248 -0
  141. package/packages/server/dist/self-mcp-types.js +10 -0
  142. package/packages/server/dist/self-mcp-util.d.ts +73 -0
  143. package/packages/server/dist/self-mcp-util.js +157 -0
  144. package/packages/server/dist/self-mcp-write.d.ts +35 -0
  145. package/packages/server/dist/self-mcp-write.js +215 -0
  146. package/packages/server/dist/self-mcp.d.ts +95 -0
  147. package/packages/server/dist/self-mcp.js +448 -0
  148. package/packages/server/dist/send-file-mcp.d.ts +46 -0
  149. package/packages/server/dist/send-file-mcp.js +281 -0
  150. package/packages/server/dist/session-hub.d.ts +186 -0
  151. package/packages/server/dist/session-hub.js +291 -0
  152. package/packages/server/dist/spawn-capability.d.ts +99 -0
  153. package/packages/server/dist/spawn-capability.js +106 -0
  154. package/packages/server/dist/star.d.ts +26 -0
  155. package/packages/server/dist/star.js +94 -0
  156. package/packages/server/dist/start.d.ts +1 -0
  157. package/packages/server/dist/start.js +21 -0
  158. package/packages/server/dist/subagents.d.ts +337 -0
  159. package/packages/server/dist/subagents.js +626 -0
  160. package/packages/server/dist/sweep.d.ts +122 -0
  161. package/packages/server/dist/sweep.js +696 -0
  162. package/packages/server/dist/tooldetails.d.ts +33 -0
  163. package/packages/server/dist/tooldetails.js +466 -0
  164. package/packages/server/dist/transcribe.d.ts +69 -0
  165. package/packages/server/dist/transcribe.js +119 -0
  166. package/packages/server/dist/transcripts.d.ts +42 -0
  167. package/packages/server/dist/transcripts.js +164 -0
  168. package/packages/server/dist/trigger-config.d.ts +278 -0
  169. package/packages/server/dist/trigger-config.js +417 -0
  170. package/packages/server/dist/trigger-runtime.d.ts +94 -0
  171. package/packages/server/dist/trigger-runtime.js +82 -0
  172. package/packages/server/dist/trigger-session.d.ts +34 -0
  173. package/packages/server/dist/trigger-session.js +122 -0
  174. package/packages/server/dist/triggers.d.ts +83 -0
  175. package/packages/server/dist/triggers.js +124 -0
  176. package/packages/server/dist/trusted-proxy.d.ts +57 -0
  177. package/packages/server/dist/trusted-proxy.js +157 -0
  178. package/packages/server/dist/turn-notice.d.ts +154 -0
  179. package/packages/server/dist/turn-notice.js +271 -0
  180. package/packages/server/dist/unread.d.ts +36 -0
  181. package/packages/server/dist/unread.js +123 -0
  182. package/packages/server/dist/usage.d.ts +66 -0
  183. package/packages/server/dist/usage.js +243 -0
  184. package/packages/server/dist/wake-injection.d.ts +156 -0
  185. package/packages/server/dist/wake-injection.js +136 -0
  186. package/packages/server/dist/whisper-local.d.ts +9 -0
  187. package/packages/server/dist/whisper-local.js +122 -0
  188. package/packages/server/dist/ws-context.d.ts +158 -0
  189. package/packages/server/dist/ws-context.js +1 -0
  190. package/packages/server/dist/ws-protocol.d.ts +423 -0
  191. package/packages/server/dist/ws-protocol.js +251 -0
  192. package/packages/server/dist/ws-self-mcp.d.ts +52 -0
  193. package/packages/server/dist/ws-self-mcp.js +28 -0
  194. package/packages/server/dist/ws-triggers.d.ts +29 -0
  195. package/packages/server/dist/ws-triggers.js +261 -0
  196. package/packages/server/dist/ws-turn.d.ts +86 -0
  197. package/packages/server/dist/ws-turn.js +751 -0
  198. package/packages/server/dist/ws.d.ts +62 -0
  199. package/packages/server/dist/ws.js +1024 -0
  200. package/packages/server/package.json +6 -0
  201. package/packages/web/dist/assets/InstanceConfigPage-Bypd_quL.js +2 -0
  202. package/packages/web/dist/assets/MarkdownRenderer-Co8w9iuX.js +30 -0
  203. package/packages/web/dist/assets/ProjectRedirect-DXrA24Cy.js +2 -0
  204. package/packages/web/dist/assets/ProjectView-B9opP7Sv.js +13 -0
  205. package/packages/web/dist/assets/ProjectsGrid-D2cwb2jY.js +2 -0
  206. package/packages/web/dist/assets/arc-B--FIRB8.js +2 -0
  207. package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-B6dRWZZ6.js +37 -0
  208. package/packages/web/dist/assets/bash-I8pq0VWm.js +2 -0
  209. package/packages/web/dist/assets/blockDiagram-GPEHLZMM-DLxFAvvf.js +133 -0
  210. package/packages/web/dist/assets/c-D2F1sQUL.js +2 -0
  211. package/packages/web/dist/assets/c4Diagram-AAUBKEIU-CNee5ks4.js +11 -0
  212. package/packages/web/dist/assets/channel-Bw0A52CB.js +2 -0
  213. package/packages/web/dist/assets/chunk-2J33WTMH-DObw03Xl.js +2 -0
  214. package/packages/web/dist/assets/chunk-4BX2VUAB-CH2SR6nN.js +2 -0
  215. package/packages/web/dist/assets/chunk-55IACEB6-WlVpegT3.js +2 -0
  216. package/packages/web/dist/assets/chunk-727SXJPM-g8GT9Rgd.js +207 -0
  217. package/packages/web/dist/assets/chunk-AQP2D5EJ-DbN4PsDF.js +232 -0
  218. package/packages/web/dist/assets/chunk-FMBD7UC4-DSN3BjFG.js +16 -0
  219. package/packages/web/dist/assets/chunk-ND2GUHAM-D9R4le0z.js +2 -0
  220. package/packages/web/dist/assets/chunk-QZHKN3VN-D4YgRaKH.js +2 -0
  221. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-itf_kt-q.js +2 -0
  222. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-itf_kt-q.js +2 -0
  223. package/packages/web/dist/assets/core-CysJ2gLj.js +4 -0
  224. package/packages/web/dist/assets/cose-bilkent-S5V4N54A-D1PB0KNu.js +2 -0
  225. package/packages/web/dist/assets/cpp-Bm3ii6kY.js +2 -0
  226. package/packages/web/dist/assets/csharp-BNpuYZMG.js +2 -0
  227. package/packages/web/dist/assets/css-DazXZka4.js +2 -0
  228. package/packages/web/dist/assets/cytoscape.esm-CUqq0XTU.js +332 -0
  229. package/packages/web/dist/assets/dagre-BM42HDAG-C6Uuq-dq.js +5 -0
  230. package/packages/web/dist/assets/defaultLocale-DX6XiGOO.js +2 -0
  231. package/packages/web/dist/assets/diagram-2AECGRRQ-C9JvQ425.js +44 -0
  232. package/packages/web/dist/assets/diagram-5GNKFQAL-Cu6pdh2d.js +11 -0
  233. package/packages/web/dist/assets/diagram-KO2AKTUF-PmXcUQhY.js +4 -0
  234. package/packages/web/dist/assets/diagram-LMA3HP47-9ww6_9Iq.js +25 -0
  235. package/packages/web/dist/assets/diagram-OG6HWLK6-DWQVjv_n.js +25 -0
  236. package/packages/web/dist/assets/erDiagram-TEJ5UH35-6F7xTuff.js +86 -0
  237. package/packages/web/dist/assets/flowDiagram-I6XJVG4X-SCiGu7m6.js +163 -0
  238. package/packages/web/dist/assets/format-yf7Vf9kR.js +2 -0
  239. package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BNpPwxkL.js +293 -0
  240. package/packages/web/dist/assets/gitGraphDiagram-PVQCEYII-C0gyAJjW.js +107 -0
  241. package/packages/web/dist/assets/go-BPx_y7j8.js +2 -0
  242. package/packages/web/dist/assets/graph-BlwPajkw.js +2 -0
  243. package/packages/web/dist/assets/index-B-YuzkF6.js +71 -0
  244. package/packages/web/dist/assets/index-CvGiQddO.css +1 -0
  245. package/packages/web/dist/assets/infoDiagram-5YYISTIA-CJ2CFPfy.js +3 -0
  246. package/packages/web/dist/assets/ini-B3BVgEsG.js +2 -0
  247. package/packages/web/dist/assets/init-Gi6I4Gst.js +2 -0
  248. package/packages/web/dist/assets/ishikawaDiagram-YF4QCWOH-Cs0D32Ry.js +71 -0
  249. package/packages/web/dist/assets/java-B5VKqtH2.js +2 -0
  250. package/packages/web/dist/assets/javascript-BKRaQes9.js +2 -0
  251. package/packages/web/dist/assets/journeyDiagram-JHISSGLW-Bc9fuI2i.js +140 -0
  252. package/packages/web/dist/assets/json-DIYVocXf.js +2 -0
  253. package/packages/web/dist/assets/kanban-definition-UN3LZRKU-D_tC7QfC.js +90 -0
  254. package/packages/web/dist/assets/katex-C5jXJg4s.js +258 -0
  255. package/packages/web/dist/assets/kotlin-Cw-eF_bm.js +3 -0
  256. package/packages/web/dist/assets/lastTab-CU1zsF0j.js +2 -0
  257. package/packages/web/dist/assets/layout-Boy8vhSP.js +2 -0
  258. package/packages/web/dist/assets/linear-BzGHkF9K.js +2 -0
  259. package/packages/web/dist/assets/mermaid.core-DlbsYzE-.js +310 -0
  260. package/packages/web/dist/assets/mindmap-definition-RKZ34NQL-Bxeh-ToD.js +97 -0
  261. package/packages/web/dist/assets/ordinal-Cboi1Yqb.js +2 -0
  262. package/packages/web/dist/assets/php-CRdd4dfS.js +3 -0
  263. package/packages/web/dist/assets/pieDiagram-4H26LBE5-CDmilo8S.js +31 -0
  264. package/packages/web/dist/assets/python-kSlZHhJb.js +2 -0
  265. package/packages/web/dist/assets/quadrantDiagram-W4KKPZXB-D14-JyE9.js +8 -0
  266. package/packages/web/dist/assets/queued-LamEU0GH.js +2 -0
  267. package/packages/web/dist/assets/requirementDiagram-4Y6WPE33-CJ1PUGu-.js +85 -0
  268. package/packages/web/dist/assets/ruby-7T4ktxyv.js +2 -0
  269. package/packages/web/dist/assets/rust-HC3MBUhY.js +2 -0
  270. package/packages/web/dist/assets/sankeyDiagram-5OEKKPKP-BkUK73W0.js +41 -0
  271. package/packages/web/dist/assets/scss-DZLA7YXz.js +2 -0
  272. package/packages/web/dist/assets/sequenceDiagram-3UESZ5HK-sbcvsuLp.js +163 -0
  273. package/packages/web/dist/assets/sql-BC88IN-V.js +2 -0
  274. package/packages/web/dist/assets/stateDiagram-AJRCARHV-DV-7BdHK.js +2 -0
  275. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BlkGdXKW.js +2 -0
  276. package/packages/web/dist/assets/swift-Dn8kKO_1.js +2 -0
  277. package/packages/web/dist/assets/timeline-definition-PNZ67QCA-C5su21G_.js +121 -0
  278. package/packages/web/dist/assets/typescript-C2FFdlUC.js +2 -0
  279. package/packages/web/dist/assets/vennDiagram-CIIHVFJN-JNkIJAaQ.js +35 -0
  280. package/packages/web/dist/assets/wardley-L42UT6IY-qcz6t2vR.js +174 -0
  281. package/packages/web/dist/assets/wardleyDiagram-YWT4CUSO-CMrOIZ-c.js +79 -0
  282. package/packages/web/dist/assets/xml-BXBhIUeX.js +2 -0
  283. package/packages/web/dist/assets/xychartDiagram-2RQKCTM6-C9q6Oy9u.js +8 -0
  284. package/packages/web/dist/assets/yaml-drsDnIKT.js +2 -0
  285. package/packages/web/dist/favicon.ico +0 -0
  286. package/packages/web/dist/fonts/inter-latin.woff2 +0 -0
  287. package/packages/web/dist/fonts/jetbrains-mono-latin.woff2 +0 -0
  288. package/packages/web/dist/icons/apple-touch-icon.png +0 -0
  289. package/packages/web/dist/icons/favicon-16.png +0 -0
  290. package/packages/web/dist/icons/favicon-32.png +0 -0
  291. package/packages/web/dist/icons/icon-192.png +0 -0
  292. package/packages/web/dist/icons/icon-512.png +0 -0
  293. package/packages/web/dist/icons/maskable-512.png +0 -0
  294. package/packages/web/dist/index.html +56 -0
  295. package/packages/web/dist/manifest.webmanifest +32 -0
  296. package/packages/web/dist/sw.js +139 -0
@@ -0,0 +1,625 @@
1
+ /**
2
+ * Paddock server configuration, sourced from an optional YAML instance-config
3
+ * file with environment overrides on top (precedence: file < env), and sane
4
+ * defaults beneath both.
5
+ *
6
+ * Everything is resolved once at startup so the rest of the app can import a
7
+ * frozen object. Paths are normalised to absolute.
8
+ *
9
+ * **Retired settings are IGNORED, never fatal** (#549). Config is pull-based on
10
+ * both layers: env vars are read by name via {@link envOr}, and the YAML file is
11
+ * parsed into a loose record whose keys are only ever read, never enumerated or
12
+ * validated against a schema. So a setting Paddock has since deleted is simply
13
+ * never looked at — boot succeeds and the value has no effect. This is a property
14
+ * of the resolution shape rather than a compatibility shim, so it needs no
15
+ * per-key upkeep as settings come and go. The cost is that a typo'd key is
16
+ * equally silent; that trade is deliberate — an operator should not be hard-failed
17
+ * out of a running instance by a stale line in an old env file. Pinned by
18
+ * `test/unit/config.test.ts`.
19
+ */
20
+ import path from "node:path";
21
+ import os from "node:os";
22
+ import fs from "node:fs";
23
+ import { fileURLToPath } from "node:url";
24
+ import YAML from "yaml";
25
+ import { DEFAULT_DRIVE_MODE, isKnownDriveMode, isKnownModel, } from "./models.js";
26
+ import { DEFAULT_MAX_SPAWN_DEPTH, isValidMaxSpawnDepth } from "./spawn-capability.js";
27
+ import { DEFAULT_ENVIRONMENT_PROMPT } from "./environment-prompt.js";
28
+ import { DEFAULT_RECOVERY } from "./recovery-config.js";
29
+ import { DEFAULT_CURATION } from "./curation-config.js";
30
+ import { resolveManagementApiConfig, } from "./management-config.js";
31
+ import { DEFAULT_ATTACHMENTS, sanitizeAllowedTypes, } from "./attachments-config.js";
32
+ function abs(p) {
33
+ return path.isAbsolute(p) ? p : path.resolve(process.cwd(), p);
34
+ }
35
+ /**
36
+ * Canonicalize a path: resolve symlinks in the deepest EXISTING ancestor, then
37
+ * re-append the not-yet-created tail. This matters because Claude Code records
38
+ * session transcripts under the *real* working directory (e.g. macOS maps
39
+ * `/tmp` -> `/private/tmp`), and SessionDiscoveryService encodes the configured
40
+ * working_directory to find them. Without canonicalization the configured path
41
+ * and the recorded path diverge and session discovery returns nothing.
42
+ *
43
+ * On Linux (the deploy target) this is typically a no-op, but it keeps paddock
44
+ * portable and robust against symlinked data roots.
45
+ */
46
+ function canonical(p) {
47
+ const absolute = abs(p);
48
+ let dir = absolute;
49
+ const tail = [];
50
+ // Walk up to the first existing ancestor.
51
+ // eslint-disable-next-line no-constant-condition
52
+ while (true) {
53
+ try {
54
+ const real = fs.realpathSync(dir);
55
+ return tail.length ? path.join(real, ...tail.reverse()) : real;
56
+ }
57
+ catch {
58
+ const parent = path.dirname(dir);
59
+ if (parent === dir)
60
+ return absolute; // reached root without resolving
61
+ tail.push(path.basename(dir));
62
+ dir = parent;
63
+ }
64
+ }
65
+ }
66
+ function envOr(name, fallback) {
67
+ const v = process.env[name];
68
+ return v && v.trim().length > 0 ? v : fallback;
69
+ }
70
+ /** An env var's trimmed value, or undefined when unset/blank. */
71
+ function envOpt(name) {
72
+ const v = process.env[name];
73
+ return v && v.trim().length > 0 ? v.trim() : undefined;
74
+ }
75
+ /**
76
+ * Fold a config-file value in as the fallback beneath a hardcoded default. The
77
+ * file value is stringified — so the exact same parsing/coercion an env value
78
+ * would get applies — and used when present and non-blank; otherwise the
79
+ * hardcoded default. Env still wins because callers pass the result as the
80
+ * `envOr` fallback (which is only consulted when the env var is unset).
81
+ */
82
+ function fileOr(fileVal, fallback) {
83
+ if (fileVal === undefined || fileVal === null)
84
+ return fallback;
85
+ const s = String(fileVal);
86
+ return s.trim().length > 0 ? s : fallback;
87
+ }
88
+ /** A config-file value as a trimmed string, or undefined when absent/blank. */
89
+ function fileOpt(fileVal) {
90
+ if (fileVal === undefined || fileVal === null)
91
+ return undefined;
92
+ const s = String(fileVal).trim();
93
+ return s.length > 0 ? s : undefined;
94
+ }
95
+ /** Default filename for the instance-config file, resolved under the data dir. */
96
+ const DEFAULT_CONFIG_FILENAME = "paddock.config.yaml";
97
+ /**
98
+ * Load the optional YAML instance-config file that provides the BASE layer for
99
+ * {@link PaddockConfig} (issue #270 / DD-5). Env vars still override every value
100
+ * (precedence file < env).
101
+ *
102
+ * Path resolution: an explicit `PADDOCK_CONFIG` env var wins; otherwise
103
+ * `<dataDir>/paddock.config.yaml`. When no file exists at the default location
104
+ * the result is an empty object, so an env-only deployment behaves exactly as
105
+ * before — this is a no-op for existing installs. An explicit `PADDOCK_CONFIG`
106
+ * that points at a missing file is treated as a misconfiguration (a clear error)
107
+ * rather than silently ignored.
108
+ *
109
+ * A present-but-malformed file (unparseable YAML, or a top-level scalar/list
110
+ * instead of a mapping) throws a clear Error rather than starting up with a
111
+ * half-empty config.
112
+ */
113
+ export function loadConfigFile(dataDir) {
114
+ const explicit = envOpt("PADDOCK_CONFIG");
115
+ const configPath = explicit ? abs(explicit) : path.join(dataDir, DEFAULT_CONFIG_FILENAME);
116
+ let raw;
117
+ try {
118
+ raw = fs.readFileSync(configPath, "utf8");
119
+ }
120
+ catch (err) {
121
+ const code = err.code;
122
+ if (code === "ENOENT") {
123
+ // Missing default file → env-only, unchanged behaviour. A missing file
124
+ // that was EXPLICITLY requested is an operator error worth surfacing.
125
+ if (explicit) {
126
+ throw new Error(`PADDOCK_CONFIG points at a config file that does not exist: ${configPath}`);
127
+ }
128
+ return {};
129
+ }
130
+ throw new Error(`Failed to read Paddock config file ${configPath}: ${err.message}`);
131
+ }
132
+ let parsed;
133
+ try {
134
+ parsed = YAML.parse(raw);
135
+ }
136
+ catch (err) {
137
+ throw new Error(`Failed to parse Paddock config file ${configPath} as YAML: ${err.message}`);
138
+ }
139
+ // An empty (or comments-only) file parses to null → treat as "no overrides".
140
+ if (parsed === null || parsed === undefined)
141
+ return {};
142
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
143
+ throw new Error(`Paddock config file ${configPath} must contain a YAML mapping (got ${Array.isArray(parsed) ? "a list" : typeof parsed}).`);
144
+ }
145
+ // A valueless key (`brand:` / `auth:` with nothing after it) parses to `null`.
146
+ // Treat such an empty section (or scalar) as ABSENT — drop it so it falls back
147
+ // to env/defaults — rather than passing `null` through to a loader that expects
148
+ // an object (which would crash with an unclear TypeError). Deeper `null`s and
149
+ // wrong-typed sections already degrade to defaults via fileOr/fileOpt.
150
+ const obj = parsed;
151
+ for (const key of Object.keys(obj)) {
152
+ if (obj[key] === null)
153
+ delete obj[key];
154
+ }
155
+ return obj;
156
+ }
157
+ /**
158
+ * Resolve the provider-agnostic auth config from `PADDOCK_AUTH_*` env vars.
159
+ * Everything is optional; the default is `none` (fully open). Validation of
160
+ * mode-specific requirements (e.g. a JWKS URL in jwt mode) happens in the auth
161
+ * plugin so a misconfig surfaces as a clear startup error rather than a
162
+ * silently-open server.
163
+ */
164
+ function loadAuthConfig(file = {}) {
165
+ const rawMode = envOr("PADDOCK_AUTH_MODE", fileOr(file.mode, "none")).toLowerCase();
166
+ const mode = rawMode === "trusted-header" || rawMode === "jwt" ? rawMode : "none";
167
+ return {
168
+ mode,
169
+ userHeader: envOr("PADDOCK_AUTH_USER_HEADER", fileOr(file.userHeader, "X-Forwarded-User")),
170
+ emailHeader: envOpt("PADDOCK_AUTH_EMAIL_HEADER") ?? fileOpt(file.emailHeader),
171
+ groupsHeader: envOpt("PADDOCK_AUTH_GROUPS_HEADER") ?? fileOpt(file.groupsHeader),
172
+ jwtHeader: envOr("PADDOCK_AUTH_JWT_HEADER", fileOr(file.jwtHeader, "Authorization")),
173
+ jwksUrl: envOpt("PADDOCK_AUTH_JWKS_URL") ?? fileOpt(file.jwksUrl),
174
+ jwtIssuer: envOpt("PADDOCK_AUTH_JWT_ISSUER") ?? fileOpt(file.jwtIssuer),
175
+ jwtAudience: envOpt("PADDOCK_AUTH_JWT_AUDIENCE") ?? fileOpt(file.jwtAudience),
176
+ usernameClaim: envOpt("PADDOCK_AUTH_USERNAME_CLAIM") ?? fileOpt(file.usernameClaim),
177
+ groupsClaim: envOr("PADDOCK_AUTH_GROUPS_CLAIM", fileOr(file.groupsClaim, "groups")),
178
+ };
179
+ }
180
+ /**
181
+ * Resolve the dangerously-open opt-in from `PADDOCK_DANGEROUSLY_ALLOW_OPEN`
182
+ * (#435). Off by default; accepts the shared 1/true/yes truthy convention. When
183
+ * set, the bind-safety guard downgrades its refusal (non-loopback + auth=none)
184
+ * to a loud boot warning instead of failing closed.
185
+ */
186
+ function loadDangerouslyAllowOpen() {
187
+ const raw = envOr("PADDOCK_DANGEROUSLY_ALLOW_OPEN", "false").toLowerCase();
188
+ return raw === "1" || raw === "true" || raw === "yes";
189
+ }
190
+ /**
191
+ * Resolve the voice-dictation (Whisper) capability from env. Defaults to `off`,
192
+ * so a plain instance never shows a mic button. The mode defaults to `remote`
193
+ * when an endpoint is configured (the common case — point it at a shared whisper
194
+ * server), and can be forced with `PADDOCK_WHISPER_MODE`. Accepts local/remote/off.
195
+ */
196
+ function loadTranscriptionConfig(file = {}) {
197
+ const endpoint = envOpt("PADDOCK_WHISPER_ENDPOINT") ?? fileOpt(file.endpoint);
198
+ const rawMode = envOr("PADDOCK_WHISPER_MODE", fileOr(file.mode, endpoint ? "remote" : "off")).toLowerCase();
199
+ const mode = rawMode === "local" || rawMode === "remote" ? rawMode : "off";
200
+ return {
201
+ mode,
202
+ model: envOr("PADDOCK_WHISPER_MODEL", fileOr(file.model, "base")),
203
+ endpoint,
204
+ apiKey: envOpt("PADDOCK_WHISPER_API_KEY") ?? fileOpt(file.apiKey),
205
+ language: envOpt("PADDOCK_WHISPER_LANGUAGE") ?? fileOpt(file.language),
206
+ maxUploadBytes: Number(envOr("PADDOCK_WHISPER_MAX_UPLOAD_BYTES", fileOr(file.maxUploadBytes, String(25 * 1024 * 1024)))),
207
+ };
208
+ }
209
+ /**
210
+ * Resolve per-instance branding from env. Defaults preserve today's look, so a
211
+ * plain instance is unchanged; an operator running several instances from one
212
+ * image sets `PADDOCK_BRAND_*` in each instance's env to tell them apart.
213
+ */
214
+ function loadBrandConfig(file = {}) {
215
+ return {
216
+ name: envOr("PADDOCK_BRAND_NAME", fileOr(file.name, "Paddock")),
217
+ logo: envOr("PADDOCK_BRAND_LOGO", fileOr(file.logo, "🐎")),
218
+ accent: envOr("PADDOCK_BRAND_ACCENT", fileOr(file.accent, "#c2603c")),
219
+ };
220
+ }
221
+ /**
222
+ * Default location of the built web SPA, resolved RELATIVE TO THIS MODULE:
223
+ * `packages/server/{src,dist}/config.js` -> `packages/web/dist`. The same hop
224
+ * works from both the `tsx src/` and `node dist/` layouts, so neither needs a
225
+ * `PADDOCK_WEB_DIST` override.
226
+ *
227
+ * Takes the module URL as a parameter purely so a test can exercise install
228
+ * paths this repo's own checkout does not have.
229
+ *
230
+ * **`fileURLToPath`, NOT `new URL(url).pathname`.** The raw pathname is
231
+ * percent-ENCODED, so an install path containing a space or any non-ASCII
232
+ * character (`/opt/my paddock/`, `~/Développement/`) yields a literal `%20`
233
+ * that resolves to a directory which does not exist. The failure is SILENT:
234
+ * `app.ts` degrades to API-only mode with only a log warning, so the user gets
235
+ * a blank page and no explanation of why. `fileURLToPath` also decodes the
236
+ * `/C:/...` drive-letter form on Windows. This is irrelevant when the path is
237
+ * the Docker image's fixed `/app`, and load-bearing the moment the package is
238
+ * installed under an arbitrary user directory (`npx`, global install).
239
+ */
240
+ export function resolveDefaultWebDist(moduleUrl) {
241
+ return path.resolve(path.dirname(fileURLToPath(moduleUrl)), "../../web/dist");
242
+ }
243
+ export function loadPaddockConfig() {
244
+ // The optional YAML instance-config file provides the BASE layer; env vars
245
+ // override it (precedence file < env). The file is located under the data dir,
246
+ // so resolve a BOOTSTRAP data dir from env/default first to find it — the file
247
+ // may then re-base the data dir for the resolved config below.
248
+ const bootstrapDataDir = abs(envOr("PADDOCK_DATA_DIR", "./data"));
249
+ const file = loadConfigFile(bootstrapDataDir);
250
+ // Ensure the data root exists first so symlinks (e.g. /tmp -> /private/tmp on
251
+ // macOS) resolve consistently for every derived path below.
252
+ const dataRoot = abs(envOr("PADDOCK_DATA_DIR", fileOr(file.dataDir, "./data")));
253
+ try {
254
+ fs.mkdirSync(dataRoot, { recursive: true });
255
+ }
256
+ catch {
257
+ /* best-effort; downstream mkdirs will surface real errors */
258
+ }
259
+ // working_directory of keeper agents MUST be canonical so session discovery
260
+ // (which encodes the real path) can find Claude transcripts.
261
+ const dataDir = canonical(dataRoot);
262
+ const projectsRoot = canonical(envOr("PADDOCK_PROJECTS_DIR", fileOr(file.projectsRoot, path.join(dataRoot, "projects"))));
263
+ const stateDir = canonical(envOr("PADDOCK_STATE_DIR", fileOr(file.stateDir, path.join(dataRoot, ".herdctl"))));
264
+ const herdctlConfigPath = canonical(envOr("PADDOCK_HERDCTL_CONFIG", fileOr(file.herdctlConfigPath, path.join(dataRoot, "herdctl.yaml"))));
265
+ // The ONE Claude home for this process (#588): paddock's transcript symlinks,
266
+ // the engine's session discovery and session adoption all resolve against this
267
+ // exact value. NOT canonical()-ed: `claudeHome()` is also what the CLI runtime
268
+ // and Claude Code itself see, and canonicalising would silently diverge from
269
+ // the literal path they encode into `<home>/projects/<encoded-cwd>`.
270
+ const resolvedClaudeHome = claudeHome();
271
+ const defaultWebDist = resolveDefaultWebDist(import.meta.url);
272
+ return Object.freeze({
273
+ port: Number(envOr("PORT", fileOr(file.port, "4000"))),
274
+ // Safe by default (#435): bind loopback unless explicitly told otherwise, so
275
+ // a fresh source/tarball run is network-closed. Non-loopback + no auth is then
276
+ // gated by the bind-safety guard (see bind-safety.ts). Existing deployments
277
+ // that set HOST/PADDOCK_HOST are unaffected — only the DEFAULT changed from
278
+ // 0.0.0.0. (The container image keeps ENV HOST=0.0.0.0: the network namespace
279
+ // is its boundary and Docker can't reach 127.0.0.1 inside the container.)
280
+ host: envOr("HOST", envOr("PADDOCK_HOST", fileOr(file.host, "127.0.0.1"))),
281
+ dangerouslyAllowOpen: loadDangerouslyAllowOpen(),
282
+ dataDir,
283
+ projectsRoot,
284
+ stateDir,
285
+ herdctlConfigPath,
286
+ webDist: abs(envOr("PADDOCK_WEB_DIST", fileOr(file.webDist, defaultWebDist))),
287
+ claudeHome: resolvedClaudeHome,
288
+ auth: loadAuthConfig(file.auth),
289
+ transcription: loadTranscriptionConfig(file.transcription),
290
+ brand: loadBrandConfig(file.brand),
291
+ models: loadModels(file.models),
292
+ driveMode: loadDriveMode(file.driveMode),
293
+ nativeSystemPrompt: loadNativeSystemPrompt(file.nativeSystemPrompt),
294
+ environmentPrompt: loadEnvironmentPrompt(file.environmentPrompt),
295
+ selfMcpEnabled: loadSelfMcpEnabled(file.selfMcpEnabled),
296
+ selfMcpWriteEnabled: loadSelfMcpEnabled(file.selfMcpEnabled) && loadSelfMcpWriteEnabled(file.selfMcpWriteEnabled),
297
+ // Projects tool implies write implies read — the tool is appended in the write
298
+ // block, so both outer gates must be on for it to mean anything (issue #467).
299
+ selfMcpProjectsEnabled: loadSelfMcpEnabled(file.selfMcpEnabled) &&
300
+ loadSelfMcpWriteEnabled(file.selfMcpWriteEnabled) &&
301
+ loadSelfMcpProjectsEnabled(file.selfMcpProjectsEnabled),
302
+ maxSpawnDepth: loadMaxSpawnDepth(file.maxSpawnDepth),
303
+ scheduleMutationEnabled: loadScheduleMutationEnabled(file.scheduleMutationEnabled),
304
+ ...(() => {
305
+ // #312 M1: resolved against the real environment (token material is only
306
+ // ever an `env:` reference). Diagnostics ride along for app.ts to log.
307
+ const { config, errors, warnings } = resolveManagementApiConfig(file.managementApi, process.env);
308
+ return { managementApi: config, managementApiDiagnostics: { errors, warnings } };
309
+ })(),
310
+ hooksMcpEnabled: loadHooksMcpEnabled(file.hooksMcpEnabled),
311
+ recovery: loadRecoveryConfig(file.recovery),
312
+ attachments: loadAttachmentsConfig(file.attachments),
313
+ curation: loadCurationConfig(file.curation),
314
+ logLevel: envOr("LOG_LEVEL", fileOr(file.logLevel, "info")),
315
+ browserMcp: loadBrowserMcp(file.browserMcp),
316
+ openapi: loadOpenApiConfig(file.openapi),
317
+ sweepMinIntervalMs: loadSweepMinIntervalMs(file.sweepMinIntervalMs),
318
+ gitAuthor: {
319
+ name: envOr("PADDOCK_GIT_AUTHOR_NAME", fileOr(file.gitAuthor?.name, "Paddock")),
320
+ email: envOr("PADDOCK_GIT_AUTHOR_EMAIL", fileOr(file.gitAuthor?.email, "paddock@localhost")),
321
+ },
322
+ githubClientId: envOpt("PADDOCK_GITHUB_CLIENT_ID") ?? fileOpt(file.githubClientId),
323
+ });
324
+ }
325
+ // CurationConfig + DEFAULT_CURATION now live in ./curation-config.ts (issue #384),
326
+ // alongside the per-project override resolver — mirroring recovery-config.ts.
327
+ /** Resolve one positive-integer budget from env < file < built-in default. */
328
+ function loadCurationNum(envName, file, def) {
329
+ const raw = envOpt(envName) ?? fileOpt(file);
330
+ if (raw === undefined)
331
+ return def;
332
+ const n = Number(raw);
333
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : def;
334
+ }
335
+ /** Resolve the curation token budgets (issue #379), precedence env < file < default. */
336
+ function loadCurationConfig(file) {
337
+ return {
338
+ overviewMaxTokens: loadCurationNum("PADDOCK_CURATION_OVERVIEW_MAX_TOKENS", file?.overviewMaxTokens, DEFAULT_CURATION.overviewMaxTokens),
339
+ changelogMaxTokens: loadCurationNum("PADDOCK_CURATION_CHANGELOG_MAX_TOKENS", file?.changelogMaxTokens, DEFAULT_CURATION.changelogMaxTokens),
340
+ claudeMaxTokens: loadCurationNum("PADDOCK_CURATION_CLAUDEMD_MAX_TOKENS", file?.claudeMaxTokens, DEFAULT_CURATION.claudeMaxTokens),
341
+ };
342
+ }
343
+ /**
344
+ * Resolve the post-turn sweep's minimum interval from
345
+ * `PADDOCK_SWEEP_MIN_INTERVAL_MS` (issue #269 fold). Returns `undefined` when
346
+ * unset or invalid (non-finite / negative) so the SweepService default (5 min)
347
+ * applies — preserving the pre-fold `envIntervalMs()` semantics exactly.
348
+ */
349
+ function loadSweepMinIntervalMs(file) {
350
+ const raw = envOpt("PADDOCK_SWEEP_MIN_INTERVAL_MS") ?? fileOpt(file);
351
+ if (raw === undefined)
352
+ return undefined;
353
+ const n = Number(raw);
354
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
355
+ }
356
+ /**
357
+ * Resolve whether keeper agents receive the Playwright browser MCP
358
+ * (issue #269 fold). The env var keeps its exact literal-'1' semantics (any
359
+ * other set value — including `true` — disables it, matching pre-loader
360
+ * behaviour); only when the env var is UNSET does the config file provide the
361
+ * base, using the 1/true/yes convention shared by the other boolean knobs.
362
+ */
363
+ function loadBrowserMcp(file) {
364
+ const env = process.env.PADDOCK_BROWSER_MCP;
365
+ if (env !== undefined)
366
+ return env === "1";
367
+ const raw = fileOpt(file)?.toLowerCase();
368
+ return raw === "1" || raw === "true" || raw === "yes";
369
+ }
370
+ /**
371
+ * Resolve the OpenAPI reference config. `enabled` defaults OFF (opt-in):
372
+ * publishing the API surface is a deliberate choice, so it only mounts when
373
+ * `PADDOCK_OPENAPI_ENABLED` / YAML is a truthy value (1/true/yes/on). `path`
374
+ * (the UI route prefix) defaults `/open-api`; it is normalized to a leading
375
+ * slash with no trailing slash.
376
+ */
377
+ function loadOpenApiConfig(file) {
378
+ const rawEnabled = (process.env.PADDOCK_OPENAPI_ENABLED ??
379
+ (file?.enabled === undefined ? undefined : String(file.enabled)))?.toLowerCase();
380
+ const enabled = rawEnabled === "1" || rawEnabled === "true" || rawEnabled === "yes" || rawEnabled === "on";
381
+ let path = envOpt("PADDOCK_OPENAPI_PATH") ?? fileOpt(file?.path) ?? "/open-api";
382
+ if (!path.startsWith("/"))
383
+ path = "/" + path;
384
+ if (path.length > 1 && path.endsWith("/"))
385
+ path = path.slice(0, -1);
386
+ return { enabled, path };
387
+ }
388
+ /**
389
+ * Resolve the instance-default max spawn depth from `PADDOCK_MAX_SPAWN_DEPTH`
390
+ * (issue #262). Defaults to {@link DEFAULT_MAX_SPAWN_DEPTH} (`1`); a
391
+ * missing/blank/out-of-range value falls back to the default rather than failing
392
+ * startup. A per-project override still wins at dispatch.
393
+ */
394
+ function loadMaxSpawnDepth(file) {
395
+ const raw = envOpt("PADDOCK_MAX_SPAWN_DEPTH") ?? fileOpt(file);
396
+ if (raw === undefined)
397
+ return DEFAULT_MAX_SPAWN_DEPTH;
398
+ const n = Number(raw);
399
+ return isValidMaxSpawnDepth(n) ? n : DEFAULT_MAX_SPAWN_DEPTH;
400
+ }
401
+ /**
402
+ * Resolve a boolean recovery knob (issue #301) with the shared 1/true/yes
403
+ * convention, env over file over the built-in default. Mirrors the other boolean
404
+ * loaders; factored so each recovery flag reads identically.
405
+ */
406
+ function loadRecoveryBool(env, file, fallback) {
407
+ const raw = envOr(env, fileOr(file, String(fallback))).toLowerCase();
408
+ return raw === "1" || raw === "true" || raw === "yes";
409
+ }
410
+ /**
411
+ * Resolve a non-negative-integer recovery knob (issue #301). A missing/blank/
412
+ * non-finite/negative value falls back to the default rather than failing
413
+ * startup — defensive, like {@link loadMaxSpawnDepth}.
414
+ */
415
+ function loadRecoveryInt(env, file, fallback) {
416
+ const raw = envOpt(env) ?? fileOpt(file);
417
+ if (raw === undefined)
418
+ return fallback;
419
+ const n = Number(raw);
420
+ return Number.isInteger(n) && n >= 0 ? n : fallback;
421
+ }
422
+ /**
423
+ * Resolve the instance-default keeper-chat recovery config (issue #301). Layer 2
424
+ * (`surfaceKilledTask`) defaults ON, Layer 3 (`autoReDrive`) defaults OFF; the
425
+ * guards default per {@link DEFAULT_RECOVERY}. Each field is env-over-file-over-
426
+ * default, and a per-project `recovery` override still wins at dispatch
427
+ * (resolveRecoveryConfig). Malformed values degrade to the default, never fatal.
428
+ */
429
+ function loadRecoveryConfig(file) {
430
+ const f = file ?? {};
431
+ return {
432
+ surfaceKilledTask: loadRecoveryBool("PADDOCK_RECOVERY_SURFACE", f.surfaceKilledTask, DEFAULT_RECOVERY.surfaceKilledTask),
433
+ autoReDrive: loadRecoveryBool("PADDOCK_RECOVERY_AUTODRIVE", f.autoReDrive, DEFAULT_RECOVERY.autoReDrive),
434
+ debounceMs: loadRecoveryInt("PADDOCK_RECOVERY_DEBOUNCE_MS", f.debounceMs, DEFAULT_RECOVERY.debounceMs),
435
+ maxRetries: loadRecoveryInt("PADDOCK_RECOVERY_MAX_RETRIES", f.maxRetries, DEFAULT_RECOVERY.maxRetries),
436
+ limboTimeoutMs: loadRecoveryInt("PADDOCK_RECOVERY_LIMBO_MS", f.limboTimeoutMs, DEFAULT_RECOVERY.limboTimeoutMs),
437
+ };
438
+ }
439
+ /**
440
+ * Resolve a positive-integer attachment knob (issue #328). A missing/blank/
441
+ * non-integer/< 1 value falls back to the default rather than failing startup —
442
+ * defensive, like {@link loadRecoveryInt} (but size/count must be ≥ 1).
443
+ */
444
+ function loadAttachmentsInt(env, file, fallback) {
445
+ const raw = envOpt(env) ?? fileOpt(file);
446
+ if (raw === undefined)
447
+ return fallback;
448
+ const n = Number(raw);
449
+ return Number.isInteger(n) && n >= 1 ? n : fallback;
450
+ }
451
+ /**
452
+ * Resolve the `allowedTypes` list (issue #328). The env var is comma-separated
453
+ * (`PADDOCK_ATTACHMENTS_ALLOWED_TYPES=image/*,.pdf`); the YAML file takes a real
454
+ * array (or a comma-separated string, coerced the same way). An empty/invalid
455
+ * value falls back to the default (allow-all) — never "deny all".
456
+ */
457
+ function loadAllowedTypes(file, fallback) {
458
+ const env = envOpt("PADDOCK_ATTACHMENTS_ALLOWED_TYPES");
459
+ const raw = env !== undefined ? env.split(",") : Array.isArray(file) ? file : typeof file === "string" ? file.split(",") : undefined;
460
+ return sanitizeAllowedTypes(raw) ?? fallback;
461
+ }
462
+ /**
463
+ * Resolve the instance-default inbound-attachment config (issue #328). Enabled +
464
+ * allow-all by default; each field is env-over-file-over-default, and a
465
+ * per-project `attachments` override still wins at request time
466
+ * (resolveAttachmentsConfig). Malformed values degrade to the default, never fatal.
467
+ */
468
+ function loadAttachmentsConfig(file) {
469
+ const f = file ?? {};
470
+ const enabledRaw = envOr("PADDOCK_ATTACHMENTS_ENABLED", fileOr(f.enabled, String(DEFAULT_ATTACHMENTS.enabled))).toLowerCase();
471
+ return {
472
+ enabled: enabledRaw === "1" || enabledRaw === "true" || enabledRaw === "yes",
473
+ maxFileSizeMb: loadAttachmentsInt("PADDOCK_ATTACHMENTS_MAX_FILE_SIZE_MB", f.maxFileSizeMb, DEFAULT_ATTACHMENTS.maxFileSizeMb),
474
+ maxFilesPerMessage: loadAttachmentsInt("PADDOCK_ATTACHMENTS_MAX_FILES_PER_MESSAGE", f.maxFilesPerMessage, DEFAULT_ATTACHMENTS.maxFilesPerMessage),
475
+ allowedTypes: loadAllowedTypes(f.allowedTypes, DEFAULT_ATTACHMENTS.allowedTypes),
476
+ };
477
+ }
478
+ /**
479
+ * Resolve the per-deployment schedule-mutation gate (issue #265 / DD-7). Defaults
480
+ * OFF so a plain instance can't have its schedules mutated programmatically; an
481
+ * operator opts in with `PADDOCK_SCHEDULE_MUTATION=1` (or the config file). Accepts
482
+ * 1/true/yes. Static `project.yaml` schedules are armed regardless of this flag.
483
+ */
484
+ function loadScheduleMutationEnabled(file) {
485
+ const raw = envOr("PADDOCK_SCHEDULE_MUTATION", fileOr(file, "false")).toLowerCase();
486
+ return raw === "1" || raw === "true" || raw === "yes";
487
+ }
488
+ /**
489
+ * Resolve the instance-default hook-management MCP gate (Epic G / G5, GG-4).
490
+ * Defaults OFF so a plain instance never advertises the hook tools; opt in with
491
+ * `PADDOCK_HOOKS_MCP=1` (or the config file), and a per-project `hooksMcpEnabled`
492
+ * override still wins at dispatch. Accepts 1/true/yes. Only meaningful when the
493
+ * self-MCP write tools are also enabled (the hook tools live on that server).
494
+ */
495
+ function loadHooksMcpEnabled(file) {
496
+ const raw = envOr("PADDOCK_HOOKS_MCP", fileOr(file, "false")).toLowerCase();
497
+ return raw === "1" || raw === "true" || raw === "yes";
498
+ }
499
+ /**
500
+ * Resolve whether keepers additionally get the self-management MCP WRITE tools
501
+ * (issue #214 Phase 2). Defaults OFF; only takes effect when `PADDOCK_SELF_MCP`
502
+ * is also on (write implies read — enforced at the call site above). Accepts
503
+ * 1/true/yes.
504
+ */
505
+ function loadSelfMcpWriteEnabled(file) {
506
+ const raw = envOr("PADDOCK_SELF_MCP_WRITE", fileOr(file, "false")).toLowerCase();
507
+ return raw === "1" || raw === "true" || raw === "yes";
508
+ }
509
+ /**
510
+ * Resolve whether keepers additionally get the self-management MCP PROJECT tools
511
+ * (issue #467 — `create_project`). Defaults OFF; only takes effect when
512
+ * `PADDOCK_SELF_MCP` and `PADDOCK_SELF_MCP_WRITE` are also on (enforced at the call
513
+ * site above). Accepts 1/true/yes.
514
+ */
515
+ function loadSelfMcpProjectsEnabled(file) {
516
+ const raw = envOr("PADDOCK_SELF_MCP_PROJECTS", fileOr(file, "false")).toLowerCase();
517
+ return raw === "1" || raw === "true" || raw === "yes";
518
+ }
519
+ /**
520
+ * Resolve whether keepers get the read-only self-management MCP (issue #214).
521
+ * Defaults to OFF so a plain instance never advertises it; opt in per instance
522
+ * with `PADDOCK_SELF_MCP=1`. Accepts 1/true/yes.
523
+ */
524
+ function loadSelfMcpEnabled(file) {
525
+ const raw = envOr("PADDOCK_SELF_MCP", fileOr(file, "false")).toLowerCase();
526
+ return raw === "1" || raw === "true" || raw === "yes";
527
+ }
528
+ /**
529
+ * Resolve whether keeper agents use the native system prompt + CLAUDE.md
530
+ * hierarchy (issue #176). Defaults to `true` (native) on every instance so a
531
+ * seeded instance-wide + per-project `CLAUDE.md` is auto-loaded; set
532
+ * `PADDOCK_NATIVE_PROMPT` to 0/false/no to fall back to the terse replace
533
+ * prompt.
534
+ */
535
+ function loadNativeSystemPrompt(file) {
536
+ const raw = envOr("PADDOCK_NATIVE_PROMPT", fileOr(file, "true")).toLowerCase();
537
+ return !(raw === "0" || raw === "false" || raw === "no");
538
+ }
539
+ /**
540
+ * Resolve the environment system prompt (issue #635): env
541
+ * `PADDOCK_ENVIRONMENT_PROMPT` over the `environmentPrompt:` file key over the
542
+ * built-in {@link DEFAULT_ENVIRONMENT_PROMPT}.
543
+ *
544
+ * Deliberately does NOT use `envOr`/`fileOr`. Those fold a blank value to the
545
+ * fallback, which would make opting out impossible — blank is the opt-out here,
546
+ * so precedence is decided on *definedness*, not on emptiness:
547
+ *
548
+ * - `PADDOCK_ENVIRONMENT_PROMPT` **defined at all** (even empty) wins outright.
549
+ * That is why the field is declared `envShadowWhenDefined` in
550
+ * instance-config.ts — same rule as `PADDOCK_BROWSER_MCP` — so the Settings
551
+ * screen renders it read-only in exactly the cases where it really is.
552
+ * - Otherwise a `string` file value wins, empty included. A non-string (a YAML
553
+ * number, a mapping, `null` from a bare `environmentPrompt:`) is ignored
554
+ * rather than stringified — `"null"` is not a prompt anyone meant to write.
555
+ * - Otherwise the built-in default.
556
+ *
557
+ * The value is used verbatim: no trimming, no normalization. An operator's
558
+ * trailing newline or leading indentation survives the round-trip, because the
559
+ * text is going into a prompt where whitespace is content.
560
+ */
561
+ function loadEnvironmentPrompt(file) {
562
+ const env = process.env.PADDOCK_ENVIRONMENT_PROMPT;
563
+ if (env !== undefined)
564
+ return env;
565
+ if (typeof file === "string")
566
+ return file;
567
+ return DEFAULT_ENVIRONMENT_PROMPT;
568
+ }
569
+ /**
570
+ * Resolve the instance model allow-list (issue #457 Step 2), precedence
571
+ * env `PADDOCK_MODELS` (comma-separated ids) over YAML `models:` (a string array,
572
+ * or a comma-separated string coerced the same way) over the default (undefined ⇒
573
+ * every catalog model is offered). Each id is validated against the catalog with
574
+ * {@link isKnownModel}; unknown/blank/duplicate ids are dropped. If nothing valid
575
+ * survives — or the list was unset — the result is `undefined`, which downstream
576
+ * ({@link import("./models.js").resolveModels}) treats as "offer the full catalog",
577
+ * so an instance is never left with zero models.
578
+ */
579
+ function loadModels(file) {
580
+ const env = envOpt("PADDOCK_MODELS");
581
+ const raw = env !== undefined
582
+ ? env.split(",")
583
+ : Array.isArray(file)
584
+ ? file.map((v) => String(v))
585
+ : typeof file === "string"
586
+ ? file.split(",")
587
+ : undefined;
588
+ if (raw === undefined)
589
+ return undefined;
590
+ const seen = new Set();
591
+ const ids = [];
592
+ for (const item of raw) {
593
+ const id = item.trim();
594
+ if (!id || seen.has(id) || !isKnownModel(id))
595
+ continue;
596
+ seen.add(id);
597
+ ids.push(id);
598
+ }
599
+ // Empty after filtering (all unknown/blank) ⇒ fall back to the full catalog.
600
+ return ids.length > 0 ? ids : undefined;
601
+ }
602
+ /**
603
+ * Resolve the global keeper drive mode from `PADDOCK_DRIVE_MODE`. Defaults
604
+ * to `session` (DEFAULT_DRIVE_MODE, #316); an unrecognized value falls back
605
+ * to the default rather than failing startup. A per-project `driveMode` still
606
+ * overrides this at dispatch.
607
+ */
608
+ function loadDriveMode(file) {
609
+ const raw = (envOpt("PADDOCK_DRIVE_MODE") ?? fileOpt(file))?.toLowerCase();
610
+ return raw && isKnownDriveMode(raw) ? raw : DEFAULT_DRIVE_MODE;
611
+ }
612
+ /**
613
+ * Resolve the Claude home from the environment — `CLAUDE_HOME`, else
614
+ * `~/.claude`.
615
+ *
616
+ * This is the RESOLVER, not the accessor. It is read once by
617
+ * {@link loadPaddockConfig} into {@link PaddockConfig.claudeHome}; runtime code
618
+ * should thread `cfg.claudeHome` rather than call this again, so paddock and the
619
+ * engine can never end up resolving two different homes (#588). It stays a
620
+ * function (not a constant) because `os.homedir()` must be read at call time —
621
+ * tests and processes that mutate `HOME` depend on that.
622
+ */
623
+ export function claudeHome() {
624
+ return process.env.CLAUDE_HOME ?? path.join(os.homedir(), ".claude");
625
+ }