@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,1119 @@
1
+ // Top-level daemon orchestrator. Owns the DB connection, engine, registries,
2
+ // the daughter-module seam (#364: the daemon owns no transport).
3
+ // SPEC §rpc.
4
+ import { readFile } from "node:fs/promises";
5
+ import { resolve, dirname } from "node:path";
6
+ import ChannelWrite from "../core/ChannelWrite.js";
7
+ import { Paths } from "../index.js";
8
+ import Engine from "../core/Engine.js";
9
+ import ExecutorRegistry from "../core/ExecutorRegistry.js";
10
+ import SchemeRegistry from "../core/SchemeRegistry.js";
11
+ import { Mimetypes } from "@plurnk/plurnk-mimetypes";
12
+ import LogEntry from "./logEntry.js";
13
+ import Envelope from "./envelope.js";
14
+ import ClientInput from "./client-input.js";
15
+ import ClientTurn from "./clientTurn.js";
16
+ import LoopDocs from "./loopDocs.js";
17
+ import GitMembership from "../core/git-membership.js";
18
+ import Fork from "../core/fork.js";
19
+ import { promptLoopPrefix } from "../core/plurnk-uri.js";
20
+ import { parseAliasesFromEnv, resolveActiveAlias } from "@plurnk/plurnk-providers";
21
+ import Yolo from "./yolo.js";
22
+ import NoProposals from "./noProposals.js";
23
+ import { DEFAULT_LOOP_FLAGS } from "../core/scheme-types.js";
24
+ export default class Daemon {
25
+ #db;
26
+ #engine;
27
+ #schemes;
28
+ #mimetypes;
29
+ #provider;
30
+ #nodeModulesPath;
31
+ #discoveryCwd;
32
+ #started = false; // start() runs once — boots discovery + daughter modules (#364: no listener, ever)
33
+ // The emit half of the broadcast, exposed as an in-process event source (#355). A transport
34
+ // module (plurnk-agui) subscribes and fans out to its OWN clients; core emits, never fans out
35
+ // for it. The WS fan-out below is legacy scaffolding that retires at the AG-UI+ cutover.
36
+ #eventSubscribers = new Set();
37
+ // Run-level drain registry. At most one drain per run. The stored object
38
+ // is the drain's identity handle: start/exit compare it by reference so a
39
+ // drain exiting never clobbers a successor that raced in, and a loop
40
+ // enqueued during teardown is never stranded. A drain is a pure queue
41
+ // consumer (claim → run → exit on empty queue); streams live independently
42
+ // (subscriptions + Exec.idle), and a concluding stream routes through
43
+ // inject() like any other loop source.
44
+ #activeDrains = new Map();
45
+ // Per-run cancellation scope. Loops AND the streams they spawn (execs)
46
+ // share this signal, so loop.cancel / shutdown abort it once and every
47
+ // in-flight subscription tears down — even a spawn that registers AFTER the
48
+ // cancel self-aborts against the already-aborted signal (no race). Outlives
49
+ // any single (ephemeral) drain; replaced with a fresh controller once
50
+ // aborted so a later loop.run isn't born cancelled.
51
+ #runAborts = new Map();
52
+ // grammar 0.74.20 EXEC `<T,P>` — per-run hibernation poll-wake timer. When a loop parks at
53
+ // a park with a polled stream, a timer fires every P seconds to resume it (§exec-poll). One
54
+ // per run (the tightest cadence); cleared/replaced on each park and on cancel.
55
+ #parkTimers = new Map();
56
+ #pollTimers = new Map();
57
+ // Per-run drain-transition lock — see #withDrainLock (R4 / §run-lifecycle-single-drain).
58
+ #drainLocks = new Map();
59
+ // §run-lifecycle-child-wake — runs OWED a wake: a child/stream conclusion fired while the run was
60
+ // mid-turn (not yet slept), so #wakeParkedRun could not resume it. A worker-run conclusion is a
61
+ // BOUNDED, lossless wake (a worker always concludes), so a hibernation awaiting one MUST return —
62
+ // never deadlock. The drain honors the owed wake at the run's next park, closing the conclude-
63
+ // before-park race. (Only a live exec stream, unbounded absent a timeout, may hold a park open.)
64
+ #owedWakes = new Set();
65
+ constructor({ db, schemes, mimetypes, provider, nodeModulesPath, }) {
66
+ this.#db = db;
67
+ this.#schemes = schemes ?? new SchemeRegistry();
68
+ this.#provider = provider ?? null;
69
+ // Plugin discovery resolves from the SERVICE's node_modules (its exec/scheme/mimetype
70
+ // deps), NOT process.cwd() — else a globally-installed daemon started from a project dir
71
+ // finds no plugins. The bin passes the package-relative path; cwd default holds for
72
+ // in-repo tests. discover() takes a cwd and joins node_modules, so derive the parent.
73
+ this.#nodeModulesPath = nodeModulesPath ?? resolve(process.cwd(), "node_modules");
74
+ this.#discoveryCwd = dirname(this.#nodeModulesPath);
75
+ // Mimetypes owns discovery + detection; we inject the tokenize fn (from the provider's
76
+ // countTokens) and default to text/markdown.
77
+ this.#mimetypes = mimetypes ?? new Mimetypes({
78
+ defaultMimetype: "text/markdown",
79
+ discoverOptions: { cwd: this.#discoveryCwd },
80
+ });
81
+ this.#engine = new Engine({
82
+ db, schemes: this.#schemes, mimetypes: this.#mimetypes,
83
+ // Same provider-backed source as the Mimetypes tokenize lambda
84
+ // above; sync here because countTokens is sync (§provider-surface) and the
85
+ // write helpers store the count inline. Divisor tripwire only
86
+ // until a provider is resolved.
87
+ tokenize: (text) => this.#provider?.countTokens(text) ?? Math.ceil(text.length / 4),
88
+ streamEventNotify: (sessionId, event) => this.notifyStreamEvent(sessionId, event),
89
+ wakeRunNotify: (payload) => { void this.#handleWakeRun(payload); },
90
+ // run:// loop-start primitive — spawn/fork/irc deliver through
91
+ // Daemon.inject (active sister → fold; idle → enqueue + drain). The
92
+ // daemon owns provider + the law-file system prompt; the run scheme
93
+ // handler carries neither. Fire-and-forget: the returned drain runs
94
+ // independently (the sister is its own run). §machine-processes
95
+ injectRun: async ({ sessionId, runId, prompt, flags }) => {
96
+ if (this.#provider === null)
97
+ throw new Error("injectRun: no provider configured");
98
+ const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
99
+ const { action, loopId } = await this.inject({ sessionId, runId, prompt, provider: this.#provider, systemPrompt, ...(flags === undefined ? {} : { flags }) });
100
+ return { action, loopId };
101
+ },
102
+ // run:// KILL (terminate) — abort any run's in-flight work by id. One
103
+ // abort, one scope (Daemon.cancelDrain): the active loop closes 499,
104
+ // background streams tear down. Whoever holds the address may end it.
105
+ cancelRun: (runId) => this.cancelDrain(runId, "killed via run:// KILL"),
106
+ telemetryEventNotify: (sessionId, payload) => this.notifyTelemetryEvent(sessionId, payload),
107
+ });
108
+ // Wire proposal-pending events to the loop/proposal WS notification.
109
+ // Sessionid scopes the broadcast to clients on the same session.
110
+ this.#engine.onProposalPending((event) => {
111
+ this.#broadcast({ sessionId: event.sessionId }, "loop/proposal", {
112
+ logEntryId: event.logEntryId,
113
+ loopId: event.loopId,
114
+ turnId: event.turnId,
115
+ op: event.op,
116
+ target: event.target,
117
+ body: event.body,
118
+ attrs: event.attrs,
119
+ // event.flags is carried for discoverability — a client in
120
+ // server-YOLO mode (event.flags.yolo=true) knows to skip
121
+ // rendering review UI because the entry will resolve in-
122
+ // process before any human can react.
123
+ flags: event.flags,
124
+ });
125
+ });
126
+ // In-tree YOLO listener — auto-accepts proposals when the loop's
127
+ // persisted flags.yolo === true. Skips client roundtrip entirely.
128
+ Yolo.attachYolo(this.#engine, this.#db);
129
+ // Inverse of YOLO: auto-REJECT proposals in-process when the loop's
130
+ // persisted flags.noProposals === true (client has no review channel).
131
+ // The model sees an ordinary 400, never the orchestration reason.
132
+ NoProposals.attachNoProposals(this.#engine, this.#db);
133
+ }
134
+ // The client-interface seam (#355). A transport module subscribes to the daemon's in-process
135
+ // event source: it receives every session-scoped engine event as `(sessionId, method, params)`
136
+ // and fans out to its OWN clients — core emits, it never fans out for the module. Returns an
137
+ // unsubscribe. `sessionId` is the event's session, or null for a global event (e.g. session/created).
138
+ // The engine and its events are core; the fan-out belongs to the module.
139
+ subscribeToEvents(handler) {
140
+ this.#eventSubscribers.add(handler);
141
+ return () => { this.#eventSubscribers.delete(handler); };
142
+ }
143
+ // The client-interface seam (#355) — proposal HITL. A transport module reads the stopped-world
144
+ // proposals for a session (rendering each as a TOOL_CALL) and feeds back the human's decision. The
145
+ // gate, validation, and applyResolution stay core (Engine.resolveProposal); the seam is the read +
146
+ // the resolve, never the mechanism. `resolveProposal` throws for an unknown/already-resolved id.
147
+ async pendingProposals(sessionId) {
148
+ return this.#db.proposal_list_pending.all({ session_id: sessionId });
149
+ }
150
+ resolveProposal(logEntryId, resolution) {
151
+ this.#engine.resolveProposal(logEntryId, resolution);
152
+ }
153
+ // The client-interface seam (#355) — drive/steer a loop. The module supplies only session/run/prompt;
154
+ // the provider and the law-file system prompt are core's and stay inside. Returns immediately — the
155
+ // loop runs async and its outcome arrives on the event source (loop/terminated). `cancelDrain` (public)
156
+ // is the cancel hook. Both funnel through the unified `inject`, which owns the drain lifecycle.
157
+ async runLoop(args) {
158
+ ClientInput.validateLoopFlags("loop.run", args.flags); // seam fail-hard (#364) — a truthy string must never flip YOLO
159
+ if (this.#provider === null)
160
+ throw new Error("runLoop: no provider configured");
161
+ const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
162
+ // §machine-processes — the model NEVER runs in a client-origin run (its packets would carry
163
+ // client op.* rows). The module resolves the model run via ensureModelRun and passes it (or a
164
+ // fork); a client run here is a caller error, refused loudly rather than silently rehomed.
165
+ const target = await this.#db.envelope_get_run_by_id.get({ id: args.runId });
166
+ if (target === undefined)
167
+ throw new Error(`runLoop: run ${args.runId} not found`);
168
+ if (target.origin === "client")
169
+ throw new Error(`runLoop: run ${args.runId} is a client run — loops run in model runs (§machine-processes); resolve one with ensureModelRun(sessionId)`);
170
+ // Pre-loop docs (both sets: operator/client mdDocs + the teaching docs the turn-1
171
+ // FIND(plurnk://docs/**) foist discovers) — ONE truth shared with the legacy loop.run route.
172
+ await LoopDocs.materialize(this.#engine, this.#db, args.sessionId);
173
+ // §operator-config-max-turns-ceiling — the operator ceiling clamps a per-call maxTurns; a
174
+ // seam caller must not bypass operator policy (inject only DEFAULTS from env, never clamps).
175
+ const ceiling = Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "-1");
176
+ const requested = args.maxTurns ?? ceiling;
177
+ const maxTurns = ceiling < 0 ? requested : (requested < 0 ? ceiling : Math.min(requested, ceiling));
178
+ const { action, loopId, turnSeq } = await this.inject({ ...args, ...(maxTurns >= 0 ? { maxTurns } : {}), provider: this.#provider, systemPrompt });
179
+ return { action, loopId, ...(turnSeq !== undefined ? { turnSeq } : {}) };
180
+ }
181
+ // §machine-processes — the session's model run (created on first use), distinct from the client
182
+ // run so the model's packets never carry client op.* rows. The module binds its threads to this.
183
+ ensureModelRun(sessionId) {
184
+ return Envelope.ensureModelRun(this.#db, sessionId);
185
+ }
186
+ // The op-dispatch hook (#355) — execute one parsed op on behalf of a client: journaled as a
187
+ // client-origin turn (the log is core's, a client op is a first-class citizen), dispatched through
188
+ // the engine, then emitted as log/entry on the event source. One seam op backs the whole op_*
189
+ // family (read/edit/copy/find/fold/look/move/open/send/exec); the module parses at its edge with the
190
+ // grammar package and hands over the statement, then fans the emitted entry out to its own clients.
191
+ async dispatchAsClient(args) {
192
+ const { sessionId, runId, statement } = args;
193
+ const clientLoopId = await Envelope.ensureClientLoop(this.#db, runId);
194
+ const turnId = await ClientTurn.insertClientTurn(this.#db, clientLoopId);
195
+ const entryIds = [];
196
+ const result = await this.#engine.dispatch({
197
+ statement, sessionId, runId, loopId: clientLoopId, turnId, sequence: 1,
198
+ origin: "client", onDispatch: (logEntryId) => { entryIds.push(logEntryId); },
199
+ });
200
+ for (const logEntryId of entryIds) {
201
+ const entry = await LogEntry.fetchLogEntry(this.#db, logEntryId);
202
+ this.#broadcast({ sessionId }, "log/entry", { entry });
203
+ }
204
+ return result;
205
+ }
206
+ // op.look (#283/#358) — the pure READ-projection query on the seam: resolve a READ through the
207
+ // full scheme resolver and return its content, writing NO log row — the client's off-run
208
+ // inspection primitive (the module rewrites LOOK→READ and parses at its edge, exactly like
209
+ // dispatchAsClient). Rides the client loop so log:/// coordinates resolve run-relative;
210
+ // invisible to the model. Engine.look enforces READ-only.
211
+ async look(args) {
212
+ const { sessionId, runId, statement } = args;
213
+ const clientLoopId = await Envelope.ensureClientLoop(this.#db, runId);
214
+ return await this.#engine.look({ statement, sessionId, runId, loopId: clientLoopId });
215
+ }
216
+ // The log-read hook (#355) — a session's journal, the module's primary render input. The run is
217
+ // ownership-verified against the session (a session reads only its own runs — the model run included,
218
+ // #214); entries filter by loop/turn/since-id or the full L/T/S display coordinate. Core owns the
219
+ // journal + the invariant; the module shapes the entries into AG-UI messages at its edge.
220
+ async readLog(args) {
221
+ const { sessionId, runId } = args;
222
+ const target = await this.#db.envelope_get_run_by_id.get({ id: runId });
223
+ if (target === undefined)
224
+ throw new Error(`run ${runId} not found`);
225
+ if (target.session_id !== sessionId)
226
+ throw new Error(`run ${runId} is not in this session (${sessionId})`);
227
+ const rows = await this.#db.log_read_recent_ids.all({
228
+ run_id: runId,
229
+ loop_id: args.loopId ?? null, turn_id: args.turnId ?? null, since_id: args.sinceId ?? null,
230
+ loop_seq: args.loopSeq ?? null, turn_seq: args.turnSeq ?? null, sequence: args.sequence ?? null,
231
+ limit: Math.min(args.limit ?? 100, 1000),
232
+ });
233
+ const entries = [];
234
+ for (const r of rows)
235
+ entries.push(await LogEntry.fetchLogEntry(this.#db, r.id));
236
+ return entries;
237
+ }
238
+ // The metadata-read hooks (#355) — the module's render surface beyond the journal. Thin delegations
239
+ // into core's envelope / membership / provider machinery; the module fans the results into its own views.
240
+ listProviders() {
241
+ const active = resolveActiveAlias();
242
+ return {
243
+ aliases: parseAliasesFromEnv().map((a) => {
244
+ const isActive = active !== null && active.alias === a.alias;
245
+ return {
246
+ alias: a.alias, provider: a.provider, model: a.model, active: isActive,
247
+ // contextSize = the EFFECTIVE prompt budget (window minus reserves, #345) — the same
248
+ // denominator loop-usage reports; known for the active alias, null elsewhere.
249
+ contextSize: isActive && this.#provider !== null ? this.#engine.promptBudgetFor(this.#provider) : null,
250
+ };
251
+ }),
252
+ };
253
+ }
254
+ listSessions() { return Envelope.listSessions(this.#db); }
255
+ listRuns(sessionId) { return Envelope.listRunsForSession(this.#db, sessionId); }
256
+ listPrompts(sessionId, limit = 100) { return Envelope.listPromptsForSession(this.#db, sessionId, limit); }
257
+ listMembers(sessionId) { return GitMembership.resolveMembershipEffects(this.#db, sessionId, undefined); }
258
+ listConstraints(sessionId) {
259
+ return this.#db.crud_list_session_constraints.all({ session_id: sessionId });
260
+ }
261
+ // Session lifecycle (#355): the module's session-management surface. Inputs arrive already validated
262
+ // at the module's edge ("I am the wall" — settings as the stored JSON string, constraints as a typed
263
+ // array, roots absolute); core owns the envelope, its reserved-name + name-uniqueness invariants,
264
+ // membership resolution, warmSessionDerivations, and the session/created emit. No connection state
265
+ // (which client is on which session) lives here — that's the module's.
266
+ async createSession(args) {
267
+ // The SEAM fail-hards on malformed client input (#364 — validation flushed out of the
268
+ // retired WS handlers so every module inherits it): settings bag (#231/#232/#249/#328),
269
+ // constraints (#200), absolute projectRoot.
270
+ const projectRoot = ClientInput.assertProjectRoot("session.create", args.projectRoot);
271
+ const settings = ClientInput.parseSettings(args.settings);
272
+ const constraints = ClientInput.parseConstraints(args.constraints);
273
+ const envelope = await Envelope.createClientEnvelope(this.#db, { name: args.name, projectRoot, settings });
274
+ for (const { effect, glob } of constraints) {
275
+ await this.#db.crud_insert_session_constraint.run({ session_id: envelope.sessionId, effect, glob });
276
+ }
277
+ if (constraints.length > 0)
278
+ await GitMembership.resolveGitMembership(this.#db, envelope.sessionId, undefined);
279
+ void this.#engine.warmSessionDerivations(envelope.sessionId).catch(() => { });
280
+ this.#broadcast("all", "session/created", { id: envelope.sessionId, name: envelope.sessionName, projectRoot: envelope.projectRoot });
281
+ return envelope;
282
+ }
283
+ async attachSession(args) {
284
+ // attachToSession owns the reserved-name + run-ownership invariants; the seam just delegates + warms.
285
+ const envelope = await Envelope.attachToSession(this.#db, args.sessionId, { runId: args.runId, runName: args.runName });
286
+ void this.#engine.warmSessionDerivations(envelope.sessionId).catch(() => { });
287
+ return envelope;
288
+ }
289
+ async renameSession(sessionId, name) {
290
+ if (typeof name !== "string" || name.length === 0)
291
+ throw new Error("session.rename: name must be a non-empty string"); // seam fail-hard (#364)
292
+ const taken = await this.#db.envelope_get_session_by_name.get({ name });
293
+ if (taken !== undefined && taken.id !== sessionId)
294
+ throw new Error(`a session named "${name}" already exists — pick another`);
295
+ return { id: sessionId, name: await Envelope.updateSessionName(this.#db, sessionId, name) };
296
+ }
297
+ async constrain(sessionId, effect, glob) {
298
+ ClientInput.assertConstraint("session.constrain", effect, glob);
299
+ // Headless is FOREVER (owner ruling, 2026-07-11, matching the client SPEC): a session is
300
+ // born with its workspace pointer or never has one — so a 'repo' constraint on a headless
301
+ // session can never resolve. Refuse legibly instead of recording a forever-pending lie.
302
+ if (effect === "repo") {
303
+ const s = await this.#db.envelope_get_session.get({ id: sessionId });
304
+ if (s?.project_root == null)
305
+ throw new Error("session.constrain: this session is headless — and headless is forever (a workspace pointer is set at session.create or never). A 'repo' overlay needs a session created with projectRoot.");
306
+ }
307
+ await this.#db.crud_insert_session_constraint.run({ session_id: sessionId, effect, glob });
308
+ await GitMembership.resolveGitMembership(this.#db, sessionId, undefined);
309
+ // Members may have just landed — warm their derivations NOW (fire-and-forget, off the hot
310
+ // path), exactly like createSession does (dogfood catch: '/repo' embeddings waited for a
311
+ // later turn's pump).
312
+ void this.#engine.warmSessionDerivations(sessionId).catch(() => { });
313
+ return { effect, glob };
314
+ }
315
+ async unconstrain(sessionId, effect, glob) {
316
+ ClientInput.assertConstraint("session.unconstrain", effect, glob);
317
+ await this.#db.crud_delete_session_constraint.run({ session_id: sessionId, effect, glob });
318
+ await GitMembership.resolveGitMembership(this.#db, sessionId, undefined);
319
+ return { effect, glob };
320
+ }
321
+ // The entry-shape hook (#355) — one entry's channels + tags + metadata at a path. With channel+offset,
322
+ // returns just that channel's content sliced from the offset: the incremental streaming read (#192,
323
+ // the delta leaves storage, not the whole channel). The module renders growing output by re-polling.
324
+ async readEntry(args) {
325
+ const m = args.target.match(/^([a-z][a-z0-9+.-]*):\/\/(.*)$/);
326
+ if (m === null)
327
+ throw new Error(`readEntry: target must be URL-shaped (scheme://pathname); got: ${args.target}`);
328
+ if (args.offset !== undefined && args.channel === undefined)
329
+ throw new Error("readEntry: offset requires channel (which channel to slice)");
330
+ const scheme = m[1];
331
+ const pathname = m[2].split("#")[0];
332
+ const row = await this.#db.entry_read_lookup.get({ session_id: args.sessionId, scheme, pathname });
333
+ if (row === undefined)
334
+ return { status: 404, entry: null };
335
+ let channelRows;
336
+ if (args.channel === undefined) {
337
+ channelRows = await this.#db.entry_read_channels.all({ entry_id: row.id });
338
+ }
339
+ else {
340
+ const r = await this.#db.entry_read_channel_slice.get({ entry_id: row.id, channel: args.channel, offset: args.offset ?? 0 });
341
+ channelRows = r === undefined ? [] : [r];
342
+ }
343
+ const channels = {};
344
+ for (const c of channelRows)
345
+ channels[c.name] = { content: c.content, contentLength: c.contentLength, mimetype: c.mimetype, tokens: c.tokens, state: c.state };
346
+ const tagRows = await this.#db.crud_read_tags.all({ entry_id: row.id });
347
+ return { status: 200, entry: { id: row.id, scope: row.scope, sessionId: row.session_id, scheme: row.scheme, pathname: row.pathname, channels, tags: tagRows.map((t) => t.tag) } };
348
+ }
349
+ // The fork hook (#355) — branch a run's log into a new run in the same session (#228), sharing the
350
+ // session's world (entries + overlay), copying nothing of it. The module resolves the default (the
351
+ // session's model run) from its own connection state and passes the concrete runId; the seam owns the
352
+ // #366 — a fresh conversation run: AG-UI threads map to RUNS (§machine-processes — the session
353
+ // is the workspace, the run is the conversation). ensureModelRun is the stable DEFAULT door,
354
+ // forkRun the branching door (copies history); this is the fresh door — a named, empty-log,
355
+ // model-origin root that runLoop accepts. New chat = new conversation, same workspace.
356
+ async createConversationRun(args) {
357
+ const { sessionId, name } = args;
358
+ if (name !== undefined && (typeof name !== "string" || name.length === 0))
359
+ throw new Error("run.create: name must be a non-empty string");
360
+ const session = await this.#db.envelope_get_session.get({ id: sessionId });
361
+ if (session === undefined)
362
+ throw new Error(`run.create: session ${sessionId} not found`);
363
+ if (name !== undefined) {
364
+ if (Envelope.RESERVED_RUN_NAMES.has(name.toLowerCase()))
365
+ throw new Error(`run.create: name "${name}" is reserved for a non-client actor`);
366
+ const taken = await this.#db.envelope_get_run_by_name.get({ session_id: sessionId, name });
367
+ if (taken !== undefined)
368
+ throw new Error(`run.create: a run named "${name}" already exists — run names are immutable, pick another`);
369
+ }
370
+ const run = await Envelope.createModelRun(this.#db, sessionId, name);
371
+ return { runId: run.id, runName: run.name };
372
+ }
373
+ // ownership check and the run-name namespace + uniqueness invariants (names are immutable — no rename).
374
+ async forkRun(args) {
375
+ if (args.name !== undefined && (typeof args.name !== "string" || args.name.length === 0))
376
+ throw new Error("run.fork: name must be a non-empty string"); // seam fail-hard (#364)
377
+ const { sessionId, runId, name } = args;
378
+ const owner = await this.#db.envelope_get_run_by_id.get({ id: runId });
379
+ if (owner === undefined)
380
+ throw new Error(`forkRun: run ${runId} not found`);
381
+ if (owner.session_id !== sessionId)
382
+ throw new Error(`forkRun: run ${runId} is not in session ${sessionId}`);
383
+ if (name !== undefined) {
384
+ if (Envelope.RESERVED_RUN_NAMES.has(name.toLowerCase()))
385
+ throw new Error(`forkRun: name "${name}" is reserved for a non-client actor`);
386
+ const taken = await this.#db.envelope_get_run_by_name.get({ session_id: sessionId, name });
387
+ if (taken !== undefined)
388
+ throw new Error(`forkRun: a run named "${name}" already exists — run names are immutable, pick another`);
389
+ }
390
+ const branchRunId = await Fork.fork(this.#db, runId, name);
391
+ const branch = await this.#db.envelope_get_run_by_id.get({ id: branchRunId });
392
+ return { runId: branchRunId, runName: branch?.name ?? null, parentRunId: runId };
393
+ }
394
+ // The module-load hook (#355 / #289) — register a runtime into the live registry, driver-agnostic:
395
+ // the kernel knows nothing about MCP or any specific driver. The struct is the booth window agreed
396
+ // with the execs agent (execs-mcp installServer's hotload callback): framework types only — the decl
397
+ // (tag + glyph/example/documentation), the executor, the driver's probe result. RegistryEntry never
398
+ // leaves the kernel; it's wrapped here, mirroring boot. The engine's scheme-face arbitration
399
+ // (reserved / cross-family collision, #240) gates the tag before registering.
400
+ hotloadRuntime(reg) {
401
+ const { decl, executor, availability } = reg;
402
+ this.#engine.hotloadRuntime(decl.name, {
403
+ executor,
404
+ glyph: decl.glyph ?? "",
405
+ example: decl.example ?? "",
406
+ documentation: decl.documentation ?? "",
407
+ available: availability.available,
408
+ detail: availability.detail,
409
+ });
410
+ }
411
+ get engine() { return this.#engine; }
412
+ get provider() { return this.#provider; }
413
+ get schemes() { return this.#schemes; }
414
+ get mimetypes() { return this.#mimetypes; }
415
+ // The boot plug-point (#355 hook D) — register a daughter module before start(); its init runs at
416
+ // boot with the curated CoreSeam handle, where it opens its own transport/listener. Direct wiring, no
417
+ // plugin-kind abstraction: a second transport earns one if it ever appears. "Here's your handle."
418
+ // The init's return value is ignored — a module may hand back its instance (or nothing).
419
+ #moduleInits = [];
420
+ registerModule(init) {
421
+ this.#moduleInits.push(init);
422
+ }
423
+ async start() {
424
+ if (this.#started)
425
+ throw new Error("daemon already started");
426
+ this.#started = true;
427
+ // Mimetypes owns its own discovery scan over @plurnk/plurnk-mimetypes-*
428
+ // packages; pre-warm it so first index render doesn't pay the cost.
429
+ await this.#mimetypes.ready();
430
+ // Discover + probe the installed executor siblings, then hand the
431
+ // registry to the engine for exec dispatch (plurnk-service#181). The
432
+ // shell is the default runtime, so its executor must boot usable.
433
+ const executors = await ExecutorRegistry.build({ defaultRuntime: "sh", cwd: this.#discoveryCwd });
434
+ this.#engine.setExecutors(executors);
435
+ // §exec — mint a scheme per runtime tag so exec output entries address by tag
436
+ // authority (sh:///l/t/s). The "exec" scheme stays for the EXEC op dispatch.
437
+ this.#schemes.registerRuntimeSchemes(executors);
438
+ // Discover external @plurnk/plurnk-schemes-* siblings + register them
439
+ // (agnostic, by plurnk.kind:"scheme"). They light up http://, etc. with
440
+ // no further engine change — #run wraps their ctx in SchemeCtxImpl (#195).
441
+ await this.#schemes.discoverExternal(this.#discoveryCwd);
442
+ // #364 — the daemon opens NO transport, ever: daughter modules open theirs via the seam.
443
+ for (const init of this.#moduleInits)
444
+ await init(this);
445
+ }
446
+ async stop() {
447
+ if (!this.#started)
448
+ return;
449
+ this.#started = false;
450
+ // Drain order: (1) abort in-flight loops via #activeDrains so
451
+ // strike paths don't keep going, (2) await each drain's promise
452
+ // to completion, (3) drain streaming schemes' background work
453
+ // (exec spawn cleanup, channel writes). Only THEN close the DB
454
+ // upstream — drain queries hit the DB right up until they exit.
455
+ // Abort every run's cancellation scope — stops in-flight loops AND the
456
+ // streams (background execs) linked to them, so idle() doesn't block on
457
+ // a long-running command. Covers runs whose drain already exited but
458
+ // whose exec is still in flight.
459
+ // Settle the stopped world FIRST: a drain paused at a pending proposal awaits a resolution
460
+ // that will never arrive once clients are gone — allSettled(drains) below would deadlock
461
+ // the stop forever (a daemon with a pending HITL proposal could not shut down).
462
+ this.#engine.cancelAllProposals("daemon_stopping");
463
+ for (const scope of this.#runAborts.values()) {
464
+ if (!scope.signal.aborted)
465
+ scope.abort("daemon_stopping");
466
+ }
467
+ for (const t of this.#pollTimers.values())
468
+ clearTimeout(t); // drop pending hibernation poll-wakes
469
+ this.#pollTimers.clear();
470
+ const drainPromises = [...this.#activeDrains.values()].map((d) => d.promise);
471
+ await Promise.allSettled(drainPromises);
472
+ await this.#drainStreamingSchemes();
473
+ await this.#engine.drainDerivations(); // §derivation-off-hot-path — background pumps finish before the db closes upstream
474
+ }
475
+ // Per-scheme idle awaits for clean shutdown. New streaming schemes
476
+ // (SSE, WS) add themselves here as they land.
477
+ async #drainStreamingSchemes() {
478
+ const exec = this.#schemes.get("exec");
479
+ if (exec?.idle !== undefined)
480
+ await exec.idle();
481
+ }
482
+ /**
483
+ * Emit a stream/event notification scoped to the session containing the
484
+ * entry. ChannelWrite helpers (src/core/ChannelWrite.ts) invoke this when
485
+ * they update channel content or state. SPEC §notifications.
486
+ */
487
+ notifyStreamEvent(sessionId, event) {
488
+ this.#broadcast({ sessionId }, "stream/event", event);
489
+ }
490
+ /**
491
+ * Emit a telemetry/event notification scoped to the session containing
492
+ * the loop. TelemetryChannel.push invokes this for every TelemetryEvent
493
+ * (parse_error, strike, cycle, sudden_death, no_ops, max_commands_exceeded,
494
+ * action_failure) the moment it lands in the loop's telemetry buffer.
495
+ * SPEC §telemetry.
496
+ */
497
+ notifyTelemetryEvent(sessionId, payload) {
498
+ this.#broadcast({ sessionId }, "telemetry/event", payload);
499
+ }
500
+ /**
501
+ * Inject a prompt into a run. Two paths:
502
+ * - Active drain: writes a plurnk://prompt/<run>/<loop>/<next-turn> entry
503
+ * via Engine.inject. Current loop sees the new prompt at its next
504
+ * turn. Returns immediately with {action: "injected_next_turn"}.
505
+ * - No active drain: enqueues a fresh loop with the prompt at
506
+ * status=100, starts a drain. Returns the drain promise so the
507
+ * caller can await full completion.
508
+ *
509
+ * Rummy parallel: AgentLoop.inject(). Unified surface — both `loop.run`
510
+ * and wake-on-completion go through this method. §actor-boundary-passive-wake
511
+ */
512
+ // #368 — flags are LOOP-scoped (persisted per loop row; the packet's teaching follows them), so a
513
+ // prompt folding into a live/parked loop cannot re-flag it mid-flight — and it must never PRETEND
514
+ // to: an inject carrying flags that DIFFER from the target loop's effective flags is refused
515
+ // legibly (cancel the loop or omit the flags), never a silent posture discard. Identical or
516
+ // absent flags fold clean.
517
+ async #assertFoldPosture(runId, flags, loopId) {
518
+ if (flags === undefined || Object.keys(flags).length === 0)
519
+ return;
520
+ const row = loopId !== undefined
521
+ ? await this.#db.engine_get_loop_flags.get({ loop_id: loopId })
522
+ : await this.#db.drain_active_loop_flags.get({ run_id: runId });
523
+ const effective = { ...DEFAULT_LOOP_FLAGS, ...JSON.parse(row?.flags ?? "{}") };
524
+ const conflicts = Object.entries(flags).filter(([k, v]) => v !== undefined && effective[k] !== v).map(([k, v]) => `${k}: ${JSON.stringify(effective[k])} → ${JSON.stringify(v)}`);
525
+ if (conflicts.length > 0) {
526
+ throw new Error(`inject: the prompt would fold into a live loop whose flags differ (${conflicts.join(", ")}) — flags are loop-scoped and never change mid-flight. Cancel the loop (loop.cancel) and re-run with the new flags, or send the prompt without flags to adopt the loop's posture.`);
527
+ }
528
+ }
529
+ async inject(args) {
530
+ const { sessionId, runId, prompt } = args;
531
+ // Active loop (status=102)? Fold the wake/prompt into its next turn.
532
+ // engine.inject returns null when no loop is currently executing, so
533
+ // we enqueue a fresh loop below and ensure a drain claims it.
534
+ if (this.#activeDrains.has(runId)) {
535
+ await this.#assertFoldPosture(runId, args.flags); // #368 — a fold never silently discards intent
536
+ const result = await this.#engine.inject(runId, prompt);
537
+ if (result !== null) {
538
+ return { action: "injected_next_turn", loopId: result.loopId, turnSeq: result.turnSeq };
539
+ }
540
+ }
541
+ // #55 — a run PARKED at 202 RESUMES that slept loop in place: the voice door (irc / loop.inject)
542
+ // is a wake edge like a stream/child conclusion, not a fresh loop that orphans the parked one
543
+ // (which would leave the run non-quiescent forever). engine.inject writes the message as the
544
+ // slept loop's next-turn prompt (the directed message — distinct from the env door, which
545
+ // resumes promptless); then re-queue + drain it. §run-lifecycle-wake-liveness.
546
+ if (!this.#activeDrains.has(runId)) {
547
+ const slept = await this.#db.drain_find_slept_loop.get({ run_id: runId });
548
+ if (slept !== undefined) {
549
+ await this.#assertFoldPosture(runId, args.flags, slept.id); // #368 — the resume path drops nothing silently either
550
+ const injected = await this.#engine.inject(runId, prompt);
551
+ await this.#db.drain_resume_slept_loop.run({ loop_id: slept.id });
552
+ const started = await this.#ensureDrain({
553
+ sessionId, runId, provider: args.provider, systemPrompt: args.systemPrompt,
554
+ maxTurns: args.maxTurns ?? Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "50"),
555
+ });
556
+ return { action: "injected_next_turn", loopId: slept.id, ...(injected?.turnSeq !== undefined ? { turnSeq: injected.turnSeq } : {}), ...(started ?? {}) };
557
+ }
558
+ }
559
+ // Enqueue a fresh loop. Persist flags on the row.
560
+ const seqRow = await this.#db.loop_run_next_sequence.get({ run_id: runId });
561
+ if (seqRow === undefined)
562
+ throw new Error("inject: next-sequence query returned no row");
563
+ const loopRow = await this.#db.drain_enqueue_loop.get({
564
+ run_id: runId, sequence: seqRow.next, prompt,
565
+ });
566
+ if (loopRow === undefined)
567
+ throw new Error("inject: loop enqueue returned no row");
568
+ const loopId = loopRow.id;
569
+ if (args.flags !== undefined) {
570
+ const merged = { ...DEFAULT_LOOP_FLAGS, ...args.flags };
571
+ await this.#db.engine_set_loop_flags.run({
572
+ loop_id: loopId, flags: JSON.stringify(merged),
573
+ });
574
+ }
575
+ // #260 — persist client-passed @file paths before the drain claims the loop, so turn 0 foists them.
576
+ if (args.openPaths !== undefined && args.openPaths.length > 0) {
577
+ await this.#db.engine_set_loop_open_paths.run({
578
+ loop_id: loopId, open_paths: JSON.stringify(args.openPaths),
579
+ });
580
+ }
581
+ // Guarantee a drain claims the loop we just enqueued. #ensureDrain runs its
582
+ // check-and-start UNDER the per-run drain lock (§run-lifecycle-single-drain),
583
+ // serialized against a draining sibling's teardown relinquish so the two can't
584
+ // both register a drain (R4). A live drain re-claims the loop in its own
585
+ // iteration or its lock-held exit re-claim, so it's never stranded.
586
+ // firstLoopPromise is present only when THIS call started the drain — loop.run
587
+ // keys its fast-path response on that.
588
+ const started = await this.#ensureDrain({
589
+ sessionId, runId, provider: args.provider,
590
+ systemPrompt: args.systemPrompt,
591
+ maxTurns: args.maxTurns ?? Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "50"),
592
+ });
593
+ return { action: "enqueued_new_loop", loopId, ...(started ?? {}) };
594
+ }
595
+ /**
596
+ * Start a drain for the given run. The drain claims queued loops via
597
+ * drain_claim_next_loop (atomic 100→102 flip), executes each via
598
+ * Engine.runLoop, and re-checks. Stream-aware: when the queue is empty
599
+ * but the run has active subscriptions, the drain parks on a
600
+ * #drainPokes signal — wake-on-completion → inject() wakes it. Drain
601
+ * exits when queue is empty AND no active subscriptions remain.
602
+ *
603
+ * Returns both `firstLoopPromise` (resolves once the first loop the
604
+ * drain processes completes — used by loop.run to give the caller a
605
+ * fast response containing their loop's result) and `drainPromise`
606
+ * (resolves only when the whole drain finishes, queue+subs settled).
607
+ */
608
+ #startDrain(opts) {
609
+ const { sessionId, runId, provider, systemPrompt, maxTurns } = opts;
610
+ // The drain runs under the run's cancellation scope (shared with the
611
+ // execs its loops spawn), so loop.cancel/shutdown abort it as a unit.
612
+ const controller = this.#runSignal(runId);
613
+ const handle = {
614
+ controller, promise: Promise.resolve(),
615
+ };
616
+ let resolveFirst = () => { };
617
+ let rejectFirst = () => { };
618
+ const firstLoopPromise = new Promise((res, rej) => {
619
+ resolveFirst = res;
620
+ rejectFirst = rej;
621
+ });
622
+ let firstSettled = false;
623
+ const claim = () => this.#db.drain_claim_next_loop.get({ run_id: runId });
624
+ const drainPromise = (async () => {
625
+ let loopsDrained = 0;
626
+ let lastResult = null;
627
+ let currentLoopId = null; // the loop being drained — for the #204 abort→499 resolution below
628
+ try {
629
+ while (true) {
630
+ controller.signal.throwIfAborted();
631
+ let loopRow = await claim();
632
+ if (loopRow === undefined) {
633
+ // Queue empty → teardown UNDER the per-run drain lock (R4 / I1),
634
+ // serialized against #ensureDrain so a concurrent inject can't
635
+ // start a 2nd drain in the gap. Re-claim while holding the lock;
636
+ // relinquish the registry slot only if it's empty too. A loop
637
+ // that raced in is returned and run — we stay registered, so
638
+ // there's no transient delete for #ensureDrain to catch.
639
+ loopRow = await this.#withDrainLock(runId, async () => {
640
+ const claimed = await claim();
641
+ if (claimed === undefined && this.#activeDrains.get(runId) === handle) {
642
+ this.#activeDrains.delete(runId);
643
+ }
644
+ return claimed;
645
+ });
646
+ if (loopRow === undefined)
647
+ break;
648
+ }
649
+ currentLoopId = loopRow.id;
650
+ const onDispatch = (logEntryId) => {
651
+ void (async () => {
652
+ const entry = await LogEntry.fetchLogEntry(this.#db, logEntryId);
653
+ this.#broadcast({ sessionId }, "log/entry", { entry });
654
+ })();
655
+ };
656
+ const result = await this.#engine.runLoop({
657
+ provider, sessionId, runId, loopId: loopRow.id, maxTurns,
658
+ messages: [
659
+ { role: "system", content: systemPrompt },
660
+ { role: "user", content: loopRow.prompt },
661
+ ],
662
+ origin: "model",
663
+ onDispatch,
664
+ signal: controller.signal,
665
+ });
666
+ if (result.finalStatus === 202) {
667
+ // The loop SLEPT (parked via [102]<T>/<-1>) — suspended, not terminated. Leave it at 202
668
+ // (resumable); no loop/terminated, no orphan-reconcile. A stream conclusion
669
+ // (#handleWakeRun) re-queues it; and if it holds a polled stream, a poll timer
670
+ // wakes it every P to inspect (§exec-poll). §run-lifecycle-wake-liveness.
671
+ void this.#schedulePollWake(sessionId, runId, provider, systemPrompt);
672
+ // §send-premature-terminate/[102]<T> — the park DEADLINE (grammar 0.75.0): the
673
+ // dispatcher recorded the marker's seconds; a bounded park is woken at T
674
+ // regardless of arrivals, so a park always has a next turn. -1 (indefinite:
675
+ // the butler, a [300] ask) schedules nothing — irc/inject/conclusions wake it.
676
+ // In-memory: a daemon restart drops pending deadlines.
677
+ if (currentLoopId !== null) {
678
+ const deadline = this.#engine.parkDeadlines.get(currentLoopId);
679
+ this.#engine.parkDeadlines.delete(currentLoopId);
680
+ const prior = this.#parkTimers.get(runId);
681
+ if (prior !== undefined) {
682
+ clearTimeout(prior);
683
+ this.#parkTimers.delete(runId);
684
+ }
685
+ if (deadline !== undefined && deadline > 0) {
686
+ const t = setTimeout(() => {
687
+ this.#parkTimers.delete(runId);
688
+ void this.#wakeParkedRun(sessionId, runId, provider, systemPrompt);
689
+ }, deadline * 1000);
690
+ t.unref();
691
+ this.#parkTimers.set(runId, t);
692
+ }
693
+ }
694
+ // Honor an OWED wake (§run-lifecycle-child-wake): a child/stream concluded while
695
+ // this run was mid-turn, before it slept — resume in place rather than park blind,
696
+ // so a worker-run hibernation always returns. The loop is 202 here; reset to
697
+ // claimable and the drain re-runs it on the next claim below.
698
+ if (this.#owedWakes.delete(runId)) {
699
+ await this.#db.drain_resume_slept_loop.run({ loop_id: loopRow.id });
700
+ continue;
701
+ }
702
+ // The loop is blocked at 202 on a live obligation (§wait-obligation-matrix);
703
+ // that obligation's conclusion is its wake edge (the owed-wake above covers the
704
+ // conclude-before-block race). An idle wait never reaches here — it concluded at dispatch.
705
+ continue;
706
+ }
707
+ this.#owedWakes.delete(runId); // the loop concluded (non-202) — no park to honor a held wake at
708
+ const usage = await this.#engine.loopUsage(loopRow.id);
709
+ this.#broadcast({ sessionId }, "loop/terminated", {
710
+ loopId: loopRow.id,
711
+ finalStatus: result.finalStatus,
712
+ hitMaxTurns: result.hitMaxTurns,
713
+ turnIds: result.turnIds,
714
+ usage,
715
+ });
716
+ loopsDrained++;
717
+ const loopResult = {
718
+ loopId: loopRow.id,
719
+ turnIds: result.turnIds,
720
+ finalStatus: result.finalStatus,
721
+ hitMaxTurns: result.hitMaxTurns,
722
+ usage,
723
+ };
724
+ lastResult = loopResult;
725
+ if (!firstSettled) {
726
+ firstSettled = true;
727
+ resolveFirst(loopResult);
728
+ }
729
+ // A next-turn prompt this loop ended before consuming (a
730
+ // wake conclusion or a loop.run-while-active) is promoted to
731
+ // a fresh queued loop so it's never silently dropped.
732
+ await this.#reconcileOrphanedWake(runId, loopRow.id);
733
+ }
734
+ }
735
+ catch (err) {
736
+ if (controller.signal.aborted) {
737
+ // #204 / Model 3 — loop.cancel / shutdown aborted the live drain. A cancellation
738
+ // is the loop's TERMINAL state (499), delivered via loop/terminated (loop.run no
739
+ // longer blocks to return it). A genuine error rejects firstLoopPromise.
740
+ const usage = currentLoopId === null
741
+ ? { promptTokens: 0, completionTokens: 0, costPico: 0, contextTokens: 0, contextSize: null, meta: {} }
742
+ : await this.#engine.loopUsage(currentLoopId);
743
+ if (currentLoopId !== null) {
744
+ // #380 (owner ruling) — the cancel is allowed but provenanced: the ROW goes
745
+ // terminal 499 (a dead loop must never read as live 102, #311) carrying
746
+ // terminated_by='cancel' + the abort reason as the abandonment message, and
747
+ // the broadcast carries the same message. The abort reason is the client's
748
+ // loop.cancel reason (cancelDrain threads it through scope.abort).
749
+ const message = String(controller.signal.reason ?? "user_cancelled").slice(0, 500);
750
+ await this.#db.engine_loop_cancel_external.run({ loop_id: currentLoopId, message });
751
+ this.#broadcast({ sessionId }, "loop/terminated", {
752
+ loopId: currentLoopId, finalStatus: 499, hitMaxTurns: false, turnIds: [], usage, message,
753
+ });
754
+ }
755
+ if (!firstSettled) {
756
+ firstSettled = true;
757
+ resolveFirst({ loopId: currentLoopId ?? 0, turnIds: [], finalStatus: 499, hitMaxTurns: false, usage });
758
+ }
759
+ }
760
+ else {
761
+ // #265 — a genuine (non-abort) loop error must still reach the client. loop.run only
762
+ // acked finalStatus:100, so loop/terminated is the sole outcome channel; the rejection
763
+ // alone reaches no one (firstLoopPromise/drainPromise are .catch()'d). Broadcast 500
764
+ // (failed) — distinct from an abort's 499 — for every error, not just the pre-first one.
765
+ if (currentLoopId !== null) {
766
+ // #311 — the failure must be first-class on BOTH surfaces: the loop row goes
767
+ // terminal 500 carrying the cause (a dead loop must never read as live 102 —
768
+ // the premature-terminate gate counts live loops), and the broadcast carries
769
+ // the same message so a backend 400 (context overflow, auth, …) reaches the
770
+ // client as text, never a contentless 500.
771
+ const message = (err instanceof Error ? err.message : String(err)).slice(0, 500);
772
+ await this.#db.engine_loop_set_status.run({ loop_id: currentLoopId, status: 500, message });
773
+ const usage = await this.#engine.loopUsage(currentLoopId);
774
+ this.#broadcast({ sessionId }, "loop/terminated", {
775
+ loopId: currentLoopId, finalStatus: 500, hitMaxTurns: false, turnIds: [], usage, message,
776
+ });
777
+ }
778
+ if (!firstSettled) {
779
+ firstSettled = true;
780
+ rejectFirst(err);
781
+ }
782
+ }
783
+ throw err;
784
+ }
785
+ finally {
786
+ if (!firstSettled) {
787
+ firstSettled = true;
788
+ rejectFirst(new Error("drain exited without producing a result"));
789
+ }
790
+ if (this.#activeDrains.get(runId) === handle)
791
+ this.#activeDrains.delete(runId);
792
+ }
793
+ return { loopsDrained, lastResult };
794
+ })();
795
+ handle.promise = drainPromise;
796
+ this.#activeDrains.set(runId, handle);
797
+ // Topology join (§run-lifecycle): when this drain exits having CONCLUDED the run, wake its parent
798
+ // if parked. Runs after the drain fully tears down (settled promise) so the quiescence check sees
799
+ // final state; speculative (#onDrainExit no-ops unless the run concluded AND the parent is parked).
800
+ drainPromise.then(() => this.#onDrainExit(sessionId, runId, provider, systemPrompt)).catch(() => { });
801
+ // Swallow unhandled rejections (drain aborts with no awaiter); the
802
+ // error already surfaced via firstLoopPromise or was logged inside.
803
+ drainPromise.catch(() => { });
804
+ firstLoopPromise.catch(() => { });
805
+ return { firstLoopPromise, drainPromise };
806
+ }
807
+ // Per-run drain-transition lock (R4 / §run-lifecycle-single-drain). #ensureDrain's
808
+ // start and a drain's teardown relinquish both run under it, serialized, so the two
809
+ // can't interleave and register two drains for one run. The critical section is the
810
+ // registry decision only (never a loop's work) — a sub-ms hop at drain boundaries.
811
+ // A promise-chain mutex: each caller awaits the prior holder; the tail self-prunes
812
+ // when idle so the Map stays bounded to runs mid-transition.
813
+ #withDrainLock(runId, fn) {
814
+ const prev = this.#drainLocks.get(runId) ?? Promise.resolve();
815
+ const run = prev.then(fn, fn);
816
+ const tail = run.catch(() => { });
817
+ this.#drainLocks.set(runId, tail);
818
+ void tail.then(() => { if (this.#drainLocks.get(runId) === tail)
819
+ this.#drainLocks.delete(runId); });
820
+ return run;
821
+ }
822
+ // The drain guarantee, serialized per run via #withDrainLock so it can't race a
823
+ // sibling drain's teardown relinquish into a double-drain (R4). A live drain
824
+ // (registered, NOT aborting) will claim the just-enqueued loop in its own iteration
825
+ // or its lock-held exit re-claim → return null. A registered-but-ABORTING drain is
826
+ // in teardown and won't claim, so we don't defer to it — start fresh, or the loop
827
+ // strands on a cancel/resume race (I6 no-lost-loop). Otherwise start one.
828
+ #ensureDrain(opts) {
829
+ return this.#withDrainLock(opts.runId, async () => {
830
+ const existing = this.#activeDrains.get(opts.runId);
831
+ if (existing !== undefined && !existing.controller.signal.aborted)
832
+ return null;
833
+ return this.#startDrain(opts);
834
+ });
835
+ }
836
+ // After a loop terminates, promote any next-turn prompt it never consumed —
837
+ // an injected wake (stream conclusion) or a loop.run-while-active prompt
838
+ // that landed on a turn the loop didn't reach — into a fresh queued loop.
839
+ // The drain claims it on its next iteration, so a conclusion or client
840
+ // prompt is never silently dropped. Inherits the ended loop's flags.
841
+ async #reconcileOrphanedWake(runId, endedLoopId) {
842
+ const endedSeq = (await this.#db.engine_loop_sequence.get({ loop_id: endedLoopId }))?.sequence ?? endedLoopId;
843
+ const prefix = promptLoopPrefix(runId, endedSeq);
844
+ const orphan = await this.#db.drain_orphaned_prompt_for_loop.get({ loop_id: endedLoopId, pattern: `${prefix}%`, prefix_len: prefix.length });
845
+ if (orphan === undefined)
846
+ return;
847
+ const seqRow = await this.#db.loop_run_next_sequence.get({ run_id: runId });
848
+ if (seqRow === undefined)
849
+ throw new Error("reconcileOrphanedWake: next-sequence query returned no row");
850
+ const fresh = await this.#db.drain_enqueue_loop.get({
851
+ run_id: runId, sequence: seqRow.next, prompt: orphan.body,
852
+ });
853
+ if (fresh === undefined)
854
+ throw new Error("reconcileOrphanedWake: enqueue returned no row");
855
+ if (orphan.flags !== null) {
856
+ await this.#db.engine_set_loop_flags.run({ loop_id: fresh.id, flags: orphan.flags });
857
+ }
858
+ }
859
+ // The run's cancellation scope — lazily created, and replaced once aborted
860
+ // so a later loop.run gets a live signal. The drain and the execs its loops
861
+ // spawn all run under it.
862
+ #runSignal(runId) {
863
+ const existing = this.#runAborts.get(runId);
864
+ if (existing !== undefined && !existing.signal.aborted)
865
+ return existing;
866
+ const fresh = new AbortController();
867
+ this.#runAborts.set(runId, fresh);
868
+ return fresh;
869
+ }
870
+ /**
871
+ * Cancel the run's in-flight work (loop.cancel). One abort, one scope: the
872
+ * run signal stops the running loop's turn generation AND tears down every
873
+ * stream linked to it — a background exec that outlived its loop, or even a
874
+ * spawn that registers after this abort (it self-aborts against the aborted
875
+ * signal). Returns cancelled iff there was work. Queued loops stay enqueued.
876
+ */
877
+ cancelDrain(runId, reason = "user_cancelled") {
878
+ const hadDrain = this.#activeDrains.has(runId);
879
+ const hadWork = hadDrain || this.#runHasActiveStreams(runId);
880
+ // A cancel is deliberate — kill any pending hibernation poll-wake so it can't resurrect the run.
881
+ const pollTimer = this.#pollTimers.get(runId);
882
+ if (pollTimer !== undefined) {
883
+ clearTimeout(pollTimer);
884
+ this.#pollTimers.delete(runId);
885
+ }
886
+ // Stop the active drain's turn-generation (its loop closes 499). The run
887
+ // signal is the optimization path — the fast, listener-driven reap.
888
+ const scope = this.#runAborts.get(runId);
889
+ if (scope !== undefined && !scope.signal.aborted)
890
+ scope.abort(reason);
891
+ // #380, the PARKED case — a 202-blocked loop has no drain to observe the abort (the
892
+ // drain tears down on 202), so before this a cancelled park stayed 202 forever with no
893
+ // terminal and no broadcast. Terminalize the run's live loops (102/202; queued 100 stays
894
+ // enqueued) with provenance and broadcast each. With a live drain its abort catch does
895
+ // this instead — skipping here keeps the broadcast single.
896
+ if (!hadDrain) {
897
+ void (async () => {
898
+ const message = reason.slice(0, 500);
899
+ const dead = await this.#db.engine_run_cancel_live_loops.all({ run_id: runId, message });
900
+ if (dead.length === 0)
901
+ return;
902
+ const srow = await this.#db.drain_get_run_session.get({ run_id: runId });
903
+ if (srow === undefined)
904
+ return;
905
+ for (const { id } of dead) {
906
+ const usage = await this.#engine.loopUsage(id);
907
+ this.#broadcast({ sessionId: srow.session_id }, "loop/terminated", {
908
+ loopId: id, finalStatus: 499, hitMaxTurns: false, turnIds: [], usage, message,
909
+ });
910
+ }
911
+ })().catch((err) => {
912
+ console.error(`cancelDrain(${runId}) live-loop terminalize failed:`, err);
913
+ });
914
+ }
915
+ // Total reap by the REGISTRY (§run-lifecycle-total-reap): the durable source
916
+ // of truth. Every open subscription the run holds, aborted via its owning
917
+ // scheme — independent of the signal-listener timing, so an exec mid-spawn
918
+ // (registry row written before it is killable) is reaped too. A late spawn
919
+ // (registering after this) self-aborts against its captured, now-aborted
920
+ // epoch (§exec-timeout). Idempotent; fire-and-forget (the
921
+ // abort is sync, the registry read async; the 499 conclusion surfaces async).
922
+ void this.#reapRunStreams(runId).catch((err) => {
923
+ console.error(`reapRunStreams(${runId}) failed:`, err);
924
+ });
925
+ return hadWork;
926
+ }
927
+ // Does the run have an in-flight stream (a background exec)? Used only for
928
+ // loop.cancel's cancelled=true/false answer; the teardown itself rides the
929
+ // run signal. Duck-typed like #drainStreamingSchemes.
930
+ #runHasActiveStreams(runId) {
931
+ const exec = this.#schemes.get("exec");
932
+ return exec?.hasActiveSpawns?.(runId) ?? false;
933
+ }
934
+ // The registry-routed reap (§run-lifecycle-total-reap): every open subscription
935
+ // the run holds, aborted via its owning scheme. The durable answer to "reap
936
+ // everything" — the in-process AbortSignal listener is the optimization, this is
937
+ // the source of truth: an exec mid-spawn (registry row written before it is
938
+ // killable) or a background exec from any past loop is caught regardless of
939
+ // listener timing. Idempotent — a stream the signal already reaped is a no-op.
940
+ async #reapRunStreams(runId) {
941
+ const open = await ChannelWrite.findOpenSubscriptionsForRun(this.#db, runId);
942
+ for (const { id, scheme } of open) {
943
+ const handler = this.#schemes.get(scheme);
944
+ handler?.abortSubscription?.(id);
945
+ }
946
+ }
947
+ /**
948
+ * Wake-on-completion handler. Streaming schemes call this when a
949
+ * subscription closes. If the run has an active loop, the channel
950
+ * transition will surface at that loop's next turn boundary — no new
951
+ * loop needed. Otherwise we open a fresh loop with the synthetic
952
+ * summary as the user prompt so the model gets a chance to react.
953
+ *
954
+ * Skipped on closeStatus=499 (aborted): the model already knows about
955
+ * its own SEND[499], and a forcefully-cancelled loop's spawn-abort
956
+ * shouldn't resurrect into a wake loop (defeats the cancel).
957
+ *
958
+ * Rummy parallel: plugins/stream/stream.js stream/completed wake:true.
959
+ */
960
+ async #handleWakeRun(payload) {
961
+ // Aborted streams don't wake — the abort was deliberate.
962
+ if (payload.closeStatus === 499) {
963
+ this.#broadcast({ sessionId: payload.sessionId }, "stream/concluded", {
964
+ ...payload, wakeAction: "skipped-aborted",
965
+ });
966
+ return;
967
+ }
968
+ // No resurrection (§run-lifecycle-no-resurrection): a non-499 completion whose
969
+ // run was CANCELLED (idle + its scope aborted) must not start a fresh drain —
970
+ // the cancel was deliberate. The deliverable is already in the channel/log and
971
+ // surfaces as a `collect` environment delta (§env-delta) if the run is read or
972
+ // resumed; we just don't inject a turn. (An active run folds the wake into its
973
+ // next turn via inject below; a resumed run is active, never aborted, so it is
974
+ // unaffected.)
975
+ const scope = this.#runAborts.get(payload.runId);
976
+ if (scope?.signal.aborted === true && !this.#activeDrains.has(payload.runId)) {
977
+ this.#broadcast({ sessionId: payload.sessionId }, "stream/concluded", {
978
+ ...payload, wakeAction: "skipped-cancelled",
979
+ });
980
+ return;
981
+ }
982
+ if (this.#provider === null) {
983
+ this.#broadcast({ sessionId: payload.sessionId }, "stream/concluded", {
984
+ ...payload, wakeAction: "skipped-no-provider",
985
+ });
986
+ return;
987
+ }
988
+ try {
989
+ const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
990
+ // A slept (202) loop means the run PARKED ([102]<T>/<-1>) → RESUME it IN PLACE: re-queue
991
+ // it (202→100) so the drain re-claims and CONTINUES it (seq>1 → no re-foist). Checked
992
+ // FIRST: the slept status is the run's true disposition regardless of a draining
993
+ // sibling mid-teardown (the #ensureDrain lock serializes the re-claim). No fresh loop,
994
+ // no summary-as-prompt — the resumed loop reads the concluded stream's own state from
995
+ // the manifest. §run-lifecycle-wake-liveness.
996
+ const slept = await this.#db.drain_find_slept_loop.get({ run_id: payload.runId });
997
+ if (slept !== undefined) {
998
+ await this.#db.drain_resume_slept_loop.run({ loop_id: slept.id });
999
+ const started = await this.#ensureDrain({
1000
+ sessionId: payload.sessionId, runId: payload.runId, provider: this.#provider,
1001
+ systemPrompt, maxTurns: Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "50"),
1002
+ });
1003
+ this.#broadcast({ sessionId: payload.sessionId }, "stream/concluded", {
1004
+ ...payload, wakeAction: "resumed-loop", wakeLoopId: slept.id,
1005
+ });
1006
+ started?.drainPromise?.catch((err) => {
1007
+ console.error("wake resume drain failed:", err instanceof Error ? err.message : String(err));
1008
+ });
1009
+ return;
1010
+ }
1011
+ // No slept loop. A live loop surfaces the concluded stream ambiently via the
1012
+ // environment-observation injector (§exec-stream) on its next turn — there is no prompt
1013
+ // to inject and NO task to overwrite. The obsolete "automated environment update"
1014
+ // synthesis (which clobbered the model's actual goal) is retired; just tell the client.
1015
+ if (this.#activeDrains.has(payload.runId)) {
1016
+ this.#broadcast({ sessionId: payload.sessionId }, "stream/concluded", {
1017
+ ...payload, wakeAction: "no-op-active-loop",
1018
+ });
1019
+ return;
1020
+ }
1021
+ // No slept loop, no active drain — nothing to resume (e.g. a SEND[200]-done run whose
1022
+ // streams were swept). Surface the conclusion without opening a loop.
1023
+ this.#broadcast({ sessionId: payload.sessionId }, "stream/concluded", {
1024
+ ...payload, wakeAction: "no-loop",
1025
+ });
1026
+ }
1027
+ catch (err) {
1028
+ console.error("wake-on-completion setup failed:", err instanceof Error ? err.message : String(err));
1029
+ }
1030
+ }
1031
+ /**
1032
+ * grammar 0.74.20 EXEC `<T,P>` — schedule a hibernation poll-wake. Called when a loop parks at
1033
+ * a park; if the run holds an open polled stream, arm a timer for its tightest cadence P that
1034
+ * resumes the slept loop so the model inspects progress. While the loop is ACTIVE there is no
1035
+ * poll work — ambient folded stream deltas already surface progress (§exec-stream); the wake
1036
+ * matters only across hibernation. A wake-edge-less 202 (no polled stream) gets no timer. §exec-poll
1037
+ */
1038
+ async #schedulePollWake(sessionId, runId, provider, systemPrompt) {
1039
+ const existing = this.#pollTimers.get(runId);
1040
+ if (existing !== undefined) {
1041
+ clearTimeout(existing);
1042
+ this.#pollTimers.delete(runId);
1043
+ }
1044
+ const row = await this.#db.drain_run_min_poll.get({ run_id: runId });
1045
+ const pollSec = row?.poll_seconds ?? null;
1046
+ if (pollSec === null || pollSec <= 0)
1047
+ return; // no polled stream → the 202 just sleeps (woken only by conclusion)
1048
+ // Floored by the post-EXEC breath (PLURNK_SERVICE_EXEC_WAIT_MS) so a `<…,1>` can't wake the loop
1049
+ // faster than a turn settles — §exec-poll.
1050
+ const execWaitMs = Number(process.env.PLURNK_SERVICE_EXEC_WAIT_MS ?? "0");
1051
+ const timer = setTimeout(() => {
1052
+ this.#pollTimers.delete(runId);
1053
+ void this.#wakeParkedRun(sessionId, runId, provider, systemPrompt);
1054
+ }, Math.max(pollSec * 1000, execWaitMs));
1055
+ timer.unref();
1056
+ this.#pollTimers.set(runId, timer);
1057
+ }
1058
+ /** Resume `runId`'s slept (202) loop in place — the same 202→100 resume #handleWakeRun uses, minus a
1059
+ * wake payload. The shared wake primitive: a poll cadence (§exec-poll), a watched stream concluding,
1060
+ * or a child run finishing (§run-lifecycle topology join) all call this. A no-op if the run was
1061
+ * cancelled or isn't actually parked (no slept loop) — so calling it speculatively is safe. */
1062
+ async #wakeParkedRun(sessionId, runId, provider, systemPrompt) {
1063
+ const scope = this.#runAborts.get(runId);
1064
+ if (scope?.signal.aborted === true && !this.#activeDrains.has(runId))
1065
+ return; // cancelled — no resurrection
1066
+ const slept = await this.#db.drain_find_slept_loop.get({ run_id: runId });
1067
+ if (slept === undefined) {
1068
+ // Not parked. If a drain is still ACTIVE, the run is mid-turn and about to park — the
1069
+ // conclusion that fired this wake arrived before the 202 committed (the conclude-before-park
1070
+ // race). OWE the wake: the drain honors it at park so a worker-run hibernation never deadlocks.
1071
+ // (No active drain → already concluded/running; nothing to wake.)
1072
+ if (this.#activeDrains.has(runId))
1073
+ this.#owedWakes.add(runId);
1074
+ return;
1075
+ }
1076
+ await this.#db.drain_resume_slept_loop.run({ loop_id: slept.id });
1077
+ const started = await this.#ensureDrain({
1078
+ sessionId, runId, provider, systemPrompt,
1079
+ maxTurns: Number(process.env.PLURNK_SERVICE_MAX_TURNS ?? "50"),
1080
+ });
1081
+ started?.drainPromise?.catch((err) => {
1082
+ console.error("wake-parked resume drain failed:", err instanceof Error ? err.message : String(err));
1083
+ });
1084
+ }
1085
+ /** A run's drain exited. If the run truly CONCLUDED — no 202-blocked loop, no open stream — then
1086
+ * wake its PARENT in place if the parent is blocked on the join (the structured-concurrency join — a
1087
+ * child finishing is the wake edge for a parent that waited on it, §run-lifecycle-child-wake). A run
1088
+ * blocked at 202, or still holding a stream, is NOT concluded — its own wake edges drive it, not this.
1089
+ * The parent reads the child's deliverable from its own log (the §run-scheme-collect delta) on
1090
+ * resume — control edge here, never an injected prompt. Recurses up via the parent's own drain-exit. */
1091
+ async #onDrainExit(sessionId, runId, provider, systemPrompt) {
1092
+ const slept = await this.#db.drain_find_slept_loop.get({ run_id: runId });
1093
+ if (slept !== undefined)
1094
+ return; // parked at 202 — not concluded, the run is still alive
1095
+ const openSubs = await this.#db.find_open_subscriptions_for_run.all({ run_id: runId });
1096
+ if (openSubs.length > 0)
1097
+ return; // a stream still runs — its conclusion re-evaluates, not this exit
1098
+ const parent = await this.#db.run_parent_id.get({ run_id: runId });
1099
+ if (parent?.parent_run_id == null)
1100
+ return; // a root run — nobody to wake
1101
+ await this.#wakeParkedRun(sessionId, parent.parent_run_id, provider, systemPrompt);
1102
+ }
1103
+ #broadcast(target, method, params) {
1104
+ if (target === "all") {
1105
+ // A global engine event (e.g. session/created) — emitted to the seam with sessionId null (#355).
1106
+ for (const sub of this.#eventSubscribers)
1107
+ sub(null, method, params);
1108
+ return;
1109
+ }
1110
+ const sessionId = target.sessionId;
1111
+ // Publish the raw event to the in-process source first (#355) — transport modules subscribe
1112
+ // here (plurnk-agui renders to AG-UI+). Each subscriber owns its own fan-out; core just emits.
1113
+ for (const sub of this.#eventSubscribers)
1114
+ sub(sessionId, method, params);
1115
+ // Scope-stamping onto the notification envelope (§notifications-envelope-carries-sessionid)
1116
+ // is each subscriber's edge concern now — the seam hands (sessionId, method, params) raw.
1117
+ }
1118
+ }
1119
+ //# sourceMappingURL=Daemon.js.map