@plurnk/plurnk-service 1.0.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 (343) hide show
  1. package/.env.defaults +208 -0
  2. package/INSTALL.md +69 -0
  3. package/LICENSE +21 -0
  4. package/README.md +46 -0
  5. package/SPEC.md +1511 -0
  6. package/dist/Paths.d.ts +13 -0
  7. package/dist/Paths.d.ts.map +1 -0
  8. package/dist/Paths.js +60 -0
  9. package/dist/Paths.js.map +1 -0
  10. package/dist/content/edited-span.d.ts +2 -0
  11. package/dist/content/edited-span.d.ts.map +1 -0
  12. package/dist/content/edited-span.js +33 -0
  13. package/dist/content/edited-span.js.map +1 -0
  14. package/dist/content/index.d.ts +10 -0
  15. package/dist/content/index.d.ts.map +1 -0
  16. package/dist/content/index.js +11 -0
  17. package/dist/content/index.js.map +1 -0
  18. package/dist/content/line-marker.d.ts +15 -0
  19. package/dist/content/line-marker.d.ts.map +1 -0
  20. package/dist/content/line-marker.js +21 -0
  21. package/dist/content/line-marker.js.map +1 -0
  22. package/dist/content/matcher.d.ts +44 -0
  23. package/dist/content/matcher.d.ts.map +1 -0
  24. package/dist/content/matcher.js +126 -0
  25. package/dist/content/matcher.js.map +1 -0
  26. package/dist/content/mimetype-binary.d.ts +8 -0
  27. package/dist/content/mimetype-binary.d.ts.map +1 -0
  28. package/dist/content/mimetype-binary.js +17 -0
  29. package/dist/content/mimetype-binary.js.map +1 -0
  30. package/dist/content/path-mimetype.d.ts +5 -0
  31. package/dist/content/path-mimetype.d.ts.map +1 -0
  32. package/dist/content/path-mimetype.js +15 -0
  33. package/dist/content/path-mimetype.js.map +1 -0
  34. package/dist/content/read-resolve.d.ts +20 -0
  35. package/dist/content/read-resolve.d.ts.map +1 -0
  36. package/dist/content/read-resolve.js +68 -0
  37. package/dist/content/read-resolve.js.map +1 -0
  38. package/dist/core/ChannelWrite.d.ts +100 -0
  39. package/dist/core/ChannelWrite.d.ts.map +1 -0
  40. package/dist/core/ChannelWrite.js +112 -0
  41. package/dist/core/ChannelWrite.js.map +1 -0
  42. package/dist/core/ChannelWrite.sql +84 -0
  43. package/dist/core/Db.d.ts +14 -0
  44. package/dist/core/Db.d.ts.map +1 -0
  45. package/dist/core/Db.js +7 -0
  46. package/dist/core/Db.js.map +1 -0
  47. package/dist/core/Dispatcher.d.ts +67 -0
  48. package/dist/core/Dispatcher.d.ts.map +1 -0
  49. package/dist/core/Dispatcher.js +1021 -0
  50. package/dist/core/Dispatcher.js.map +1 -0
  51. package/dist/core/Engine.d.ts +113 -0
  52. package/dist/core/Engine.d.ts.map +1 -0
  53. package/dist/core/Engine.js +1410 -0
  54. package/dist/core/Engine.js.map +1 -0
  55. package/dist/core/Engine.sql +538 -0
  56. package/dist/core/EnvFlags.d.ts +13 -0
  57. package/dist/core/EnvFlags.d.ts.map +1 -0
  58. package/dist/core/EnvFlags.js +84 -0
  59. package/dist/core/EnvFlags.js.map +1 -0
  60. package/dist/core/ExecutorRegistry.d.ts +45 -0
  61. package/dist/core/ExecutorRegistry.d.ts.map +1 -0
  62. package/dist/core/ExecutorRegistry.js +128 -0
  63. package/dist/core/ExecutorRegistry.js.map +1 -0
  64. package/dist/core/PacketBuilder.d.ts +67 -0
  65. package/dist/core/PacketBuilder.d.ts.map +1 -0
  66. package/dist/core/PacketBuilder.js +524 -0
  67. package/dist/core/PacketBuilder.js.map +1 -0
  68. package/dist/core/PluginLoader.d.ts +17 -0
  69. package/dist/core/PluginLoader.d.ts.map +1 -0
  70. package/dist/core/PluginLoader.js +97 -0
  71. package/dist/core/PluginLoader.js.map +1 -0
  72. package/dist/core/ProposalLifecycle.d.ts +59 -0
  73. package/dist/core/ProposalLifecycle.d.ts.map +1 -0
  74. package/dist/core/ProposalLifecycle.js +215 -0
  75. package/dist/core/ProposalLifecycle.js.map +1 -0
  76. package/dist/core/ProviderInstantiate.d.ts +9 -0
  77. package/dist/core/ProviderInstantiate.d.ts.map +1 -0
  78. package/dist/core/ProviderInstantiate.js +156 -0
  79. package/dist/core/ProviderInstantiate.js.map +1 -0
  80. package/dist/core/SchemeRegistry.d.ts +26 -0
  81. package/dist/core/SchemeRegistry.d.ts.map +1 -0
  82. package/dist/core/SchemeRegistry.js +205 -0
  83. package/dist/core/SchemeRegistry.js.map +1 -0
  84. package/dist/core/StrikeRail.d.ts +28 -0
  85. package/dist/core/StrikeRail.d.ts.map +1 -0
  86. package/dist/core/StrikeRail.js +165 -0
  87. package/dist/core/StrikeRail.js.map +1 -0
  88. package/dist/core/TelemetryChannel.d.ts +37 -0
  89. package/dist/core/TelemetryChannel.d.ts.map +1 -0
  90. package/dist/core/TelemetryChannel.js +77 -0
  91. package/dist/core/TelemetryChannel.js.map +1 -0
  92. package/dist/core/TokenGauge.d.ts +16 -0
  93. package/dist/core/TokenGauge.d.ts.map +1 -0
  94. package/dist/core/TokenGauge.js +58 -0
  95. package/dist/core/TokenGauge.js.map +1 -0
  96. package/dist/core/caps/CapsResolve.d.ts +5 -0
  97. package/dist/core/caps/CapsResolve.d.ts.map +1 -0
  98. package/dist/core/caps/CapsResolve.js +13 -0
  99. package/dist/core/caps/CapsResolve.js.map +1 -0
  100. package/dist/core/caps/DbChannelCaps.d.ts +16 -0
  101. package/dist/core/caps/DbChannelCaps.d.ts.map +1 -0
  102. package/dist/core/caps/DbChannelCaps.js +41 -0
  103. package/dist/core/caps/DbChannelCaps.js.map +1 -0
  104. package/dist/core/caps/DbEntryCaps.d.ts +19 -0
  105. package/dist/core/caps/DbEntryCaps.d.ts.map +1 -0
  106. package/dist/core/caps/DbEntryCaps.js +28 -0
  107. package/dist/core/caps/DbEntryCaps.js.map +1 -0
  108. package/dist/core/caps/DbNotifyCaps.d.ts +8 -0
  109. package/dist/core/caps/DbNotifyCaps.d.ts.map +1 -0
  110. package/dist/core/caps/DbNotifyCaps.js +35 -0
  111. package/dist/core/caps/DbNotifyCaps.js.map +1 -0
  112. package/dist/core/caps/DbSubscriptionCaps.d.ts +10 -0
  113. package/dist/core/caps/DbSubscriptionCaps.d.ts.map +1 -0
  114. package/dist/core/caps/DbSubscriptionCaps.js +86 -0
  115. package/dist/core/caps/DbSubscriptionCaps.js.map +1 -0
  116. package/dist/core/caps/DbTagCaps.d.ts +17 -0
  117. package/dist/core/caps/DbTagCaps.d.ts.map +1 -0
  118. package/dist/core/caps/DbTagCaps.js +37 -0
  119. package/dist/core/caps/DbTagCaps.js.map +1 -0
  120. package/dist/core/caps/SchemeCtxImpl.d.ts +18 -0
  121. package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -0
  122. package/dist/core/caps/SchemeCtxImpl.js +42 -0
  123. package/dist/core/caps/SchemeCtxImpl.js.map +1 -0
  124. package/dist/core/env-defaults.d.ts +19 -0
  125. package/dist/core/env-defaults.d.ts.map +1 -0
  126. package/dist/core/env-defaults.js +89 -0
  127. package/dist/core/env-defaults.js.map +1 -0
  128. package/dist/core/fork.d.ts +6 -0
  129. package/dist/core/fork.d.ts.map +1 -0
  130. package/dist/core/fork.js +84 -0
  131. package/dist/core/fork.js.map +1 -0
  132. package/dist/core/fork.sql +85 -0
  133. package/dist/core/git-membership.d.ts +23 -0
  134. package/dist/core/git-membership.d.ts.map +1 -0
  135. package/dist/core/git-membership.js +366 -0
  136. package/dist/core/git-membership.js.map +1 -0
  137. package/dist/core/git-state.d.ts +15 -0
  138. package/dist/core/git-state.d.ts.map +1 -0
  139. package/dist/core/git-state.js +67 -0
  140. package/dist/core/git-state.js.map +1 -0
  141. package/dist/core/packet-inject.d.ts +5 -0
  142. package/dist/core/packet-inject.d.ts.map +1 -0
  143. package/dist/core/packet-inject.js +48 -0
  144. package/dist/core/packet-inject.js.map +1 -0
  145. package/dist/core/packet-wire.d.ts +46 -0
  146. package/dist/core/packet-wire.d.ts.map +1 -0
  147. package/dist/core/packet-wire.js +453 -0
  148. package/dist/core/packet-wire.js.map +1 -0
  149. package/dist/core/path-decode.d.ts +2 -0
  150. package/dist/core/path-decode.d.ts.map +1 -0
  151. package/dist/core/path-decode.js +8 -0
  152. package/dist/core/path-decode.js.map +1 -0
  153. package/dist/core/plugin-attribution.d.ts +5 -0
  154. package/dist/core/plugin-attribution.d.ts.map +1 -0
  155. package/dist/core/plugin-attribution.js +39 -0
  156. package/dist/core/plugin-attribution.js.map +1 -0
  157. package/dist/core/plurnk-uri.d.ts +7 -0
  158. package/dist/core/plurnk-uri.d.ts.map +1 -0
  159. package/dist/core/plurnk-uri.js +52 -0
  160. package/dist/core/plurnk-uri.js.map +1 -0
  161. package/dist/core/resolveForLoop.d.ts +5 -0
  162. package/dist/core/resolveForLoop.d.ts.map +1 -0
  163. package/dist/core/resolveForLoop.js +12 -0
  164. package/dist/core/resolveForLoop.js.map +1 -0
  165. package/dist/core/results.d.ts +12 -0
  166. package/dist/core/results.d.ts.map +1 -0
  167. package/dist/core/results.js +27 -0
  168. package/dist/core/results.js.map +1 -0
  169. package/dist/core/run-cap.d.ts +8 -0
  170. package/dist/core/run-cap.d.ts.map +1 -0
  171. package/dist/core/run-cap.js +20 -0
  172. package/dist/core/run-cap.js.map +1 -0
  173. package/dist/core/run-ops.sql +43 -0
  174. package/dist/core/scheme-types.d.ts +45 -0
  175. package/dist/core/scheme-types.d.ts.map +1 -0
  176. package/dist/core/scheme-types.js +6 -0
  177. package/dist/core/scheme-types.js.map +1 -0
  178. package/dist/core/session-settings.d.ts +23 -0
  179. package/dist/core/session-settings.d.ts.map +1 -0
  180. package/dist/core/session-settings.js +55 -0
  181. package/dist/core/session-settings.js.map +1 -0
  182. package/dist/core/teaching.d.ts +3 -0
  183. package/dist/core/teaching.d.ts.map +1 -0
  184. package/dist/core/teaching.js +13 -0
  185. package/dist/core/teaching.js.map +1 -0
  186. package/dist/core/types.d.ts +3 -0
  187. package/dist/core/types.d.ts.map +1 -0
  188. package/dist/core/types.js +2 -0
  189. package/dist/core/types.js.map +1 -0
  190. package/dist/digest/Digest.d.ts +20 -0
  191. package/dist/digest/Digest.d.ts.map +1 -0
  192. package/dist/digest/Digest.js +451 -0
  193. package/dist/digest/Digest.js.map +1 -0
  194. package/dist/digest/Digest.sql +1 -0
  195. package/dist/digest/digest.sql +55 -0
  196. package/dist/index.d.ts +18 -0
  197. package/dist/index.d.ts.map +1 -0
  198. package/dist/index.js +28 -0
  199. package/dist/index.js.map +1 -0
  200. package/dist/schemes/EffectPolicy.d.ts +6 -0
  201. package/dist/schemes/EffectPolicy.d.ts.map +1 -0
  202. package/dist/schemes/EffectPolicy.js +18 -0
  203. package/dist/schemes/EffectPolicy.js.map +1 -0
  204. package/dist/schemes/Exec.d.ts +39 -0
  205. package/dist/schemes/Exec.d.ts.map +1 -0
  206. package/dist/schemes/Exec.js +510 -0
  207. package/dist/schemes/Exec.js.map +1 -0
  208. package/dist/schemes/ExecOutputScheme.d.ts +20 -0
  209. package/dist/schemes/ExecOutputScheme.d.ts.map +1 -0
  210. package/dist/schemes/ExecOutputScheme.js +39 -0
  211. package/dist/schemes/ExecOutputScheme.js.map +1 -0
  212. package/dist/schemes/File.d.ts +42 -0
  213. package/dist/schemes/File.d.ts.map +1 -0
  214. package/dist/schemes/File.js +356 -0
  215. package/dist/schemes/File.js.map +1 -0
  216. package/dist/schemes/Known.d.ts +17 -0
  217. package/dist/schemes/Known.d.ts.map +1 -0
  218. package/dist/schemes/Known.js +40 -0
  219. package/dist/schemes/Known.js.map +1 -0
  220. package/dist/schemes/Log.d.ts +22 -0
  221. package/dist/schemes/Log.d.ts.map +1 -0
  222. package/dist/schemes/Log.js +269 -0
  223. package/dist/schemes/Log.js.map +1 -0
  224. package/dist/schemes/Log.sql +59 -0
  225. package/dist/schemes/Plurnk.d.ts +17 -0
  226. package/dist/schemes/Plurnk.d.ts.map +1 -0
  227. package/dist/schemes/Plurnk.js +46 -0
  228. package/dist/schemes/Plurnk.js.map +1 -0
  229. package/dist/schemes/Run.d.ts +25 -0
  230. package/dist/schemes/Run.d.ts.map +1 -0
  231. package/dist/schemes/Run.js +172 -0
  232. package/dist/schemes/Run.js.map +1 -0
  233. package/dist/schemes/Skill.d.ts +17 -0
  234. package/dist/schemes/Skill.d.ts.map +1 -0
  235. package/dist/schemes/Skill.js +40 -0
  236. package/dist/schemes/Skill.js.map +1 -0
  237. package/dist/schemes/Unknown.d.ts +17 -0
  238. package/dist/schemes/Unknown.d.ts.map +1 -0
  239. package/dist/schemes/Unknown.js +39 -0
  240. package/dist/schemes/Unknown.js.map +1 -0
  241. package/dist/schemes/_entry-chunk.d.ts +11 -0
  242. package/dist/schemes/_entry-chunk.d.ts.map +1 -0
  243. package/dist/schemes/_entry-chunk.js +100 -0
  244. package/dist/schemes/_entry-chunk.js.map +1 -0
  245. package/dist/schemes/_entry-crud.d.ts +31 -0
  246. package/dist/schemes/_entry-crud.d.ts.map +1 -0
  247. package/dist/schemes/_entry-crud.js +77 -0
  248. package/dist/schemes/_entry-crud.js.map +1 -0
  249. package/dist/schemes/_entry-crud.sql +101 -0
  250. package/dist/schemes/_entry-find.d.ts +32 -0
  251. package/dist/schemes/_entry-find.d.ts.map +1 -0
  252. package/dist/schemes/_entry-find.js +226 -0
  253. package/dist/schemes/_entry-find.js.map +1 -0
  254. package/dist/schemes/_entry-find.sql +54 -0
  255. package/dist/schemes/_entry-graph.d.ts +16 -0
  256. package/dist/schemes/_entry-graph.d.ts.map +1 -0
  257. package/dist/schemes/_entry-graph.js +94 -0
  258. package/dist/schemes/_entry-graph.js.map +1 -0
  259. package/dist/schemes/_entry-graph.sql +52 -0
  260. package/dist/schemes/_entry-manifest.d.ts +25 -0
  261. package/dist/schemes/_entry-manifest.d.ts.map +1 -0
  262. package/dist/schemes/_entry-manifest.js +266 -0
  263. package/dist/schemes/_entry-manifest.js.map +1 -0
  264. package/dist/schemes/_entry-ops.d.ts +32 -0
  265. package/dist/schemes/_entry-ops.d.ts.map +1 -0
  266. package/dist/schemes/_entry-ops.js +216 -0
  267. package/dist/schemes/_entry-ops.js.map +1 -0
  268. package/dist/schemes/_entry-ops.sql +31 -0
  269. package/dist/schemes/_entry-semantic.d.ts +38 -0
  270. package/dist/schemes/_entry-semantic.d.ts.map +1 -0
  271. package/dist/schemes/_entry-semantic.js +202 -0
  272. package/dist/schemes/_entry-semantic.js.map +1 -0
  273. package/dist/schemes/_entry-semantic.sql +95 -0
  274. package/dist/schemes/_entry-send.d.ts +11 -0
  275. package/dist/schemes/_entry-send.d.ts.map +1 -0
  276. package/dist/schemes/_entry-send.js +66 -0
  277. package/dist/schemes/_entry-send.js.map +1 -0
  278. package/dist/schemes/cosine.d.ts +4 -0
  279. package/dist/schemes/cosine.d.ts.map +1 -0
  280. package/dist/schemes/cosine.js +9 -0
  281. package/dist/schemes/cosine.js.map +1 -0
  282. package/dist/schemes/exec-abort.d.ts +15 -0
  283. package/dist/schemes/exec-abort.d.ts.map +1 -0
  284. package/dist/schemes/exec-abort.js +29 -0
  285. package/dist/schemes/exec-abort.js.map +1 -0
  286. package/dist/schemes/exec-env.d.ts +5 -0
  287. package/dist/schemes/exec-env.d.ts.map +1 -0
  288. package/dist/schemes/exec-env.js +34 -0
  289. package/dist/schemes/exec-env.js.map +1 -0
  290. package/dist/server/Daemon.d.ts +244 -0
  291. package/dist/server/Daemon.d.ts.map +1 -0
  292. package/dist/server/Daemon.js +1119 -0
  293. package/dist/server/Daemon.js.map +1 -0
  294. package/dist/server/client-input.d.ts +11 -0
  295. package/dist/server/client-input.d.ts.map +1 -0
  296. package/dist/server/client-input.js +145 -0
  297. package/dist/server/client-input.js.map +1 -0
  298. package/dist/server/clientTurn.d.ts +6 -0
  299. package/dist/server/clientTurn.d.ts.map +1 -0
  300. package/dist/server/clientTurn.js +23 -0
  301. package/dist/server/clientTurn.js.map +1 -0
  302. package/dist/server/clientTurn.sql +10 -0
  303. package/dist/server/dispatch-as-plurnk.d.ts +7 -0
  304. package/dist/server/dispatch-as-plurnk.d.ts.map +1 -0
  305. package/dist/server/dispatch-as-plurnk.js +23 -0
  306. package/dist/server/dispatch-as-plurnk.js.map +1 -0
  307. package/dist/server/drain.sql +124 -0
  308. package/dist/server/envelope.d.ts +53 -0
  309. package/dist/server/envelope.d.ts.map +1 -0
  310. package/dist/server/envelope.js +172 -0
  311. package/dist/server/envelope.js.map +1 -0
  312. package/dist/server/envelope.sql +76 -0
  313. package/dist/server/logEntry.d.ts +35 -0
  314. package/dist/server/logEntry.d.ts.map +1 -0
  315. package/dist/server/logEntry.js +49 -0
  316. package/dist/server/logEntry.js.map +1 -0
  317. package/dist/server/logEntry.sql +10 -0
  318. package/dist/server/loopDocs.d.ts +6 -0
  319. package/dist/server/loopDocs.d.ts.map +1 -0
  320. package/dist/server/loopDocs.js +22 -0
  321. package/dist/server/loopDocs.js.map +1 -0
  322. package/dist/server/noProposals.d.ts +6 -0
  323. package/dist/server/noProposals.d.ts.map +1 -0
  324. package/dist/server/noProposals.js +37 -0
  325. package/dist/server/noProposals.js.map +1 -0
  326. package/dist/server/seam-entry-read.sql +21 -0
  327. package/dist/server/seam-log-read.sql +21 -0
  328. package/dist/server/seam-loop.sql +9 -0
  329. package/dist/server/seam-proposal-list.sql +13 -0
  330. package/dist/server/version-info.d.ts +14 -0
  331. package/dist/server/version-info.d.ts.map +1 -0
  332. package/dist/server/version-info.js +69 -0
  333. package/dist/server/version-info.js.map +1 -0
  334. package/dist/server/yolo.d.ts +6 -0
  335. package/dist/server/yolo.d.ts.map +1 -0
  336. package/dist/server/yolo.js +64 -0
  337. package/dist/server/yolo.js.map +1 -0
  338. package/dist/service.d.ts +6 -0
  339. package/dist/service.d.ts.map +1 -0
  340. package/dist/service.js +309 -0
  341. package/dist/service.js.map +1 -0
  342. package/migrations/0000-00-00.01_schema.sql +500 -0
  343. package/package.json +86 -0
@@ -0,0 +1,1021 @@
1
+ var _a;
2
+ import { parsePath } from "@plurnk/plurnk-grammar";
3
+ import { foldAuthorityIntoPath, schemeNameOf } from "./plurnk-uri.js";
4
+ import Fork from "./fork.js";
5
+ import RunCap from "./run-cap.js";
6
+ import { decodePathParens } from "./path-decode.js";
7
+ import { DEFAULT_LOOP_FLAGS } from "./scheme-types.js";
8
+ import { LineMarkerOps, MimetypeBinary } from "../content/index.js";
9
+ import SchemeCtxImpl from "./caps/SchemeCtxImpl.js";
10
+ import SessionSettings from "./session-settings.js";
11
+ // SPEC §scheme-surface: writer must be in target scheme's manifest.writableBy.
12
+ // OPEN/FOLD/READ/FIND are not gated — they curate the log or read, never mutating an entry.
13
+ const MUTATING_OPS = new Set(["EDIT", "SEND", "COPY", "MOVE", "EXEC", "KILL", "FORK", "WORK"]);
14
+ const pathnameFromPath = (path) => {
15
+ if (path.kind === "regex")
16
+ return path.raw; // regex source — parens are syntax, never encoded
17
+ return decodePathParens(path.kind === "url" ? path.pathname : path.raw); // #239 item 4
18
+ };
19
+ // Op dispatch (§op-methods-op-dispatch): gates (writableBy, loop flags), the
20
+ // engine-owned op orchestrations (COPY/MOVE/KILL/SEND/READ-fanout), scheme
21
+ // routing, the durable log write, and the proposal pause.
22
+ class Dispatcher {
23
+ #db;
24
+ #schemes;
25
+ #mimetypes;
26
+ #tokenize;
27
+ #telemetry;
28
+ #proposals;
29
+ // Boot-discovered runtime executors, late-injected on Engine — thunked.
30
+ #executors;
31
+ // Per-loop abort signal, owned by Engine.runLoop — thunked.
32
+ #loopSignal;
33
+ #streamEventNotify;
34
+ #wakeRunNotify;
35
+ #injectRun;
36
+ #cancelRun;
37
+ // §send-premature-terminate/[102]<T> — the engine-owned park-deadline registry (loopId → seconds;
38
+ // -1 = indefinite). The dispatcher WRITES at park; the daemon's drain park-exit consumes.
39
+ #parkDeadlines;
40
+ // §join-blocking-collect (#354) — loops with a READ(run://running-child) armed this turn; a bare
41
+ // SEND[102] parks on it (blocking join). Engine-owned, twin of #parkDeadlines.
42
+ #joinTargets;
43
+ constructor({ db, schemes, mimetypes, tokenize, telemetry, proposals, executors, loopSignal, streamEventNotify, wakeRunNotify, injectRun, cancelRun, parkDeadlines, joinTargets }) {
44
+ this.#db = db;
45
+ this.#schemes = schemes;
46
+ this.#mimetypes = mimetypes;
47
+ this.#tokenize = tokenize;
48
+ this.#telemetry = telemetry;
49
+ this.#proposals = proposals;
50
+ this.#executors = executors;
51
+ this.#loopSignal = loopSignal;
52
+ this.#streamEventNotify = streamEventNotify;
53
+ this.#wakeRunNotify = wakeRunNotify;
54
+ this.#injectRun = injectRun;
55
+ this.#cancelRun = cancelRun;
56
+ this.#parkDeadlines = parkDeadlines ?? new Map();
57
+ this.#joinTargets = joinTargets ?? new Set();
58
+ }
59
+ async dispatch(context) {
60
+ const { statement, sessionId, runId, loopId, turnId, sequence, origin, onDispatch, gauge, turnParseErrors } = context;
61
+ const schemeCtx = this.#buildSchemeCtx({ sessionId, runId, loopId, turnId, origin, gauge });
62
+ let result;
63
+ let denial = this.#checkWritable(statement, origin);
64
+ if (denial === null)
65
+ denial = await this.#checkFlagsGate(statement, loopId);
66
+ if (denial !== null) {
67
+ result = denial;
68
+ }
69
+ else if (_a.#readFansOut(statement)) {
70
+ // READ honors FIND: a glob/folder scope or a matcher fans out to one log row per MATCH
71
+ // (its own writeLogs), returning early. A READ never proposes, so it bypasses the
72
+ // single-row path below. A bare entry, body-less, falls through to the direct read. #286
73
+ return await this.#handleReadFanout(statement, schemeCtx, { runId, loopId, turnId, sequence, origin, onDispatch });
74
+ }
75
+ else {
76
+ // SPEC §scheme-surface + plurnk-schemes#1: action-entry-as-outcome. Scheme-handler
77
+ // exceptions become the action-entry's outcome (status 500), not a
78
+ // thrown bubble. The log_entry is the durable record; engine never
79
+ // skips it. Logging failures (#writeLog throws) are NOT caught —
80
+ // those are system failures.
81
+ try {
82
+ if (statement.op === "SEND" && statement.target === null) {
83
+ result = await this.#handleSendBroadcast(statement, { sessionId, runId, loopId, turnId, turnParseErrors });
84
+ }
85
+ else if (statement.op === "FORK" || statement.op === "WORK") {
86
+ result = await this.#handleRunControl(statement, schemeCtx);
87
+ }
88
+ else if (statement.op === "COPY") {
89
+ result = await this.#handleCopy(statement, schemeCtx);
90
+ }
91
+ else if (statement.op === "MOVE") {
92
+ result = await this.#handleMove(statement, schemeCtx);
93
+ }
94
+ else if (statement.op === "KILL") {
95
+ result = await this.#handleKill(statement, schemeCtx);
96
+ }
97
+ else if (statement.op === "PLAN") {
98
+ result = this.#handlePlan(statement);
99
+ }
100
+ else if (statement.op === "EXEC") {
101
+ // EXEC's target slot is `cwd`, not a scheme address.
102
+ // Per plurnk.md the op routes unconditionally to the
103
+ // exec scheme; the scheme handler reads runtime
104
+ // (signal), cwd (target), and command (body).
105
+ result = await this.#run("exec", statement, schemeCtx);
106
+ }
107
+ else {
108
+ result = await this.#run(schemeNameOf(statement.target), statement, schemeCtx); // §op-methods-op-dispatch
109
+ }
110
+ }
111
+ catch (err) { // a scheme exception becomes the op's 500 outcome — §scheme-surface-exception-500
112
+ result = {
113
+ status: 500,
114
+ error: err instanceof Error ? err.message : String(err),
115
+ };
116
+ }
117
+ }
118
+ // §fold-open-meta-operations — OPEN/FOLD are render directives, not actions. They ARE
119
+ // recorded (#382 — a curation act with no forensic trace is how run43's task-frame fold
120
+ // stayed invisible), but engine_render_log suppresses them from the packet, so the receipt
121
+ // rents zero model-facing space — the original clutter concern, resolved by hide-not-drop.
122
+ // §join-blocking-collect (#354) — Run.read on a still-running child returns an awaitRun signal;
123
+ // arm the join so THIS turn's bare SEND[102] parks (the blocking collect) instead of spinning.
124
+ if (typeof result.awaitRun === "string")
125
+ this.#joinTargets.add(loopId);
126
+ const logEntryId = await this.#writeLog({ statement, result, runId, loopId, turnId, sequence, origin });
127
+ onDispatch?.(logEntryId);
128
+ // Proposal lifecycle (SPEC.md §engine-rails + §methods loop.resolve; §proposal-202-pauses). When a
129
+ // side-effecting op returns status 202 (a broadcast SEND[202] park is model
130
+ // speech, not a proposal — #isProposal, #255), the entry is written
131
+ // state='proposed'; dispatch then PAUSES on a per-entry waiter until
132
+ // resolution arrives via Engine.resolveProposal (from the loop/resolve RPC,
133
+ // YOLO listener, or timeout). The post-resolution status replaces 202 in the
134
+ // result the caller sees, so runTurn never branches on a pending state.
135
+ if (_a.#isProposal(statement, result)) {
136
+ // Effect-gated auto-run (read/pure runtimes, plurnk-service#182):
137
+ // no human gate, no loop/proposal notification. Accept + apply
138
+ // in-process; the model sees the outcome directly, never a review.
139
+ if (result.attrs?.inline === true) {
140
+ const effective = await this.#proposals.runApply(statement, result, { decision: "accept" }, { sessionId, runId, loopId, turnId });
141
+ return this.#proposals.applyResolution(logEntryId, effective);
142
+ }
143
+ // Register the resolution waiter SYNCHRONOUSLY before any await
144
+ // yields. A same-tick resolveProposal() (e.g. from a test that
145
+ // awaits the onDispatch callback and immediately resolves) must
146
+ // find the waiter registered — adding an await between insert
147
+ // and waiter-registration would open a race window.
148
+ const resolutionPromise = this.#proposals.awaitResolution(logEntryId);
149
+ // Notify external listeners (Daemon broadcasts loop/proposal;
150
+ // YOLO listener auto-resolves) BEFORE awaiting — they may
151
+ // resolve synchronously inside their handlers.
152
+ const target = this.#extractTarget(statement.target);
153
+ const flags = await this.#loadLoopFlags(loopId); // the loop/proposal notification carries flags (yolo) — §dual-yolo-proposal-carries-flags
154
+ // #note10 — if the target diverged on disk this turn, the model's EDIT is based
155
+ // on a stale read; flag it so a YOLO auto-accept rejects instead of clobbering.
156
+ const diverged = await this.#db.engine_target_diverged_this_turn.get({ run_id: runId, turn_id: turnId, scheme: target.scheme, pathname: target.pathname });
157
+ const event = {
158
+ logEntryId, sessionId, runId, loopId, turnId,
159
+ op: statement.op,
160
+ target: { scheme: target.scheme, pathname: target.pathname },
161
+ body: typeof result.body === "string" ? result.body : "",
162
+ attrs: (result.attrs ?? {}),
163
+ flags,
164
+ staleClobberRisk: diverged !== undefined,
165
+ };
166
+ this.#proposals.notifyPending(event);
167
+ const resolution = await resolutionPromise;
168
+ // Run the scheme's applyResolution hook on accept (writes the
169
+ // file, spawns the process, etc.). If applyResolution returns a
170
+ // 4xx/5xx or throws, the resolution is downgraded to a reject
171
+ // with the failure outcome — engine treats it like a client
172
+ // rejection.
173
+ const effective = await this.#proposals.runApply(statement, result, resolution, { sessionId, runId, loopId, turnId });
174
+ // MOVE into a proposed dest: the deferred source-delete fires ONLY now,
175
+ // after the dest write landed (accept). On reject the source survives.
176
+ if (effective.decision === "accept") {
177
+ const moveSource = result.attrs?.moveSource;
178
+ if (moveSource !== undefined) {
179
+ const srcHandler = this.#schemes.get(moveSource.scheme);
180
+ if (srcHandler !== undefined && typeof srcHandler.deleteEntry === "function") {
181
+ const del = await srcHandler.deleteEntry(moveSource.pathname, schemeCtx);
182
+ // A host-effecting source-delete PROPOSES (202); the MOVE proposal already gated the whole
183
+ // create+kill, so apply the source-delete now — never raise a second review for one MOVE.
184
+ if (del.status === 202 && del.attrs !== undefined && typeof srcHandler.applyResolution === "function")
185
+ await srcHandler.applyResolution({ attrs: del.attrs }, schemeCtx);
186
+ }
187
+ }
188
+ }
189
+ const post = await this.#proposals.applyResolution(logEntryId, effective);
190
+ return post;
191
+ }
192
+ return result;
193
+ }
194
+ // op.look (#283) — resolve a READ and return its content WITHOUT writing a
195
+ // log_entries row: the client's off-run inspection primitive (LOOK → READ,
196
+ // invisible to the model). READ never mutates and never proposes, so this is
197
+ // dispatch's resolve path minus #writeLog. Runs on the client loop, so the
198
+ // human's inspection is never constrained by a model loop's flags. {§op-look}
199
+ async look(context) {
200
+ const { statement, sessionId, runId, loopId, origin = "client" } = context;
201
+ if (statement.op !== "READ")
202
+ throw new Error(`look resolves READ only; got ${statement.op}`);
203
+ // turnId is a write-time FK only — a look writes no row, so 0 (no turn) is inert.
204
+ const schemeCtx = this.#buildSchemeCtx({ sessionId, runId, loopId, turnId: 0, origin });
205
+ const denial = await this.#checkFlagsGate(statement, loopId);
206
+ if (denial !== null)
207
+ return denial;
208
+ return this.#run(schemeNameOf(statement.target), statement, schemeCtx);
209
+ }
210
+ #buildSchemeCtx(ids) {
211
+ const { sessionId, runId, loopId, turnId, origin, gauge } = ids;
212
+ return {
213
+ db: this.#db,
214
+ sessionId, runId, loopId, turnId,
215
+ writer: origin,
216
+ signal: this.#loopSignal(loopId),
217
+ streamEventNotify: this.#streamEventNotify,
218
+ wakeRunNotify: this.#wakeRunNotify,
219
+ injectRun: this.#injectRun,
220
+ mimetypes: this.#mimetypes,
221
+ tokenize: this.#tokenize,
222
+ gauge,
223
+ pushTelemetry: (event) => this.#telemetry.push(sessionId, loopId, event),
224
+ executors: this.#executors(),
225
+ };
226
+ }
227
+ // Loads loops.flags (json column) and merges over DEFAULT_LOOP_FLAGS so
228
+ // missing keys read as their documented defaults. Single read site —
229
+ // ProposalPendingEvent.flags is constructed from this, and listeners
230
+ // (Daemon broadcast, YOLO auto-accept) share the result.
231
+ async #loadLoopFlags(loopId) {
232
+ const row = await this.#db.engine_get_loop_flags.get({ loop_id: loopId });
233
+ if (row === undefined)
234
+ return DEFAULT_LOOP_FLAGS;
235
+ try {
236
+ const parsed = JSON.parse(row.flags);
237
+ return { ...DEFAULT_LOOP_FLAGS, ...parsed };
238
+ }
239
+ catch {
240
+ return DEFAULT_LOOP_FLAGS;
241
+ }
242
+ }
243
+ // SPEC §scheme-surface: engine rejects writes whose origin is outside the target
244
+ // scheme's manifest.writableBy.
245
+ // - Read-side ops (READ, FIND, OPEN, FOLD) are not gated.
246
+ // - SEND broadcast (path=null) has no target scheme; not gated.
247
+ // - COPY: dst scheme writableBy applies.
248
+ // - MOVE: both src (delete) and dst (write) schemes' writableBy apply.
249
+ // - Schemes without a manifest are not gated (legacy / future allowance).
250
+ #checkWritable(statement, origin) {
251
+ if (!MUTATING_OPS.has(statement.op))
252
+ return null;
253
+ if (statement.op === "SEND" && statement.target === null)
254
+ return null;
255
+ // EXEC's target slot is `cwd`, not a scheme address. The op's
256
+ // authority always belongs to the exec scheme regardless of cwd.
257
+ if (statement.op === "EXEC") {
258
+ return this.#denyIfDisallowed("exec", origin);
259
+ }
260
+ // Run control (FORK/WORK → run://<name>, spawn or fork) is gated by run://'s writableBy — its
261
+ // body is a seed prompt, not a dst path, so the entry-COPY dst-parse below doesn't apply.
262
+ // §machine-processes
263
+ if (this.#isRunControl(statement))
264
+ return this.#denyIfDisallowed("run", origin);
265
+ if (statement.op === "COPY" || statement.op === "MOVE") {
266
+ const dst = statement.op === "COPY" ? (statement.body === null ? null : parsePath(statement.body)) : statement.body;
267
+ const dstScheme = schemeNameOf(dst);
268
+ const dstDenial = this.#denyIfDisallowed(dstScheme, origin);
269
+ if (dstDenial !== null)
270
+ return dstDenial;
271
+ if (statement.op === "MOVE") {
272
+ const srcScheme = schemeNameOf(statement.target);
273
+ if (srcScheme !== dstScheme) {
274
+ const srcDenial = this.#denyIfDisallowed(srcScheme, origin);
275
+ if (srcDenial !== null)
276
+ return srcDenial;
277
+ }
278
+ }
279
+ return null;
280
+ }
281
+ const target = schemeNameOf(statement.target);
282
+ return this.#denyIfDisallowed(target, origin);
283
+ }
284
+ #denyIfDisallowed(schemeName, origin) {
285
+ if (schemeName === null)
286
+ return null;
287
+ const handler = this.#schemes.get(schemeName);
288
+ if (handler === undefined)
289
+ return null;
290
+ const manifest = handler.constructor.manifest;
291
+ if (manifest === undefined)
292
+ return null;
293
+ if (manifest.writableBy.includes(origin))
294
+ return null;
295
+ return { status: 403, error: `writer '${origin}' is not in writableBy for scheme '${schemeName}'` }; // §scheme-surface-writableby-403
296
+ }
297
+ // Per-loop flag gating. Schemes self-declare their flag affinity in
298
+ // their manifest (excludedInAsk / requiresWeb /
299
+ // requiresInteraction); SchemeRegistry.resolveForLoop returns the
300
+ // active set under the loop's persisted flags. Anything outside the
301
+ // set returns 403 — action-entry-as-outcome carries the rejection.
302
+ async #checkFlagsGate(statement, loopId) {
303
+ // Broadcast SEND has no scheme to gate.
304
+ if (statement.op === "SEND" && statement.target === null)
305
+ return null;
306
+ const flags = await this.#loadLoopFlags(loopId);
307
+ // Fast path: default flags gate nothing. (yolo never gates.)
308
+ if (!flags.noWeb && !flags.noInteraction && flags.mode === "act")
309
+ return null;
310
+ // §mode-ask-read-only — the ancient contract: an ask-mode loop NEVER changes the world. The
311
+ // filesystem writes (EDIT/COPY-dest/MOVE/KILL touching the `file` scheme — each proposes disk
312
+ // egress, §membership) are refused HERE, regardless of the scheme's read-activity, because
313
+ // `file` stays active for READs. The EXEC host runtime is refused by its excludedInAsk scheme
314
+ // below. This lived only in SPEC (line 65) with no anchor → no guard → it silently regressed.
315
+ if (flags.mode === "ask") {
316
+ const isFile = (t) => schemeNameOf(t) === "file";
317
+ // Each branch narrows statement.op so statement.body is correctly typed (COPY dest is a
318
+ // string to parse; MOVE dest is already a path). EDIT/KILL write the target; COPY writes
319
+ // the dest; MOVE deletes the source AND writes the dest — any `file` touch is a write.
320
+ let writesFilesystem = false;
321
+ if (statement.op === "EDIT" || statement.op === "KILL")
322
+ writesFilesystem = isFile(statement.target);
323
+ else if (statement.op === "COPY")
324
+ writesFilesystem = isFile(statement.body === null ? null : parsePath(statement.body));
325
+ else if (statement.op === "MOVE")
326
+ writesFilesystem = isFile(statement.target) || isFile(statement.body);
327
+ if (writesFilesystem) {
328
+ return { status: 403, error: `'${statement.op}' cannot change the filesystem in an ask-mode loop — ask is read-only (no side-effecting ops). Answer or advise the user directly; an act-mode loop is required to edit files.` };
329
+ }
330
+ }
331
+ const active = this.#schemes.resolveForLoop(flags);
332
+ // #367 — the steer NAMES the restriction and says DO NOT RETRY: the old vague "inactive under
333
+ // current loop flags" invited an identical re-emit each turn → the StrikeRail's 508. An
334
+ // unavailable op is not a failing one; the model must change course, not repeat.
335
+ const restriction = flags.mode === "ask"
336
+ ? "this is an ask-mode (read-only) loop — you cannot run commands or take host actions here"
337
+ : flags.noWeb && flags.noInteraction ? "web and interaction are disabled for this loop"
338
+ : flags.noWeb ? "web access is disabled for this loop"
339
+ : "interaction is disabled for this loop";
340
+ const check = (target) => {
341
+ const scheme = schemeNameOf(target);
342
+ if (scheme === null)
343
+ return null;
344
+ if (active.has(scheme))
345
+ return null;
346
+ return { status: 403, error: `'${scheme}' is unavailable: ${restriction}. Do NOT retry it — it is unavailable, not failing; answer or advise the user directly (an act-mode loop is required to use it).` };
347
+ };
348
+ if (this.#isRunControl(statement))
349
+ return check(statement.target); // body is a spawn/fork task, not a dst path
350
+ if (statement.op === "COPY" || statement.op === "MOVE") {
351
+ return check(statement.target) ?? check(statement.op === "COPY" ? (statement.body === null ? null : parsePath(statement.body)) : statement.body);
352
+ }
353
+ return check(statement.target);
354
+ }
355
+ // Run control is FORK/WORK (grammar 0.74.55), not COPY — its body
356
+ // is the new run's seed prompt, not a destination path. The COPY gates and #handleCopy
357
+ // branch on this so they never parse the prompt as a dst path.
358
+ #isRunControl(statement) {
359
+ return statement.op === "FORK" || statement.op === "WORK"; // run control targets run://<name> (grammar 0.74.55)
360
+ }
361
+ // FORK/WORK(run://<name>):task — run control (grammar 0.74.55):
362
+ // • run://self → FORK: deep-copy the current run's log into a new sister (Fork), then
363
+ // continue it with the prompt (§machine-processes-fork-copies-the-log).
364
+ // • run://<name> → SPAWN: a fresh sister (empty log) named <name>, started on the prompt.
365
+ // A LIVE sister already holding <name> is a 409 conflict; a free or terminated name is
366
+ // reclaimed (§run-scheme-spawn). The self form is fork; only a name spawns.
367
+ // Both ride the daemon inject and obey the active-runs cap (508, §run-scheme-cap).
368
+ // FORK/WORK — run control (grammar 0.74.55). Both name a NEW run in the target authority
369
+ // (run://<name>) and carry its seed task in the body. WORK spawns a fresh worker; FORK branches
370
+ // the current run's log into a named sister. Replaces the COPY(run://) overload — one verb, one
371
+ // intent, so the model never conflates the target slot with the body (grammar#52).
372
+ async #handleRunControl(statement, ctx) {
373
+ const target = statement.target;
374
+ if (target === null)
375
+ return { status: 400, error: `${statement.op} requires a run target (${statement.op}(run://<name>))` };
376
+ const name = target.kind === "url" ? (target.hostname ?? "") : ""; // §run-scheme — run is the AUTHORITY (run://<name>), not the path
377
+ if (name === "")
378
+ return { status: 400, error: `${statement.op} requires a run name (run://<name>)` };
379
+ if (name === "self")
380
+ return { status: 400, error: `'self' is the current run — ${statement.op} names a NEW run (run://<name>)` };
381
+ if (ctx.injectRun === undefined)
382
+ throw new Error("run control: injectRun capability absent");
383
+ const denied = await RunCap.deny(this.#db, ctx.sessionId);
384
+ if (denied !== null)
385
+ return denied;
386
+ const prompt = typeof statement.body === "string" ? statement.body : "";
387
+ // §run-delegation-inherits-flags — authority flows down the delegation edge: the child's live
388
+ // loop runs with ITS DELEGATOR'S flags. A flagless (non-YOLO) child's every side-effecting op
389
+ // proposes into a resolver-less void — 300s auto-cancel per attempt was the fan-out wedge.
390
+ const flags = await this.#loadLoopFlags(ctx.loopId);
391
+ // A name is frozen per run but reclaimable across time (§machine-processes-run-origin): a LIVE
392
+ // sister holding it is a 409 (legible, never a raw UNIQUE 500); a free/terminated name reclaims.
393
+ const live = await this.#db.run_live_by_name.get({ session_id: ctx.sessionId, name });
394
+ if (live !== undefined)
395
+ return { status: 409, error: `run '${name}' is already running` };
396
+ if (statement.op === "FORK") {
397
+ // Branch the current run's log into a named sister.
398
+ const branchRunId = await Fork.fork(this.#db, ctx.runId, name);
399
+ await ctx.injectRun({ sessionId: ctx.sessionId, runId: branchRunId, prompt, flags });
400
+ return { status: 200, body: name };
401
+ }
402
+ // WORK — a fresh worker sister named <name>.
403
+ const row = await this.#db.fork_insert_run.get({
404
+ session_id: ctx.sessionId, name, parent_run_id: ctx.runId, origin: ctx.writer,
405
+ });
406
+ if (row === undefined)
407
+ throw new Error("run spawn: run insert returned no row");
408
+ await ctx.injectRun({ sessionId: ctx.sessionId, runId: row.id, prompt, flags });
409
+ return { status: 200, body: name };
410
+ }
411
+ async #handleCopy(statement, ctx) {
412
+ if (statement.op !== "COPY")
413
+ throw new Error("unreachable");
414
+ const srcPath = statement.target;
415
+ // COPY is entry-copy only — run control (spawn/fork) moved to the FORK/WORK verbs
416
+ // (grammar 0.74.55). COPY's body is a dest path (grammar §COPY); an unparseable dest → 400.
417
+ const dstPath = statement.body === null ? null : parsePath(statement.body);
418
+ if (srcPath === null)
419
+ return { status: 400, error: "COPY requires source path" };
420
+ if (dstPath === null)
421
+ return { status: 400, error: "COPY destination must be a parseable path in the body slot" };
422
+ return await this.#copyOrchestration({ statement, srcPath, dstPath, ctx });
423
+ }
424
+ async #handleMove(statement, ctx) {
425
+ if (statement.op !== "MOVE")
426
+ throw new Error("unreachable");
427
+ const srcPath = statement.target;
428
+ const dstPath = statement.body;
429
+ if (srcPath === null)
430
+ return { status: 400, error: "MOVE requires source path" };
431
+ // MOVE is relocation only — deletion is KILL's job (§move, §move-dev-null-not-special). The /dev/null
432
+ // and null-body delete-by-MOVE back-compat is retired: no silent debt.
433
+ if (dstPath === null)
434
+ return { status: 400, error: "MOVE requires a destination; use KILL to delete" }; // §move-null-body-400
435
+ const srcSchemeName = schemeNameOf(srcPath);
436
+ if (srcSchemeName === null)
437
+ return { status: 400, error: "MOVE source must be a URL path with a scheme" };
438
+ const srcHandler = this.#schemes.get(srcSchemeName);
439
+ if (srcHandler === undefined || typeof srcHandler.deleteEntry !== "function")
440
+ return { status: 501 };
441
+ // Relocation: COPY then DELETE source (§move-relocation-deletes-source).
442
+ const copyResult = await this.#copyOrchestration({ statement, srcPath, dstPath, ctx });
443
+ if (copyResult.status >= 400)
444
+ return copyResult;
445
+ const srcPathname = pathnameFromPath(srcPath);
446
+ // If the dest write is a pending proposal (file dest → §membership review), the
447
+ // source-delete MUST wait until the dest actually lands — a rejected
448
+ // proposal would otherwise lose the source. Thread it into the resolution:
449
+ // dispatch deletes the source AFTER the dest applies on accept.
450
+ if (copyResult.status === 202) {
451
+ return { ...copyResult, attrs: { ...copyResult.attrs, moveSource: { scheme: srcSchemeName, pathname: srcPathname } } };
452
+ }
453
+ const delResult = await srcHandler.deleteEntry(srcPathname, ctx);
454
+ if (delResult.status >= 400)
455
+ return { status: delResult.status };
456
+ return copyResult;
457
+ }
458
+ // KILL — scheme-polymorphic destroy (plurnk-grammar#203 / 0.28.0). Entry-KILL
459
+ // permanently deletes the entry: the canonical delete now, MOVE→/dev/null
460
+ // retired from the model's vocabulary. Process-KILL (exec:///) aborts the
461
+ // running spawn's controller (the same teardown loop.cancel rides), addressed
462
+ // by coordinate pathname (#203). The KILL body is an opaque
463
+ // annotation with no runtime meaning; it survives into the log row's tx for
464
+ // free via the statement serialization. Status: 200 killed · 404 unknown ·
465
+ // 405 log:/// (append-only) · 403 writableBy (the #checkWritable gate, KILL ∈
466
+ // MUTATING_OPS) · 200/410/304/404 exec (killed / killed-earlier / exited / unknown) · 501 no-kill/delete scheme.
467
+ async #handleKill(statement, ctx) {
468
+ if (statement.op !== "KILL")
469
+ throw new Error("unreachable");
470
+ const path = statement.target;
471
+ if (path === null)
472
+ return { status: 400, error: "KILL requires a target path" };
473
+ const schemeName = schemeNameOf(path);
474
+ if (schemeName === null)
475
+ return { status: 400, error: "KILL target must be a URL path with a scheme" };
476
+ // KILL on log:/// erases the log row(s) — the model's DB-storage curation lever
477
+ // (plurnk.md:36, :98), routed to Log.kill below via the killable.kill path. The old
478
+ // "append-only" 405 forbade what the grammar requires; FOLD only collapses the render.
479
+ // Process-KILL: any scheme whose handler exposes kill() aborts a live stream — the
480
+ // exec handler, registered as "exec" + under every runtime tag (sh/node), so a tag-
481
+ // addressed stream (sh:///l/t/s) routes here, not to deleteEntry. §exec
482
+ const killable = this.#schemes.get(schemeName);
483
+ if (killable !== undefined && typeof killable.kill === "function") {
484
+ return await killable.kill(pathnameFromPath(path), statement.signal, ctx);
485
+ }
486
+ if (schemeName === "run") {
487
+ // Entry-path present → KILL a run-scope scratch ENTRY (delete it), self-only —
488
+ // NOT run cancellation. The authority (hostname) names the owner, the pathname the
489
+ // entry; only the path-ABSENT form (run://<name>) terminates the run-as-actor. §run-scheme
490
+ const entryPath = path.kind === "url" ? (path.pathname ?? "") : "";
491
+ if (entryPath !== "" && entryPath !== "/") {
492
+ const runHandler = this.#schemes.get("run");
493
+ return await runHandler.deleteEntry(statement, ctx);
494
+ }
495
+ // terminate — abort any run by address; whoever holds it may end it.
496
+ // `run://self` = self. cancelRun (→ Daemon.cancelDrain) aborts the run's signal
497
+ // (its loop closes 499); an idle run is a no-op-200, a missing run 404.
498
+ const name = path.kind === "url" ? (path.hostname ?? "") : ""; // §run-scheme — run is the AUTHORITY
499
+ if (name === "")
500
+ return { status: 400, error: "run:// kill requires a run name or 'self' (run://<name>)" };
501
+ let runId = ctx.runId;
502
+ if (name !== "self") {
503
+ const row = await this.#db.run_resolve_by_name.get({ session_id: ctx.sessionId, name });
504
+ if (row === undefined)
505
+ return { status: 404, error: `run://${name} not found in this session` };
506
+ runId = row.id;
507
+ }
508
+ if (this.#cancelRun === undefined)
509
+ throw new Error("run kill: cancelRun capability absent");
510
+ // §op-synchronous — KILL is DECISIVE: flip the run's live loops to 499 NOW so the
511
+ // same-turn premature-terminate gate sees it dead (KILL … SEND[200] concludes in ONE
512
+ // turn — the model never reasons about async reap timing). The physical scope reap
513
+ // (drain abort + stream teardown) then rides cancelRun; a killed loop can't heal back
514
+ // because cancelRun aborts its drain's signal.
515
+ await this.#db.engine_terminate_run_live_loops.run({ run_id: runId, message: "killed via run:// KILL" });
516
+ this.#cancelRun(runId);
517
+ return { status: 200 };
518
+ }
519
+ const handler = this.#schemes.get(schemeName);
520
+ if (handler === undefined || typeof handler.deleteEntry !== "function")
521
+ return { status: 501 };
522
+ // A host-effecting delete (file) returns 202 to PROPOSE — pass its attrs through so the proposal
523
+ // carries the delete target to review (§isProposal fires on 202). Plurnk-internal deletes execute inline.
524
+ const delResult = await handler.deleteEntry(pathnameFromPath(path), ctx);
525
+ return delResult.attrs !== undefined ? { status: delResult.status, attrs: delResult.attrs } : { status: delResult.status };
526
+ }
527
+ // Multi-file READ fan-out (SPEC §matcher-result — "the companion to FIND's survey"). A glob
528
+ // READ target resolves to MANY files; READ returns one log row per file that matches, each
529
+ // holding that file's matching lines. The matched SET is exactly FIND's survey (which files
530
+ // + where — matchLines), so we reuse the scheme's own find, then READ each matched file. One
531
+ // model command, N log rows — each row addresses its concrete file, so it folds/kills/re-READs
532
+ // on its own. The running sequence counter in runTurn advances by rowsWritten.
533
+ // A READ fans out (honors FIND) when it resolves to more than the single exact entry: a glob
534
+ // or folder scope, OR a matcher (which selects per-match within whatever the target resolved).
535
+ // A bare entry, body-less, is the one direct read. #286
536
+ static #readFansOut(statement) {
537
+ if (statement.op !== "READ")
538
+ return false;
539
+ if ("body" in statement && statement.body !== null)
540
+ return true; // a matcher → per-match fan-out
541
+ const t = statement.target;
542
+ const p = t === null ? "" : (t.kind === "url" ? t.pathname : t.raw);
543
+ return p.includes("*") || p.endsWith("/"); // glob/folder scope → fan out its contents
544
+ }
545
+ // Clone the READ onto one concrete match — the FIND already matched, so the per-match READ
546
+ // delivers content at the span: strip the body (no re-match) and set <L> to the span. A null
547
+ // span (body-less folder/glob fan-out) reads the whole entry. #286
548
+ static #retargetRead(statement, pathname, span) {
549
+ const t = statement.target;
550
+ const target = t !== null && t.kind === "url"
551
+ ? { ...t, pathname, raw: `${t.scheme}://${pathname}` }
552
+ : { ...t, raw: pathname };
553
+ const lineMarker = span !== null ? { marks: [span.lineStart, span.lineEnd] } : null;
554
+ return { ...statement, target: target, lineMarker, body: null };
555
+ }
556
+ async #handleReadFanout(statement, ctx, ids) {
557
+ const { runId, loopId, turnId, sequence, origin, onDispatch } = ids;
558
+ const schemeName = schemeNameOf(statement.target);
559
+ const found = await this.#run(schemeName, { ...statement, op: "FIND" }, ctx);
560
+ const matches = found.matches ?? [];
561
+ // Find-less scheme, a matcher/scope error, or zero matches → a single row carrying the
562
+ // status, exactly like a non-fanned READ. The model sees the empty/failed result, not silence.
563
+ if (found.status !== 200 || matches.length === 0) {
564
+ const result = { status: found.status === 200 ? 204 : found.status };
565
+ const id = await this.#writeLog({ statement, result, runId, loopId, turnId, sequence, origin });
566
+ onDispatch?.(id);
567
+ return { ...result, rowsWritten: 1 };
568
+ }
569
+ // One READ row per MATCH — the span's source lines (or the whole entry for a body-less
570
+ // folder/glob). The match span is SOURCE LINES, so deliver via a raw line-slice — NOT the
571
+ // scheme's <L> (which is item-index for application/json, structural for xml). Read each
572
+ // distinct entry's content once, then line-slice per match. #286
573
+ // §matcher-selection-signal — the SELECTION SUMMARY row: the internal FIND that located
574
+ // the matches is WRITTEN (op=FIND, its full result — per-hit matchSpan + matchPath, the
575
+ // canonical dialect coordinate) before the per-match deliveries, exactly as if the model
576
+ // had FINDed then READ. On a degenerate single-line document the N delivery rows are
577
+ // identical whole-file lines; the summary row is what tells the model its query hit N
578
+ // times and WHERE (run30: two hits indistinguishable from failure; 17 retries, 508).
579
+ const findRowId = await this.#writeLog({ statement: { ...statement, op: "FIND" }, result: found, runId, loopId, turnId, sequence, origin });
580
+ onDispatch?.(findRowId);
581
+ const wholeByPath = new Map();
582
+ const fannedStatuses = [];
583
+ let written = 1;
584
+ for (const m of matches) {
585
+ let whole = wholeByPath.get(m.pathname);
586
+ if (whole === undefined) {
587
+ whole = await this.#run(schemeName, _a.#retargetRead(statement, m.pathname, null), ctx);
588
+ wholeByPath.set(m.pathname, whole);
589
+ }
590
+ const result = _a.#sliceMatch(whole, m.span);
591
+ const id = await this.#writeLog({ statement: _a.#retargetRead(statement, m.pathname, m.span), result, runId, loopId, turnId, sequence: sequence + written, origin });
592
+ onDispatch?.(id);
593
+ fannedStatuses.push(result.status);
594
+ written++;
595
+ }
596
+ return { status: 200, rowsWritten: written, fannedStatuses };
597
+ }
598
+ // Deliver one match: the whole entry (body-less, span null) or the source lines at the span —
599
+ // a RAW line-slice, so a structural mimetype (json item-index / xml) doesn't mis-slice a span
600
+ // that is, by construction, source line numbers (#286).
601
+ static #sliceMatch(whole, span) {
602
+ if (whole.status !== 200 || span === null)
603
+ return whole;
604
+ const sliced = LineMarkerOps.sliceLines(typeof whole.content === "string" ? whole.content : "", { marks: [span.lineStart, span.lineEnd] });
605
+ if (sliced.status !== 200)
606
+ return { status: sliced.status, error: sliced.error };
607
+ return { status: 200, content: sliced.text ?? "", mimetype: "text/markdown", startLine: sliced.startLine ?? span.lineStart };
608
+ }
609
+ // §model-entry — mirror a verbatim model emission back as an actionless `model` log row, so
610
+ // the model can finally SEE its own prior output (and reason through its own syntax errors).
611
+ // Born FOLDED by default (budget-neutral until OPENed); the turn-0 exemplar passes folded:false
612
+ // (born open — the one worked example the model orients on, thinning the grammar). text/vnd.plurnk.
613
+ async writeModelEntry({ verbatim, runId, loopId, turnId, sequence, folded, origin = "model" }) {
614
+ const row = await this.#db.engine_insert_log_entry.get({
615
+ run_id: runId, loop_id: loopId, turn_id: turnId, sequence,
616
+ origin, source: null, op: "model", suffix: "", signal: null,
617
+ scheme: null, username: null, password: null, hostname: null, port: null,
618
+ pathname: null, params: null, fragment: null, lineMarker: null,
619
+ tx: "", mimetype_tx: "text/vnd.plurnk",
620
+ rx: JSON.stringify({ content: verbatim, mimetype: "text/vnd.plurnk" }),
621
+ mimetype_rx: "application/json",
622
+ status_rx: 200, tokens: this.#tokenize(verbatim), state: "resolved", outcome: null, attrs: "{}",
623
+ });
624
+ if (row === undefined)
625
+ throw new Error("Dispatcher.writeModelEntry: insert returned no row");
626
+ if (folded)
627
+ await this.#db.engine_fold_log_entry.run({ id: row.id });
628
+ return row.id;
629
+ }
630
+ // PLAN — the model's reasoning op (the 11th op). An ordinary op: dispatched like any
631
+ // other, logged, and broadcast to the client as a log entry — but a pure no-op for
632
+ // state (PLAN ∉ MUTATING_OPS); its body serializes into the log row's tx, no effect.
633
+ #handlePlan(statement) {
634
+ if (statement.op !== "PLAN")
635
+ throw new Error("unreachable");
636
+ return { status: 200 };
637
+ }
638
+ // Same- and cross-scheme COPY share one orchestrator — §copy-cross-scheme-copy §move-cross-scheme-move
639
+ async #copyOrchestration({ statement, srcPath, dstPath, ctx }) {
640
+ const srcSchemeName = schemeNameOf(srcPath);
641
+ const dstSchemeName = schemeNameOf(dstPath);
642
+ if (srcSchemeName === null || dstSchemeName === null)
643
+ return { status: 400, error: "COPY/MOVE require URL paths with schemes" };
644
+ const srcHandler = this.#schemes.get(srcSchemeName);
645
+ const dstHandler = this.#schemes.get(dstSchemeName);
646
+ if (srcHandler === undefined || dstHandler === undefined)
647
+ return { status: 501 };
648
+ if (typeof srcHandler.readEntry !== "function" || typeof dstHandler.writeEntry !== "function")
649
+ return { status: 501 };
650
+ const srcPathname = pathnameFromPath(srcPath);
651
+ const dstPathname = pathnameFromPath(dstPath);
652
+ const srcResult = await srcHandler.readEntry(srcPathname, ctx);
653
+ if (srcResult.status !== 200 || srcResult.entry === null)
654
+ return { status: 404, error: `COPY/MOVE source not found: ${srcSchemeName}://${srcPathname}` }; // §copy-missing-source-404 §move-missing-source-404
655
+ const entry = srcResult.entry;
656
+ // Destination read — the conflict/no-op verdict is deferred until the
657
+ // to-be-written content is known (after <L> slice + tag resolution below),
658
+ // so an identical re-copy resolves to 304 instead of a phantom 409.
659
+ const dstExisting = typeof dstHandler.readEntry === "function"
660
+ ? await dstHandler.readEntry(dstPathname, ctx)
661
+ : null;
662
+ // Mimetype compatibility check against the destination scheme's manifest
663
+ const dstManifest = dstHandler.constructor.manifest;
664
+ const dstChannels = dstManifest?.channels ?? {};
665
+ for (const [channelName, channelData] of Object.entries(entry.channels)) {
666
+ const expectedMimetype = dstChannels[channelName];
667
+ if (expectedMimetype !== undefined && expectedMimetype !== channelData.mimetype) {
668
+ return { status: 415, error: `mimetype mismatch on channel '${channelName}': ${channelData.mimetype} vs ${expectedMimetype}` }; // cross-mimetype COPY/MOVE → 415, never coerce — §channel-mimetype-cross-mimetype-415
669
+ }
670
+ }
671
+ // `<L>` source range slicing per SPEC.md §op-invariants (symmetric with READ
672
+ // `<L>` — source range, no line-number prefix).
673
+ // Applied to every channel of the source entry. Binary channels return
674
+ // 415 since line semantics don't apply.
675
+ const lineMarker = statement.lineMarker ?? null;
676
+ let channels = entry.channels;
677
+ if (lineMarker !== null) {
678
+ const sliced = {};
679
+ for (const [channelName, channelData] of Object.entries(entry.channels)) {
680
+ if (MimetypeBinary.isBinaryMimetype(channelData.mimetype)) {
681
+ return { status: 415, error: `cannot slice <L> on binary channel '${channelName}' (${channelData.mimetype})` };
682
+ }
683
+ const r = LineMarkerOps.sliceLinesRaw(channelData.content ?? "", lineMarker);
684
+ if (r.status !== 200)
685
+ return { status: r.status, error: r.error };
686
+ sliced[channelName] = { ...channelData, content: r.text ?? "" };
687
+ }
688
+ channels = sliced;
689
+ }
690
+ // Tag resolution: signal = replace (§copy-signal-replaces-source-tags); absent/empty = carry from source (§copy-no-signal-carries-source-tags)
691
+ const tags = (Array.isArray(statement.signal) && statement.signal.length > 0)
692
+ ? statement.signal
693
+ : entry.tags;
694
+ // 304/409 on an existing destination (SPEC §copy): a re-copy that would write
695
+ // exactly what's already there — same channel contents, same tags — is a no-op
696
+ // (304), mirroring EDIT's 304-on-noop (§edit). A divergent destination is a real
697
+ // collision (409); COPY/MOVE never clobbers.
698
+ if (dstExisting !== null && dstExisting.status === 200 && dstExisting.entry !== null) {
699
+ const dstChannels = dstExisting.entry.channels;
700
+ const writeNames = Object.keys(channels).sort();
701
+ const dstNames = Object.keys(dstChannels).sort();
702
+ const sameContent = writeNames.length === dstNames.length
703
+ && writeNames.every((n, i) => n === dstNames[i] && (channels[n]?.content ?? "") === (dstChannels[n]?.content ?? ""));
704
+ const sameTags = [...tags].sort().join("") === [...dstExisting.entry.tags].sort().join("");
705
+ if (sameContent && sameTags)
706
+ return { status: 304 }; // identical → §copy-noop-304
707
+ return { status: 409, error: `COPY/MOVE destination exists: ${dstSchemeName}://${dstPathname}` }; // §copy-conflict-409
708
+ }
709
+ const writeResult = await dstHandler.writeEntry(dstPathname, { channels, tags }, ctx);
710
+ // A file dest returns 202 (disk write → §membership review): propagate the
711
+ // proposal so dispatch runs the gate + routes applyResolution to the dest.
712
+ if (writeResult.status === 202)
713
+ return { status: 202, attrs: writeResult.attrs, body: writeResult.body };
714
+ return { status: writeResult.status, entryId: writeResult.entryId, created: writeResult.created };
715
+ }
716
+ // §send-premature-terminate — the unified PENDING SET, judged at the terminal's OWN dispatch
717
+ // (post-batch: the emission's earlier ops already executed, so a same-turn KILL+[200] repairs in
718
+ // ONE turn, and a same-turn WORK+[200] is caught — the spawn is live by the time the SEND lands).
719
+ // pending = open streams ∪ live children ∪ THIS turn's retrievals (READ/FIND/OPEN, results unseen
720
+ // until next packet). One rule, one steer, one repair family: nothing pending may be silently
721
+ // discarded; 499 discards BY STATED INTENT and is never gated.
722
+ async #pendingSet(runId, turnId) {
723
+ const pending = [];
724
+ const openSubs = await this.#db.find_open_subscriptions_for_run.all({ run_id: runId });
725
+ const execHandler = this.#schemes.get("exec");
726
+ if (openSubs.length > 0 || execHandler?.hasActiveSpawns?.(runId) === true)
727
+ pending.push("open streams");
728
+ const liveChild = await this.#db.engine_run_has_live_child.get({ run_id: runId });
729
+ if (liveChild !== undefined)
730
+ pending.push("live worker runs");
731
+ const retrievals = await this.#db.engine_turn_retrievals.all({ turn_id: turnId });
732
+ if (retrievals.length > 0)
733
+ pending.push("results of this turn's READ/FIND/OPEN (they arrive NEXT turn)");
734
+ // §send-undelivered-child-term — a worker that concluded DURING this turn's generation is no
735
+ // longer "live" but its deliverable hasn't reached any packet yet; concluding discards it.
736
+ const undelivered = await this.#db.engine_run_has_undelivered_child_term.get({ run_id: runId, turn_id: turnId });
737
+ if (undelivered !== undefined)
738
+ pending.push("worker results that arrived during this turn (they land NEXT turn)");
739
+ return pending;
740
+ }
741
+ // J — a live obligation to WAIT on: a spawned child or an open stream (NOT retrievals, which land
742
+ // next turn regardless). The wait-side twin of #pendingSet's stream+child legs (§wait-obligation-matrix).
743
+ async #hasLiveWork(runId) {
744
+ const openSubs = await this.#db.find_open_subscriptions_for_run.all({ run_id: runId });
745
+ if (openSubs.length > 0)
746
+ return true;
747
+ const execHandler = this.#schemes.get("exec");
748
+ if (execHandler?.hasActiveSpawns?.(runId) === true)
749
+ return true;
750
+ const liveChild = await this.#db.engine_run_has_live_child.get({ run_id: runId });
751
+ return liveChild !== undefined;
752
+ }
753
+ async #handleSendBroadcast(statement, ctx) {
754
+ if (statement.op !== "SEND")
755
+ throw new Error("unreachable");
756
+ const { runId, loopId, turnId } = ctx;
757
+ const status = statement.signal;
758
+ if (status === null)
759
+ return { status: 400 };
760
+ const raw = statement.body === null ? "" : typeof statement.body === "string" ? statement.body : statement.body.raw;
761
+ // [102]<T> — waiting is a mode of CONTINUING (grammar 0.75.0, the terminal redesign): park
762
+ // up to T seconds, woken early by any arrival (stream/child conclusion, irc, inject), woken
763
+ // at T regardless — a park ALWAYS has a next turn, so nothing can be orphaned. <-1> parks
764
+ // indefinitely (the butler/worker pattern — owner-ruled ungated; the instrumentation renders
765
+ // it legibly). Internally the parked state remains loops.status=202: the model-facing SIGNAL
766
+ // retired, the engine's park state did not.
767
+ // §join-blocking-collect (#354) — a READ(run://running-child) this turn armed a join. A BARE
768
+ // continue becomes an indefinite PARK: the blocking collect, on the same park machinery <-1>
769
+ // uses. The armed READ said "I want this result"; parking IS the collect (the model never had
770
+ // to know the park syntax — the engine holds the join). Any SEND clears the per-turn arm; a
771
+ // terminal with a live child stays the existing premature-terminate steer (#354 decision #1).
772
+ const joinArmed = this.#joinTargets.delete(loopId);
773
+ if (status === 102 && statement.lineMarker === null && joinArmed) {
774
+ await this.#db.engine_loop_set_status.run({ status: 202, loop_id: loopId, message: raw.length > 0 ? raw : "parked — awaiting a worker's result (blocking collect)" });
775
+ this.#parkDeadlines.set(loopId, -1); // indefinite: the bounded child's terminal is the wake edge
776
+ return { status: 102, attrs: { parked: -1, join: true } };
777
+ }
778
+ // §wait-obligation-matrix — the WAIT: SEND[202], and the legacy SEND[102]<T> the terminal
779
+ // redesign spelled the same park with, are one obligation-checked wait (waitpid). A live
780
+ // obligation (a spawned child or open stream, J) BLOCKS the loop until it concludes and
781
+ // reawakens it (§run-lifecycle-child-wake); a wait on nothing (∅) is already satisfied and
782
+ // resolves like 200, so <-1>+∅ self-resolves rather than hang the agent; a pending own
783
+ // retrieval (R) just lands next turn, so the wait continues.
784
+ if (status === 202 || (status === 102 && statement.lineMarker !== null)) {
785
+ const marks = statement.lineMarker?.marks[0];
786
+ const seconds = typeof marks === "number" ? marks : -1; // bare 202 / absent T = indefinite, bounded by the join
787
+ if (await this.#hasLiveWork(runId)) {
788
+ await this.#db.engine_loop_set_status.run({ status: 202, loop_id: loopId, message: raw.length > 0 ? raw : "waiting on live work" });
789
+ this.#parkDeadlines.set(loopId, seconds);
790
+ return { status: 202, attrs: { waiting: seconds } };
791
+ }
792
+ const retrievals = await this.#db.engine_turn_retrievals.all({ turn_id: turnId });
793
+ if (retrievals.length > 0)
794
+ return { status: 102 }; // R lands next turn — continue, don't conclude over it
795
+ // §send-undelivered-child-term — the fan-out race: a worker that concluded DURING this
796
+ // turn's generation is not live (J misses it), but its collect delta is queued for the
797
+ // NEXT build. The wait is NOT on nothing — the results are on the doorstep. Parking
798
+ // would hang (the wake edges already fired into an unparked run); R semantics: continue,
799
+ // the deltas land next turn, the model weighs them and concludes.
800
+ const undelivered = await this.#db.engine_run_has_undelivered_child_term.get({ run_id: runId, turn_id: turnId });
801
+ if (undelivered !== undefined)
802
+ return { status: 102 };
803
+ // ∅ — a wait on zero obligations is already satisfied; conclude like 200 (incl. <-1>+∅).
804
+ // #379 (owner ruling): the ENGINE concluded this loop, not the model — mark it as state
805
+ // so the COLLECT/delta render the act instead of presenting the park text as a result.
806
+ // The model's words (terminal_message) are never rewritten.
807
+ await this.#db.engine_loop_set_status.run({ status: 200, loop_id: loopId, message: raw === "" ? null : raw });
808
+ await this.#db.engine_loop_mark_terminated_by.run({ terminated_by: "collapse", loop_id: loopId });
809
+ return { status: 200 };
810
+ }
811
+ // §send-300-choices — ask the operator and park (the answer returns via loop.inject).
812
+ // The three-state cascade (owner design): PLURNK_QUESTIONS unset = ALLOWED (not enabled);
813
+ // =0 = DENIED servicewide (a ceiling the client cannot override); ENABLED requires the
814
+ // client to affirmatively pass it per session (settings.questions — the interactive client
815
+ // with a human enables its own sessions; headless/bench never asks). Enabled sessions ALSO
816
+ // get the questions.md teaching injected (docEntries) — capability and teaching gate as one.
817
+ // Disabled → refused with a self-decide steer, never a park into the void.
818
+ if (status === 300) {
819
+ if (!(await SessionSettings.questionsEnabled(this.#db, ctx.sessionId))) {
820
+ return { status: 409, error: "Operator asks ([300]) are not enabled in this environment — no one is watching to answer. Decide yourself and proceed: SEND[102] to continue, or [200] to conclude." };
821
+ }
822
+ // Owner ruling (#346): the question rides the SAME proposal system as file edits and
823
+ // MCP auths — stop the world. Returning 202 here routes through the proposal seam
824
+ // (#isProposal admits signal-300 SENDs): loop/proposal carries {question, choices},
825
+ // loop.resolve's accept body IS the answer, and applyResolution writes it into the
826
+ // model-facing rx — the answer arrives as the result of the ask itself. Timeout is
827
+ // the standard §proposal-timeout-cancels. Zero options = an open question.
828
+ const parts = raw.split(";").map((x) => x.trim()).filter((x) => x.length > 0);
829
+ const [question = "", ...choices] = parts;
830
+ return { status: 202, attrs: choices.length > 0 ? { question, choices } : { question } };
831
+ }
832
+ // [200] — terminate, gated by the pending set (post-batch). The row records the refused
833
+ // attempt faithfully (status_rx=409, never erased); the loop stays a continue; the strike
834
+ // couples in runTurn. [499] abandons regardless — discard by stated intent.
835
+ if (status === 200) {
836
+ // §send-200-failed-ops (#363, owner ruling: never 200 over a failed op) — the failure
837
+ // twin of the pending set: this turn's failed op results (and this emission's parse
838
+ // errors, threaded — they mint as rows only after dispatch) are UNSEEN until the next
839
+ // packet, so concluding over them is concluding blind. Refused 409; next turn, the
840
+ // errors in-log and weighed, [200] stands. [499] below is never gated — declaring
841
+ // failure IS weighing it.
842
+ const failedRows = await this.#db.engine_turn_failures.all({ turn_id: turnId });
843
+ const failCount = failedRows.length + (ctx.turnParseErrors ?? 0);
844
+ if (failCount > 0) {
845
+ return { status: 409, error: `Termination attempted despite ${failCount} failed operation(s) this turn. The errors land in your log next turn — weigh them, then conclude (or SEND[499] to abandon).` };
846
+ }
847
+ const pending = await this.#pendingSet(runId, turnId);
848
+ if (pending.length > 0) {
849
+ // Kind-specific steer (owner wording, xpath/topo forensics): retrievals-only is
850
+ // gemma's read-and-conclude idiom — no lever to pull, the results simply arrive;
851
+ // KILL/park advice only muddies it. Streams/children keep the remedy steer.
852
+ const retrievalsOnly = pending.every((k) => k.startsWith("results of this turn's"));
853
+ if (retrievalsOnly) {
854
+ // attrs.retrievalOnly — the strike decoupler (owner ruling): atomic-turn-
855
+ // pretrained models pair fetch-and-answer by habit; the refusal teaches,
856
+ // the strike executed. Engine reads this to skip the strike.
857
+ // #384 (owner wording) — the steer speaks PACKET-TIME, not dispatch-time: the
858
+ // model reads this 409 in the same packet where the retrieval results already
859
+ // sit, so it names their presence and the move (review, react) — never a
860
+ // future tense ("next turn" read as wait-more: a model parked on it).
861
+ return { status: 409, error: "Termination attempted despite this turn's retrieval operations. Their results are now in the log for review and reaction.", attrs: { retrievalOnly: true } };
862
+ }
863
+ return { status: 409, error: `Attempted [200] termination with pending work: ${pending.join("; ")}. KILL what you no longer need; SEND[102] (or [102]<seconds>) to receive the rest; then conclude.` };
864
+ }
865
+ await this.#db.engine_loop_set_status.run({ status: 200, loop_id: loopId, message: raw === "" ? null : raw });
866
+ return { status: 200 };
867
+ }
868
+ if (status === 499) {
869
+ await this.#db.engine_loop_set_status.run({ status: 499, loop_id: loopId, message: raw === "" ? null : raw });
870
+ return { status: 499 };
871
+ }
872
+ // Every other signal — 102 bare, 202 (retired as a terminal; now ordinary mid-comms), 1xx —
873
+ // is a plain broadcast row: no loop transition.
874
+ return { status };
875
+ }
876
+ async #run(schemeName, statement, ctx) {
877
+ if (schemeName === null)
878
+ return { status: 400 };
879
+ const handler = this.#schemes.get(schemeName);
880
+ if (handler === undefined)
881
+ return { status: 501 };
882
+ const methodName = statement.op.toLowerCase();
883
+ const method = handler[methodName];
884
+ if (typeof method !== "function")
885
+ return { status: 501 };
886
+ // External @plurnk/plurnk-schemes-* siblings receive the DB-free SchemeCtx
887
+ // (caps), never the raw PlurnkSchemeContext (schemes SPEC §channels). The dynamic
888
+ // dispatch is typed for in-tree schemes; the cast bridges the ctx shapes —
889
+ // the sibling reads caps, the in-tree handler reads db.
890
+ if (this.#schemes.isExternal(schemeName)) {
891
+ return method.call(handler, statement, new SchemeCtxImpl(ctx, schemeName));
892
+ }
893
+ return method.call(handler, statement, ctx);
894
+ }
895
+ // A status-202 result is a reviewable PROPOSAL (a side-effecting op — EDIT/EXEC/
896
+ // directed write — paused for client resolution) UNLESS it is a broadcast SEND.
897
+ // A broadcast SEND[202] is the model PARKING the loop (a terminal disposition,
898
+ // plurnk.md), never a side-effect — #255: gating the propose/await path on the
899
+ // bare 202 surfaced model speech as a loop/proposal and froze clients. The 202
900
+ // is overloaded (proposal-pause vs parked-terminal); the op disambiguates it.
901
+ static #isProposal(statement, result) {
902
+ // A broadcast SEND park is model speech, not a proposal (#255) — EXCEPT a [300] question,
903
+ // which IS a proposal by owner ruling (#346: the same stop-the-world system as file edits).
904
+ if (result.status !== 202)
905
+ return false;
906
+ if (statement.op === "SEND" && statement.signal === 300)
907
+ return true;
908
+ return !(statement.op === "SEND" && statement.target === null);
909
+ }
910
+ async #writeLog({ statement, result, runId, loopId, turnId, sequence, origin, }) {
911
+ const target = this.#extractTarget(statement.target);
912
+ const lineMarkerJson = "lineMarker" in statement && statement.lineMarker !== null
913
+ ? JSON.stringify(statement.lineMarker)
914
+ : null;
915
+ // A proposal (status 202 from a side-effecting op) is written to the log in
916
+ // state='proposed' until the proposal lifecycle resolves it; attrs holds the
917
+ // scheme-supplied payload (file diff, exec command, etc.) the client renders
918
+ // for review and the scheme consumes on accept. A broadcast SEND[202] is a
919
+ // parked-terminal, NOT a proposal (#isProposal / #255) → state='resolved'.
920
+ const isProposed = _a.#isProposal(statement, result);
921
+ let attrsObj = (result.attrs !== undefined && result.attrs !== null)
922
+ ? { ...result.attrs }
923
+ : {};
924
+ // EXEC produces a stream entry addressed by RUNTIME TAG as authority (§exec): it lives
925
+ // at <runtime>:///<loop_seq>/<turn_seq>/<sequence> (e.g. sh:///1/1/2). That address is a
926
+ // SEPARATE `stream` link in attrs — NOT an overload of `target`, which stays faithful to
927
+ // the EXEC's own slot (the cwd, or the path to the executable). The log:/// coordinate
928
+ // shares the trailing <loop>/<turn>/<seq>, so the op still correlates to its stream.
929
+ // Runtime comes from statement.signal (EXEC's runtime slot), resolvable for failed execs
930
+ // too; empty/absent = the default shell.
931
+ if (statement.op === "EXEC") {
932
+ const seqs = await this.#db.engine_loop_turn_seqs.get({
933
+ loop_id: loopId, turn_id: turnId,
934
+ });
935
+ if (seqs === undefined)
936
+ throw new Error(`Dispatcher.#writeLog: loop_turn_seqs returned no row for loop=${loopId} turn=${turnId}`);
937
+ const runtime = (typeof statement.signal === "string" && statement.signal.length > 0) ? statement.signal : "sh";
938
+ const coordPathname = `/${seqs.loop_seq}/${seqs.turn_seq}/${sequence}`;
939
+ attrsObj.pathname = coordPathname;
940
+ attrsObj.stream = `${runtime}://${coordPathname}`;
941
+ // Mutate the in-memory result.attrs too: the dispatch path
942
+ // hands originalResult.attrs to handler.applyResolution after
943
+ // proposal accept (see ProposalLifecycle.runApply). Both views —
944
+ // the stored row AND the in-memory proposal — need the same
945
+ // pathname so applyResolution writes the entry at the same URI.
946
+ if (result.attrs !== undefined && result.attrs !== null) {
947
+ result.attrs.pathname = coordPathname;
948
+ }
949
+ }
950
+ const attrs = JSON.stringify(attrsObj);
951
+ const txJson = JSON.stringify(statement);
952
+ const rxJson = JSON.stringify(result);
953
+ const row = await this.#db.engine_insert_log_entry.get({
954
+ run_id: runId,
955
+ loop_id: loopId,
956
+ turn_id: turnId,
957
+ sequence: sequence,
958
+ origin,
959
+ source: null, // dispatch entries are self-authored; §env-delta deltas set this
960
+ op: statement.op,
961
+ suffix: statement.suffix,
962
+ signal: this.#signalToJson(statement.signal),
963
+ scheme: target.scheme,
964
+ username: target.username,
965
+ password: target.password,
966
+ hostname: target.hostname,
967
+ port: target.port,
968
+ pathname: target.pathname,
969
+ params: target.params,
970
+ fragment: target.fragment,
971
+ lineMarker: lineMarkerJson,
972
+ tx: txJson,
973
+ mimetype_tx: "application/json",
974
+ rx: rxJson,
975
+ mimetype_rx: "application/json",
976
+ status_rx: result.status,
977
+ tokens: this.#tokenize(txJson) + this.#tokenize(rxJson),
978
+ state: isProposed ? "proposed" : "resolved",
979
+ outcome: null,
980
+ attrs,
981
+ });
982
+ if (row === undefined)
983
+ throw new Error("Dispatcher.#writeLog: INSERT ... RETURNING produced no row");
984
+ return row.id;
985
+ }
986
+ // Normalize a parsed path for storage. The `file` scheme is a routing
987
+ // internal — never stored, never rendered to the model. Both bare paths
988
+ // and `file:///...` inputs collapse to scheme=null at this boundary, so
989
+ // entries.scheme / log_entries.scheme never carry the string "file".
990
+ #extractTarget(path) {
991
+ if (path === null)
992
+ return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: null, params: null, fragment: null };
993
+ // `local` (bare path) and `regex` (grammar 0.46 `#pattern#flags` target) carry no URL parts — store the raw text as the pathname for the log record, scheme=null.
994
+ if (path.kind === "regex")
995
+ return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: path.raw, params: null, fragment: null }; // regex source — no decode
996
+ if (path.kind === "local")
997
+ return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: decodePathParens(path.raw), params: null, fragment: null }; // #239 item 4
998
+ const scheme = path.scheme === "file" ? null : path.scheme;
999
+ // Every registered (plurnk-namespace) scheme uses its authority as a namespace segment — fold
1000
+ // it into the canonical pathname so known://x ≡ known:///x ≡ /x and the log keys identically to
1001
+ // the entry (/prompt/<run>/<loop>/<N>, /docs/x.md). A foreign web host (http://, unregistered) is NOT a
1002
+ // namespace: keep it in hostname. run:// is the one registered EXCEPTION — its authority IS the
1003
+ // run selector (§run-scheme), and run://self must stay distinct from run://name, so Run.ts
1004
+ // folds the owner into the storage path itself, never here.
1005
+ const foldNs = scheme !== null && scheme !== "run" && this.#schemes.has(scheme);
1006
+ return {
1007
+ scheme, username: path.username, password: path.password,
1008
+ hostname: foldNs ? null : path.hostname, port: path.port,
1009
+ pathname: decodePathParens(foldNs ? foldAuthorityIntoPath(path.hostname, path.pathname) : path.pathname), // #239 item 4
1010
+ params: JSON.stringify(path.params), fragment: path.fragment,
1011
+ };
1012
+ }
1013
+ #signalToJson(signal) {
1014
+ if (signal === null || signal === undefined)
1015
+ return null;
1016
+ return JSON.stringify(signal);
1017
+ }
1018
+ }
1019
+ _a = Dispatcher;
1020
+ export default Dispatcher;
1021
+ //# sourceMappingURL=Dispatcher.js.map