@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,259 @@
1
+ /**
2
+ * Management API policy primitives (issue #312 M1).
3
+ *
4
+ * The `ManagementPrincipal` (WHO is calling) + `ManagementScope` (WHAT they may
5
+ * reach) and the pure predicates that decide a single call. This module is
6
+ * deliberately transport-free and dependency-free: it knows nothing about MCP,
7
+ * HTTP, Fastify or the ops callbacks. Everything here is a pure function over
8
+ * plain data, so the security-critical decisions are unit-testable without a
9
+ * fleet, a socket, or a server.
10
+ *
11
+ * ── Why policy lives HERE and not in the MCP transport ──────────────────────
12
+ * The ticket's central structural decision: enforcement happens at the OPS
13
+ * layer, not in whichever transport happens to be calling. Paddock will grow a
14
+ * REST surface for the same turn-spawning operations (#465); if policy lived in
15
+ * the MCP handler, REST would have to reimplement it and the two would drift.
16
+ * So the transports authenticate (producing a principal) and the ops layer
17
+ * enforces (consuming one) — see management-ops.ts.
18
+ *
19
+ * ── The blast-radius framing that drives the defaults ───────────────────────
20
+ * `create_chat` / `send_message` / `fork_chat*` / `run_trigger` START KEEPER
21
+ * TURNS, and a keeper runs with Bash + Write. So **any write scope is
22
+ * effectively remote code execution on the host.** Read-only is therefore the
23
+ * default for a configured client, writes are opt-in per client, and the
24
+ * grammar below is deny-biased at every ambiguity: deny beats allow, an unknown
25
+ * operation is denied, and an empty allow-list grants nothing.
26
+ */
27
+ // ── The operation catalogue ────────────────────────────────────────────────
28
+ // The canonical names policy is written against. These deliberately match the
29
+ // self-MCP tool names one-for-one, so an operator writing `allow: ["read_chat"]`
30
+ // names the same thing the agent sees as `mcp__paddock_manage__read_chat`, and
31
+ // so a future REST route can reuse the identifier unchanged.
32
+ /** Read-only operations: no state change, no turn started. */
33
+ export const READ_OPERATIONS = ["list_projects", "list_chats", "read_chat"];
34
+ /** Chat write operations. The first four START TURNS (see the RCE note above). */
35
+ export const WRITE_OPERATIONS = [
36
+ "create_project",
37
+ "create_chat",
38
+ "fork_chat",
39
+ "send_message",
40
+ "fork_chat_batch",
41
+ "archive_chat",
42
+ "unarchive_chat",
43
+ ];
44
+ /** Unified trigger operations (Epic T / T3). `list_triggers` is the read one. */
45
+ export const TRIGGER_OPERATIONS = [
46
+ "list_triggers",
47
+ "set_trigger",
48
+ "remove_trigger",
49
+ "run_trigger",
50
+ ];
51
+ /** Every operation the management surface knows about. */
52
+ export const ALL_OPERATIONS = [
53
+ ...READ_OPERATIONS,
54
+ ...WRITE_OPERATIONS,
55
+ ...TRIGGER_OPERATIONS,
56
+ ];
57
+ /**
58
+ * Operations that cause a keeper turn to run — directly (`create_chat`,
59
+ * `send_message`, `fork_chat*`, `run_trigger`) or by arming something that will
60
+ * (`set_trigger`). Granting ANY of these to a principal is granting code
61
+ * execution on the host; kept as an explicit set so that fact stays greppable
62
+ * and so docs/UI can warn on it.
63
+ */
64
+ /**
65
+ * Operations that grant code execution on the host, and so define the risk class
66
+ * a write scope really is. A superset of {@link TURN_SPAWNING_OPERATIONS}:
67
+ * `create_project` starts no turn, but it clones a CALLER-SUPPLIED git URL and
68
+ * creates instance-level state, which belongs in the same warning.
69
+ */
70
+ export const HIGH_RISK_OPERATIONS = [
71
+ "create_project",
72
+ "create_chat",
73
+ "fork_chat",
74
+ "fork_chat_batch",
75
+ "send_message",
76
+ "run_trigger",
77
+ "set_trigger",
78
+ ];
79
+ export const TURN_SPAWNING_OPERATIONS = [
80
+ "create_chat",
81
+ "fork_chat",
82
+ "send_message",
83
+ "fork_chat_batch",
84
+ "run_trigger",
85
+ "set_trigger",
86
+ ];
87
+ /**
88
+ * The DEFAULT scope for a configured client that specifies none — read-only
89
+ * across all projects. Matches the shape written on issue #312 verbatim
90
+ * (`allow: ["list_*", "read_chat"]`), which covers the three read tools plus
91
+ * `list_triggers` while excluding every mutating verb.
92
+ */
93
+ export const DEFAULT_READ_ONLY_SCOPE = Object.freeze({
94
+ projects: ["*"],
95
+ allow: ["list_*", "read_chat"],
96
+ deny: [],
97
+ });
98
+ /** Unrestricted scope — used ONLY for the in-process keeper principal. */
99
+ export const FULL_SCOPE = Object.freeze({
100
+ projects: ["*"],
101
+ allow: ["*"],
102
+ deny: [],
103
+ });
104
+ /**
105
+ * The implicit full-trust principal for the in-process keeper path.
106
+ *
107
+ * The internal caller is bounded by DEPTH (`maxSpawnDepth`, shipped in #278),
108
+ * not by auth+scope — it has no credential because it isn't a distinct identity.
109
+ * Handing it a full scope keeps a single enforcement path (every op checks a
110
+ * principal) while guaranteeing the keeper behaviour is byte-for-byte unchanged:
111
+ * every check below short-circuits to allow.
112
+ */
113
+ export const INTERNAL_PRINCIPAL = Object.freeze({
114
+ clientId: "internal",
115
+ kind: "internal",
116
+ scope: FULL_SCOPE,
117
+ });
118
+ /** True for the implicit in-process principal (bounded by depth, not scope). */
119
+ export function isInternalPrincipal(principal) {
120
+ return principal.kind === "internal";
121
+ }
122
+ /**
123
+ * Match one pattern against one value. Supports exactly two forms — a bare `"*"`
124
+ * (everything) and a trailing-`*` prefix (`"list_*"`) — plus exact equality.
125
+ *
126
+ * Deliberately NOT a general glob: a security predicate should be trivially
127
+ * auditable by the operator writing it, and the ticket calls for starting with
128
+ * explicit lists and adding globs only as needed. Anything fancier (`?`, `[]`,
129
+ * embedded `*`) is treated as a literal, so a typo'd pattern fails CLOSED
130
+ * (matches nothing) rather than accidentally widening a grant.
131
+ */
132
+ export function matchesPattern(pattern, value) {
133
+ if (pattern === "*")
134
+ return true;
135
+ if (pattern.endsWith("*") && !pattern.slice(0, -1).includes("*")) {
136
+ return value.startsWith(pattern.slice(0, -1));
137
+ }
138
+ return pattern === value;
139
+ }
140
+ function matchesAny(patterns, value) {
141
+ return (patterns ?? []).some((p) => matchesPattern(p, value));
142
+ }
143
+ /**
144
+ * Whether `scope` permits invoking `operation`. Deny beats allow; an operation
145
+ * outside {@link ALL_OPERATIONS} is refused regardless of the allow-list, so a
146
+ * typo in config (or a tool added later that policy hasn't been taught about)
147
+ * can never be reached through a stale `"*"` grant.
148
+ */
149
+ export function isOperationAllowed(scope, operation) {
150
+ if (!ALL_OPERATIONS.includes(operation))
151
+ return false;
152
+ if (matchesAny(scope.deny, operation))
153
+ return false;
154
+ return matchesAny(scope.allow, operation);
155
+ }
156
+ /** Whether `scope` permits touching `projectSlug`. Deny beats allow. */
157
+ export function isProjectAllowed(scope, projectSlug) {
158
+ if (matchesAny(scope.denyProjects, projectSlug))
159
+ return false;
160
+ return matchesAny(scope.projects, projectSlug);
161
+ }
162
+ /** The concrete operations `scope` grants, in catalogue order. */
163
+ export function allowedOperations(scope) {
164
+ return ALL_OPERATIONS.filter((op) => isOperationAllowed(scope, op));
165
+ }
166
+ /** True when `scope` grants at least one operation that starts a keeper turn. */
167
+ export function grantsTurnSpawning(scope) {
168
+ return TURN_SPAWNING_OPERATIONS.some((op) => isOperationAllowed(scope, op));
169
+ }
170
+ // ── OAuth scope vocabulary ─────────────────────────────────────────────────
171
+ // Two DIFFERENT granularities, deliberately:
172
+ //
173
+ // - Internally, a scope is a list of OPERATION names. That is the right
174
+ // granularity for an operator writing a config file, who wants to say
175
+ // exactly which verbs a CI token may call.
176
+ // - Over OAuth, scopes are coarse (`paddock:read` / `paddock:write`), because
177
+ // they are shown to a HUMAN on a consent screen. "Grant this app
178
+ // create_chat, fork_chat, fork_chat_batch, send_message…" is not a consent
179
+ // prompt anyone reads; "grant write access" is.
180
+ //
181
+ // So the coarse names are a projection of the fine-grained truth, used only in
182
+ // the `scope` parameter of a challenge and in the discovery document. Authorization
183
+ // is always decided on the fine-grained list.
184
+ export const OAUTH_SCOPE_READ = "paddock:read";
185
+ export const OAUTH_SCOPE_WRITE = "paddock:write";
186
+ /** Every OAuth scope this resource server understands (for discovery). */
187
+ export const OAUTH_SCOPES_SUPPORTED = [OAUTH_SCOPE_READ, OAUTH_SCOPE_WRITE];
188
+ /**
189
+ * The coarse OAuth scope an operation needs. Anything that mutates state or
190
+ * starts a turn is `write`; everything else is `read`.
191
+ */
192
+ export function requiredOauthScope(operation) {
193
+ if (READ_OPERATIONS.includes(operation)) {
194
+ return OAUTH_SCOPE_READ;
195
+ }
196
+ return operation === "list_triggers" ? OAUTH_SCOPE_READ : OAUTH_SCOPE_WRITE;
197
+ }
198
+ /** The coarse OAuth scopes a fine-grained scope amounts to. */
199
+ export function oauthScopesFor(scope) {
200
+ const out = [];
201
+ if (ALL_OPERATIONS.some((op) => isOperationAllowed(scope, op) && requiredOauthScope(op) === OAUTH_SCOPE_READ)) {
202
+ out.push(OAUTH_SCOPE_READ);
203
+ }
204
+ if (ALL_OPERATIONS.some((op) => isOperationAllowed(scope, op) && requiredOauthScope(op) === OAUTH_SCOPE_WRITE)) {
205
+ out.push(OAUTH_SCOPE_WRITE);
206
+ }
207
+ return out;
208
+ }
209
+ /** True when `scope` grants any operation that amounts to code execution. */
210
+ export function grantsCodeExecution(scope) {
211
+ return HIGH_RISK_OPERATIONS.some((op) => isOperationAllowed(scope, op));
212
+ }
213
+ /**
214
+ * Thrown when policy refuses a call. Carries the structured facts a transport
215
+ * needs to render its own error (MCP `isError` text, or an HTTP 403) and that
216
+ * the server log wants — `clientId` + `operation`, never the credential.
217
+ */
218
+ export class ManagementDeniedError extends Error {
219
+ code;
220
+ clientId;
221
+ operation;
222
+ projectSlug;
223
+ constructor(args) {
224
+ super(args.code === "operation_denied"
225
+ ? `not permitted: operation "${args.operation}" is outside this client's scope`
226
+ : `not permitted: project "${args.projectSlug}" is outside this client's scope`);
227
+ this.name = "ManagementDeniedError";
228
+ this.code = args.code;
229
+ this.clientId = args.clientId;
230
+ this.operation = args.operation;
231
+ this.projectSlug = args.projectSlug;
232
+ }
233
+ }
234
+ /** Throw unless `principal` may invoke `operation`. */
235
+ export function assertOperation(principal, operation) {
236
+ if (!isOperationAllowed(principal.scope, operation)) {
237
+ throw new ManagementDeniedError({
238
+ code: "operation_denied",
239
+ clientId: principal.clientId,
240
+ operation,
241
+ });
242
+ }
243
+ }
244
+ /**
245
+ * Throw unless `principal` may reach `projectSlug` via `operation`. Callers that
246
+ * ENUMERATE (list_projects/list_chats) must filter with {@link isProjectAllowed}
247
+ * instead — a scoped client listing its two projects should see those two, not a
248
+ * denial. This assert is for calls that name a target explicitly.
249
+ */
250
+ export function assertProject(principal, operation, projectSlug) {
251
+ if (!isProjectAllowed(principal.scope, projectSlug)) {
252
+ throw new ManagementDeniedError({
253
+ code: "project_denied",
254
+ clientId: principal.clientId,
255
+ operation,
256
+ projectSlug,
257
+ });
258
+ }
259
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * WHO caused a machine-injected turn. A human-typed message carries NO sender
3
+ * (absence = human, the quiet default), so this type only enumerates the machine
4
+ * sources. Mirrors the web's `MessageSender` (packages/web/src/lib/types.ts).
5
+ *
6
+ * - `chat` — another chat `send_message`d / forked / created this turn; carries
7
+ * the sending chat's project + sessionId (for a deep link) + the
8
+ * display name it had at injection time.
9
+ * - `schedule` — a schedule fire injected it; carries the schedule's name.
10
+ * - `hook` — an event hook fired it (Epic G / G1); carries the hook's name (and
11
+ * project) so a hook chat's kickoff turn is attributable.
12
+ * - `recovery` — Paddock's keeper-chat recovery nudged this turn (issue #301):
13
+ * a human clicked "Continue" on a killed-background-task
14
+ * affordance (Layer 2), or Layer 3 auto re-drove the hung keeper.
15
+ * - `agent` — a machine turn with no more specific identity (fallback).
16
+ */
17
+ export type MessageSender = {
18
+ kind: "chat";
19
+ project: string;
20
+ sessionId: string;
21
+ name?: string;
22
+ } | {
23
+ kind: "schedule";
24
+ name: string;
25
+ project?: string;
26
+ } | {
27
+ kind: "hook";
28
+ name: string;
29
+ project?: string;
30
+ } | {
31
+ kind: "recovery";
32
+ } | {
33
+ kind: "agent";
34
+ };
35
+ /** One recorded injection: its sender + the (possibly truncated) injected content. */
36
+ export interface InjectedMarker {
37
+ sender: MessageSender;
38
+ /** The injected prompt, trimmed and capped to {@link MAX_CONTENT}. */
39
+ content: string;
40
+ /** True when `content` was capped — the join then prefix-matches instead of ==. */
41
+ truncated?: boolean;
42
+ }
43
+ export declare class MessageProvenanceStore {
44
+ private readonly stateFile;
45
+ /** In-memory map of sessionId -> ordered injection markers (loaded once, written through). */
46
+ private state;
47
+ /** The in-flight load, cached so concurrent first-callers share ONE read (see RunProvenanceStore). */
48
+ private loadPromise;
49
+ /** Serialises concurrent writes so the file never interleaves. */
50
+ private writing;
51
+ constructor(dataDir: string);
52
+ /** Load the persisted map (lazily, deduped; tolerant of a missing/corrupt file). */
53
+ private ensureLoaded;
54
+ /** This chat's ordered injection markers, or [] if none. Non-throwing. */
55
+ list(sessionId: string): Promise<InjectedMarker[]>;
56
+ /**
57
+ * The chat that most likely CREATED `sessionId` — the sender of its very FIRST
58
+ * injection marker, and only if that sender is a chat.
59
+ *
60
+ * This is a backfill, not a recorded fact. A chat spawned via `create_chat` /
61
+ * `fork_chat` gets its kickoff prompt injected BY the creating chat, so the
62
+ * first marker is the parent. Later markers are ordinary `send_message` traffic
63
+ * (any chat can message any other), which is why only the first one counts.
64
+ *
65
+ * That "only the first one counts" rule is POSITIONAL (#491). Scanning for the
66
+ * first `chat`-KIND marker instead — as this did originally, despite the doc
67
+ * comment — makes a chat whose creation was NOT a chat injection adopt whichever
68
+ * chat happens to message it first: a schedule-fired chat is re-parented under
69
+ * the child that reports back to it. So a non-chat first marker means "not
70
+ * created by a chat", and returns null rather than searching on.
71
+ *
72
+ * Newer chats carry a real, recorded edge on RunProvenance.parentSessionId;
73
+ * prefer that and fall back here for chats created before it existed. Returns
74
+ * null when the chat has no markers or was never injected into by a chat — that
75
+ * includes forks with no kickoff prompt, which record nothing at all.
76
+ *
77
+ * Pure in-memory read once loaded, so the chat list can call it per row.
78
+ */
79
+ parentChat(sessionId: string): Promise<{
80
+ project: string;
81
+ sessionId: string;
82
+ name?: string;
83
+ } | null>;
84
+ /**
85
+ * Append one injection marker for `sessionId`. Order is significant — the join
86
+ * consumes markers in the order they were recorded — so this only ever appends.
87
+ * A blank/unsafe session id or malformed sender is ignored (best effort; the
88
+ * turn still runs, it just won't be attributed).
89
+ */
90
+ record(sessionId: string, sender: MessageSender, content: string): Promise<void>;
91
+ /** Write-through, serialised so overlapping records can't corrupt the file. */
92
+ private persist;
93
+ }
94
+ /**
95
+ * Attach `sender` to each machine-injected user message by joining the parsed
96
+ * transcript against this session's ordered injection markers (issue #290).
97
+ *
98
+ * The join is greedy and in-order: a pointer walks the markers; for each plain
99
+ * user-text message we test it against the NEXT unconsumed marker, and on a match
100
+ * attach that sender and advance the pointer. A message that doesn't match the
101
+ * pending marker is treated as human-typed (left unlabelled) WITHOUT advancing the
102
+ * pointer, so a later injected message can still claim the marker. Order alignment
103
+ * holds because injections into a session are serialised and the transcript
104
+ * preserves order. Returns the input untouched when there are no markers.
105
+ */
106
+ export declare function applyMessageProvenance<T extends {
107
+ role: string;
108
+ content: string;
109
+ toolCall?: unknown;
110
+ }>(messages: T[], markers: InjectedMarker[]): (T & {
111
+ sender?: MessageSender;
112
+ })[];
@@ -0,0 +1,260 @@
1
+ /**
2
+ * MessageProvenanceStore — a Paddock-side sidecar recording WHO injected each
3
+ * machine-added turn into a chat's transcript (issue #290). This is the
4
+ * per-MESSAGE analog of {@link RunProvenanceStore} (A1/#261), which records how a
5
+ * whole CHAT was created. As chats reliably inject messages into one another —
6
+ * `send_message` (a spawned child reporting back), scheduled fires, spawned-chat
7
+ * kickoffs — a transcript can interleave turns a HUMAN typed with turns a MACHINE
8
+ * added, and in the UI they look identical. This store lets the web attribute the
9
+ * machine-added ones ("↩ sent by <chat>", "⏰ scheduled by <name>").
10
+ *
11
+ * WHY a content-keyed sidecar (not a per-message uuid map): the injected user
12
+ * turn's transcript `uuid` is minted by Claude Code when it writes the JSONL line,
13
+ * so Paddock never sees it at injection time. But Paddock DOES know the exact
14
+ * prompt string it injected, and that string lands verbatim as the user message's
15
+ * content. So we record, per target session, an ORDERED list of injection markers
16
+ * carrying the sender + the injected content; at DTO-build time we walk the parsed
17
+ * messages and greedily match each machine-injected user message to the next
18
+ * unconsumed marker (see {@link applyMessageProvenance}). Injections into one
19
+ * session are serialised (herdctl keeper concurrency + same-session resume), and
20
+ * the transcript preserves order, so the in-order join is stable. A human-typed
21
+ * message won't match a pending marker's content, so it stays unlabelled (the
22
+ * default).
23
+ *
24
+ * Shape + durability mirror the other sidecars exactly: a `sessionId ->
25
+ * InjectedMarker[]` map persisted as a plain JSON object, lazy-loaded (with the
26
+ * in-flight load promise cached so concurrent first-callers can't lose an update),
27
+ * write-through, serialised, corruption-tolerant, `0o600`.
28
+ */
29
+ import { promises as fs } from "node:fs";
30
+ import path from "node:path";
31
+ const STATE_FILE = "message-provenance.json";
32
+ /**
33
+ * The longest injected-content prefix we persist per marker. Bounds sidecar size
34
+ * for a large `promptFile`-driven schedule while keeping ordinary short kickoffs
35
+ * matched exactly. Beyond this the match falls back to prefix equality (a full
36
+ * transcript content that STARTS WITH the stored prefix), which is unambiguous
37
+ * enough given the in-order consumption.
38
+ */
39
+ const MAX_CONTENT = 8192;
40
+ /** A session id we're willing to key on — mirrors RunProvenanceStore's guard. */
41
+ function isSafeId(sessionId) {
42
+ return typeof sessionId === "string" && /^[A-Za-z0-9._-]+$/.test(sessionId);
43
+ }
44
+ const SENDER_KINDS = new Set(["chat", "schedule", "hook", "recovery", "agent"]);
45
+ /** Validate + normalise an untrusted value into a MessageSender, or null. */
46
+ function coerceSender(value) {
47
+ if (!value || typeof value !== "object")
48
+ return null;
49
+ const o = value;
50
+ if (typeof o.kind !== "string" || !SENDER_KINDS.has(o.kind))
51
+ return null;
52
+ if (o.kind === "chat") {
53
+ if (typeof o.project !== "string" || typeof o.sessionId !== "string")
54
+ return null;
55
+ return {
56
+ kind: "chat",
57
+ project: o.project,
58
+ sessionId: o.sessionId,
59
+ ...(typeof o.name === "string" ? { name: o.name } : {}),
60
+ };
61
+ }
62
+ if (o.kind === "schedule") {
63
+ if (typeof o.name !== "string")
64
+ return null;
65
+ return {
66
+ kind: "schedule",
67
+ name: o.name,
68
+ ...(typeof o.project === "string" ? { project: o.project } : {}),
69
+ };
70
+ }
71
+ if (o.kind === "hook") {
72
+ if (typeof o.name !== "string")
73
+ return null;
74
+ return {
75
+ kind: "hook",
76
+ name: o.name,
77
+ ...(typeof o.project === "string" ? { project: o.project } : {}),
78
+ };
79
+ }
80
+ if (o.kind === "recovery")
81
+ return { kind: "recovery" };
82
+ return { kind: "agent" };
83
+ }
84
+ /** Validate + normalise an untrusted value into an InjectedMarker, or null. */
85
+ function coerceMarker(value) {
86
+ if (!value || typeof value !== "object")
87
+ return null;
88
+ const o = value;
89
+ const sender = coerceSender(o.sender);
90
+ if (!sender)
91
+ return null;
92
+ if (typeof o.content !== "string")
93
+ return null;
94
+ return { sender, content: o.content, ...(o.truncated === true ? { truncated: true } : {}) };
95
+ }
96
+ /** Normalise + cap injected content for storage; also used at match time. */
97
+ function normaliseContent(content) {
98
+ const trimmed = content.trim();
99
+ if (trimmed.length <= MAX_CONTENT)
100
+ return { content: trimmed, truncated: false };
101
+ return { content: trimmed.slice(0, MAX_CONTENT), truncated: true };
102
+ }
103
+ export class MessageProvenanceStore {
104
+ stateFile;
105
+ /** In-memory map of sessionId -> ordered injection markers (loaded once, written through). */
106
+ state = null;
107
+ /** The in-flight load, cached so concurrent first-callers share ONE read (see RunProvenanceStore). */
108
+ loadPromise = null;
109
+ /** Serialises concurrent writes so the file never interleaves. */
110
+ writing = Promise.resolve();
111
+ constructor(dataDir) {
112
+ this.stateFile = path.join(dataDir, STATE_FILE);
113
+ }
114
+ /** Load the persisted map (lazily, deduped; tolerant of a missing/corrupt file). */
115
+ ensureLoaded() {
116
+ if (this.state)
117
+ return Promise.resolve(this.state);
118
+ if (!this.loadPromise) {
119
+ this.loadPromise = (async () => {
120
+ const map = new Map();
121
+ try {
122
+ const raw = await fs.readFile(this.stateFile, "utf8");
123
+ const parsed = JSON.parse(raw);
124
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
125
+ for (const [k, v] of Object.entries(parsed)) {
126
+ if (!Array.isArray(v))
127
+ continue;
128
+ const markers = v.map(coerceMarker).filter((m) => m !== null);
129
+ if (markers.length)
130
+ map.set(k, markers);
131
+ }
132
+ }
133
+ }
134
+ catch {
135
+ /* missing or unreadable — start empty */
136
+ }
137
+ this.state = map;
138
+ return map;
139
+ })();
140
+ }
141
+ return this.loadPromise;
142
+ }
143
+ /** This chat's ordered injection markers, or [] if none. Non-throwing. */
144
+ async list(sessionId) {
145
+ if (!isSafeId(sessionId))
146
+ return [];
147
+ const map = await this.ensureLoaded().catch(() => new Map());
148
+ return map.get(sessionId) ?? [];
149
+ }
150
+ /**
151
+ * The chat that most likely CREATED `sessionId` — the sender of its very FIRST
152
+ * injection marker, and only if that sender is a chat.
153
+ *
154
+ * This is a backfill, not a recorded fact. A chat spawned via `create_chat` /
155
+ * `fork_chat` gets its kickoff prompt injected BY the creating chat, so the
156
+ * first marker is the parent. Later markers are ordinary `send_message` traffic
157
+ * (any chat can message any other), which is why only the first one counts.
158
+ *
159
+ * That "only the first one counts" rule is POSITIONAL (#491). Scanning for the
160
+ * first `chat`-KIND marker instead — as this did originally, despite the doc
161
+ * comment — makes a chat whose creation was NOT a chat injection adopt whichever
162
+ * chat happens to message it first: a schedule-fired chat is re-parented under
163
+ * the child that reports back to it. So a non-chat first marker means "not
164
+ * created by a chat", and returns null rather than searching on.
165
+ *
166
+ * Newer chats carry a real, recorded edge on RunProvenance.parentSessionId;
167
+ * prefer that and fall back here for chats created before it existed. Returns
168
+ * null when the chat has no markers or was never injected into by a chat — that
169
+ * includes forks with no kickoff prompt, which record nothing at all.
170
+ *
171
+ * Pure in-memory read once loaded, so the chat list can call it per row.
172
+ */
173
+ async parentChat(sessionId) {
174
+ const [first] = await this.list(sessionId);
175
+ if (first?.sender.kind !== "chat")
176
+ return null;
177
+ // Never let a corrupt record make a chat its own parent — buildChatTree
178
+ // guards cycles too, but a self-edge is meaningless this far up.
179
+ if (first.sender.sessionId === sessionId)
180
+ return null;
181
+ return {
182
+ project: first.sender.project,
183
+ sessionId: first.sender.sessionId,
184
+ ...(first.sender.name ? { name: first.sender.name } : {}),
185
+ };
186
+ }
187
+ /**
188
+ * Append one injection marker for `sessionId`. Order is significant — the join
189
+ * consumes markers in the order they were recorded — so this only ever appends.
190
+ * A blank/unsafe session id or malformed sender is ignored (best effort; the
191
+ * turn still runs, it just won't be attributed).
192
+ */
193
+ async record(sessionId, sender, content) {
194
+ if (!isSafeId(sessionId))
195
+ return;
196
+ const s = coerceSender(sender);
197
+ if (!s)
198
+ return;
199
+ const { content: capped, truncated } = normaliseContent(content);
200
+ const marker = { sender: s, content: capped, ...(truncated ? { truncated: true } : {}) };
201
+ const map = await this.ensureLoaded();
202
+ const list = map.get(sessionId);
203
+ if (list)
204
+ list.push(marker);
205
+ else
206
+ map.set(sessionId, [marker]);
207
+ await this.persist(map);
208
+ }
209
+ /** Write-through, serialised so overlapping records can't corrupt the file. */
210
+ persist(map) {
211
+ this.writing = this.writing.then(async () => {
212
+ const obj = {};
213
+ for (const [k, v] of map)
214
+ obj[k] = v;
215
+ const json = JSON.stringify(obj, null, 2);
216
+ await fs.writeFile(this.stateFile, json, { encoding: "utf8", mode: 0o600 }).catch(() => undefined);
217
+ });
218
+ return this.writing;
219
+ }
220
+ }
221
+ /** True when a message is a plain user text turn (a candidate injected turn). */
222
+ function isPlainUserText(m) {
223
+ if (m.role !== "user" || m.toolCall)
224
+ return false;
225
+ // A background `<task-notification>` is a harness-injected user line, not a
226
+ // send_message/schedule injection — never attribute it to a sender.
227
+ return !m.content.trimStart().startsWith("<task-notification>");
228
+ }
229
+ /** Does a parsed message's content satisfy a marker (exact, or prefix if capped)? */
230
+ function matchesMarker(content, marker) {
231
+ const trimmed = content.trim();
232
+ return marker.truncated ? trimmed.startsWith(marker.content) : trimmed === marker.content;
233
+ }
234
+ /**
235
+ * Attach `sender` to each machine-injected user message by joining the parsed
236
+ * transcript against this session's ordered injection markers (issue #290).
237
+ *
238
+ * The join is greedy and in-order: a pointer walks the markers; for each plain
239
+ * user-text message we test it against the NEXT unconsumed marker, and on a match
240
+ * attach that sender and advance the pointer. A message that doesn't match the
241
+ * pending marker is treated as human-typed (left unlabelled) WITHOUT advancing the
242
+ * pointer, so a later injected message can still claim the marker. Order alignment
243
+ * holds because injections into a session are serialised and the transcript
244
+ * preserves order. Returns the input untouched when there are no markers.
245
+ */
246
+ export function applyMessageProvenance(messages, markers) {
247
+ if (markers.length === 0)
248
+ return messages;
249
+ let i = 0;
250
+ return messages.map((m) => {
251
+ if (i >= markers.length || !isPlainUserText(m))
252
+ return m;
253
+ if (matchesMarker(m.content, markers[i])) {
254
+ const sender = markers[i].sender;
255
+ i++;
256
+ return { ...m, sender };
257
+ }
258
+ return m;
259
+ });
260
+ }