@plurnk/plurnk-service 1.6.1 → 1.8.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 (310) hide show
  1. package/.env.defaults +29 -42
  2. package/INSTALL.md +49 -10
  3. package/README.md +11 -1
  4. package/SPEC.md +702 -287
  5. package/digest-sql/channel-dispositions/dispositions.sql +15 -0
  6. package/digest-sql/channel-state/state.sql +6 -0
  7. package/digest-sql/curation/curation.sql +5 -0
  8. package/digest-sql/derivations/derivations.sql +4 -0
  9. package/digest-sql/embeddings/embeddings.sql +4 -0
  10. package/digest-sql/entry-dispositions/dispositions.sql +15 -0
  11. package/digest-sql/entry-state/state.sql +6 -0
  12. package/digest-sql/token-counts/token-counts.sql +2 -0
  13. package/dist/Paths.d.ts +2 -5
  14. package/dist/Paths.d.ts.map +1 -1
  15. package/dist/Paths.js +9 -21
  16. package/dist/Paths.js.map +1 -1
  17. package/dist/build-info.json +1 -1
  18. package/dist/content/read-projector.d.ts.map +1 -1
  19. package/dist/content/read-projector.js +4 -3
  20. package/dist/content/read-projector.js.map +1 -1
  21. package/dist/core/BudgetReadout.d.ts.map +1 -1
  22. package/dist/core/BudgetReadout.js +12 -22
  23. package/dist/core/BudgetReadout.js.map +1 -1
  24. package/dist/core/ChannelWrite.d.ts.map +1 -1
  25. package/dist/core/ChannelWrite.js.map +1 -1
  26. package/dist/core/ChannelWrite.sql +2 -1
  27. package/dist/core/ClientInteractions.d.ts +19 -0
  28. package/dist/core/ClientInteractions.d.ts.map +1 -0
  29. package/dist/core/ClientInteractions.js +124 -0
  30. package/dist/core/ClientInteractions.js.map +1 -0
  31. package/dist/core/ClientInteractions.sql +34 -0
  32. package/dist/core/CoreSchemeServices.d.ts +8 -2
  33. package/dist/core/CoreSchemeServices.d.ts.map +1 -1
  34. package/dist/core/CoreSchemeServices.js +7 -1
  35. package/dist/core/CoreSchemeServices.js.map +1 -1
  36. package/dist/core/Dispatcher.d.ts +9 -6
  37. package/dist/core/Dispatcher.d.ts.map +1 -1
  38. package/dist/core/Dispatcher.js +74 -125
  39. package/dist/core/Dispatcher.js.map +1 -1
  40. package/dist/core/Engine.d.ts +26 -9
  41. package/dist/core/Engine.d.ts.map +1 -1
  42. package/dist/core/Engine.js +125 -44
  43. package/dist/core/Engine.js.map +1 -1
  44. package/dist/core/Engine.sql +112 -107
  45. package/dist/core/ExecutorRegistry.d.ts +19 -5
  46. package/dist/core/ExecutorRegistry.d.ts.map +1 -1
  47. package/dist/core/ExecutorRegistry.js +144 -18
  48. package/dist/core/ExecutorRegistry.js.map +1 -1
  49. package/dist/core/HostPaths.d.ts +29 -0
  50. package/dist/core/HostPaths.d.ts.map +1 -0
  51. package/dist/core/HostPaths.js +74 -0
  52. package/dist/core/HostPaths.js.map +1 -0
  53. package/dist/core/LegacyHome.d.ts +10 -0
  54. package/dist/core/LegacyHome.d.ts.map +1 -0
  55. package/dist/core/LegacyHome.js +170 -0
  56. package/dist/core/LegacyHome.js.map +1 -0
  57. package/dist/core/LogBody.d.ts +1 -1
  58. package/dist/core/LogBody.d.ts.map +1 -1
  59. package/dist/core/LogBody.js +2 -2
  60. package/dist/core/LogBody.js.map +1 -1
  61. package/dist/core/LogEntryProjection.js +1 -1
  62. package/dist/core/LogEntryProjection.js.map +1 -1
  63. package/dist/core/LogVisibility.d.ts +28 -0
  64. package/dist/core/LogVisibility.d.ts.map +1 -0
  65. package/dist/core/LogVisibility.js +215 -0
  66. package/dist/core/LogVisibility.js.map +1 -0
  67. package/dist/core/LoopLifecycle.d.ts +1 -0
  68. package/dist/core/LoopLifecycle.d.ts.map +1 -1
  69. package/dist/core/LoopLifecycle.js +8 -0
  70. package/dist/core/LoopLifecycle.js.map +1 -1
  71. package/dist/core/LoopLifecycle.sql +10 -0
  72. package/dist/core/OperatorConfig.d.ts +6 -0
  73. package/dist/core/OperatorConfig.d.ts.map +1 -0
  74. package/dist/core/OperatorConfig.js +92 -0
  75. package/dist/core/OperatorConfig.js.map +1 -0
  76. package/dist/core/OverflowTurn.d.ts +11 -0
  77. package/dist/core/OverflowTurn.d.ts.map +1 -0
  78. package/dist/core/OverflowTurn.js +77 -0
  79. package/dist/core/OverflowTurn.js.map +1 -0
  80. package/dist/core/Owner.js +2 -2
  81. package/dist/core/Owner.js.map +1 -1
  82. package/dist/core/PacketBuilder.d.ts +3 -15
  83. package/dist/core/PacketBuilder.d.ts.map +1 -1
  84. package/dist/core/PacketBuilder.js +52 -113
  85. package/dist/core/PacketBuilder.js.map +1 -1
  86. package/dist/core/ProblemLog.js +1 -1
  87. package/dist/core/ProblemLog.js.map +1 -1
  88. package/dist/core/ProposalLifecycle.d.ts +3 -1
  89. package/dist/core/ProposalLifecycle.d.ts.map +1 -1
  90. package/dist/core/ProposalLifecycle.js +9 -26
  91. package/dist/core/ProposalLifecycle.js.map +1 -1
  92. package/dist/core/ProviderInstantiate.d.ts +9 -5
  93. package/dist/core/ProviderInstantiate.d.ts.map +1 -1
  94. package/dist/core/ProviderInstantiate.js +99 -104
  95. package/dist/core/ProviderInstantiate.js.map +1 -1
  96. package/dist/core/ResourceMutations.d.ts +2 -2
  97. package/dist/core/ResourceMutations.d.ts.map +1 -1
  98. package/dist/core/ResourceMutations.js +13 -11
  99. package/dist/core/ResourceMutations.js.map +1 -1
  100. package/dist/core/SchemeRegistry.d.ts +18 -11
  101. package/dist/core/SchemeRegistry.d.ts.map +1 -1
  102. package/dist/core/SchemeRegistry.js +201 -53
  103. package/dist/core/SchemeRegistry.js.map +1 -1
  104. package/dist/core/ToolResources.d.ts +19 -0
  105. package/dist/core/ToolResources.d.ts.map +1 -0
  106. package/dist/core/ToolResources.js +122 -0
  107. package/dist/core/ToolResources.js.map +1 -0
  108. package/dist/core/Turn.d.ts +27 -0
  109. package/dist/core/Turn.d.ts.map +1 -0
  110. package/dist/core/Turn.js +43 -0
  111. package/dist/core/Turn.js.map +1 -0
  112. package/dist/core/Turn.sql +72 -0
  113. package/dist/core/TurnOps.d.ts +7 -0
  114. package/dist/core/TurnOps.d.ts.map +1 -0
  115. package/dist/core/TurnOps.js +63 -0
  116. package/dist/core/TurnOps.js.map +1 -0
  117. package/dist/core/TurnRunner.d.ts +15 -5
  118. package/dist/core/TurnRunner.d.ts.map +1 -1
  119. package/dist/core/TurnRunner.js +1050 -900
  120. package/dist/core/TurnRunner.js.map +1 -1
  121. package/dist/core/WorkerName.d.ts +1 -1
  122. package/dist/core/WorkerName.d.ts.map +1 -1
  123. package/dist/core/WorkspaceGate.d.ts +1 -0
  124. package/dist/core/WorkspaceGate.d.ts.map +1 -1
  125. package/dist/core/WorkspaceGate.js +39 -0
  126. package/dist/core/WorkspaceGate.js.map +1 -1
  127. package/dist/core/caps/CoreInteractionCaps.d.ts +9 -0
  128. package/dist/core/caps/CoreInteractionCaps.d.ts.map +1 -0
  129. package/dist/core/caps/CoreInteractionCaps.js +14 -0
  130. package/dist/core/caps/CoreInteractionCaps.js.map +1 -0
  131. package/dist/core/caps/SchemeCtxImpl.d.ts +2 -1
  132. package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -1
  133. package/dist/core/caps/SchemeCtxImpl.js +3 -0
  134. package/dist/core/caps/SchemeCtxImpl.js.map +1 -1
  135. package/dist/core/env-defaults.d.ts.map +1 -1
  136. package/dist/core/env-defaults.js +7 -5
  137. package/dist/core/env-defaults.js.map +1 -1
  138. package/dist/core/fork.d.ts.map +1 -1
  139. package/dist/core/fork.js +15 -4
  140. package/dist/core/fork.js.map +1 -1
  141. package/dist/core/fork.sql +57 -27
  142. package/dist/core/git-env.d.ts +0 -1
  143. package/dist/core/git-env.d.ts.map +1 -1
  144. package/dist/core/git-env.js +0 -3
  145. package/dist/core/git-env.js.map +1 -1
  146. package/dist/core/git-membership.d.ts.map +1 -1
  147. package/dist/core/git-membership.js +10 -26
  148. package/dist/core/git-membership.js.map +1 -1
  149. package/dist/core/git-state.d.ts.map +1 -1
  150. package/dist/core/git-state.js +13 -14
  151. package/dist/core/git-state.js.map +1 -1
  152. package/dist/core/operation-target-groups.d.ts +3 -0
  153. package/dist/core/operation-target-groups.d.ts.map +1 -0
  154. package/dist/core/operation-target-groups.js +53 -0
  155. package/dist/core/operation-target-groups.js.map +1 -0
  156. package/dist/core/packet-inject.d.ts +0 -1
  157. package/dist/core/packet-inject.d.ts.map +1 -1
  158. package/dist/core/packet-inject.js +6 -13
  159. package/dist/core/packet-inject.js.map +1 -1
  160. package/dist/core/packet-wire.d.ts +4 -0
  161. package/dist/core/packet-wire.d.ts.map +1 -1
  162. package/dist/core/packet-wire.js +208 -44
  163. package/dist/core/packet-wire.js.map +1 -1
  164. package/dist/core/plurnk-uri.d.ts +1 -1
  165. package/dist/core/plurnk-uri.d.ts.map +1 -1
  166. package/dist/core/plurnk-uri.js +3 -3
  167. package/dist/core/plurnk-uri.js.map +1 -1
  168. package/dist/core/scheme-types.d.ts +2 -1
  169. package/dist/core/scheme-types.d.ts.map +1 -1
  170. package/dist/core/scheme-types.js.map +1 -1
  171. package/dist/core/teaching.d.ts.map +1 -1
  172. package/dist/core/teaching.js +4 -5
  173. package/dist/core/teaching.js.map +1 -1
  174. package/dist/core/worker-settings.d.ts +9 -0
  175. package/dist/core/worker-settings.d.ts.map +1 -0
  176. package/dist/core/worker-settings.js +25 -0
  177. package/dist/core/worker-settings.js.map +1 -0
  178. package/dist/core/workspace-settings.d.ts +0 -11
  179. package/dist/core/workspace-settings.d.ts.map +1 -1
  180. package/dist/core/workspace-settings.js +3 -47
  181. package/dist/core/workspace-settings.js.map +1 -1
  182. package/dist/digest/Digest.d.ts.map +1 -1
  183. package/dist/digest/Digest.js +164 -92
  184. package/dist/digest/Digest.js.map +1 -1
  185. package/dist/digest/digest.sql +8 -2
  186. package/dist/observe/genai.d.ts +9 -0
  187. package/dist/observe/genai.d.ts.map +1 -0
  188. package/dist/observe/genai.js +31 -0
  189. package/dist/observe/genai.js.map +1 -0
  190. package/dist/observe/spans.d.ts +3 -3
  191. package/dist/observe/spans.d.ts.map +1 -1
  192. package/dist/observe/spans.js +20 -5
  193. package/dist/observe/spans.js.map +1 -1
  194. package/dist/schemes/EffectPolicy.d.ts +3 -0
  195. package/dist/schemes/EffectPolicy.d.ts.map +1 -1
  196. package/dist/schemes/EffectPolicy.js +38 -6
  197. package/dist/schemes/EffectPolicy.js.map +1 -1
  198. package/dist/schemes/Exec.d.ts.map +1 -1
  199. package/dist/schemes/Exec.js +82 -18
  200. package/dist/schemes/Exec.js.map +1 -1
  201. package/dist/schemes/Log.d.ts +6 -2
  202. package/dist/schemes/Log.d.ts.map +1 -1
  203. package/dist/schemes/Log.js +73 -22
  204. package/dist/schemes/Log.js.map +1 -1
  205. package/dist/schemes/Log.sql +25 -7
  206. package/dist/schemes/Prompt.d.ts.map +1 -1
  207. package/dist/schemes/Prompt.js +1 -2
  208. package/dist/schemes/Prompt.js.map +1 -1
  209. package/dist/schemes/QuestionTool.d.ts +10 -0
  210. package/dist/schemes/QuestionTool.d.ts.map +1 -0
  211. package/dist/schemes/QuestionTool.js +81 -0
  212. package/dist/schemes/QuestionTool.js.map +1 -0
  213. package/dist/schemes/Skill.d.ts.map +1 -1
  214. package/dist/schemes/Skill.js +4 -1
  215. package/dist/schemes/Skill.js.map +1 -1
  216. package/dist/schemes/Worker.d.ts.map +1 -1
  217. package/dist/schemes/Worker.js +20 -3
  218. package/dist/schemes/Worker.js.map +1 -1
  219. package/dist/schemes/_entry-crud.sql +19 -1
  220. package/dist/schemes/_entry-find.d.ts +5 -3
  221. package/dist/schemes/_entry-find.d.ts.map +1 -1
  222. package/dist/schemes/_entry-find.js +102 -22
  223. package/dist/schemes/_entry-find.js.map +1 -1
  224. package/dist/schemes/_entry-find.sql +8 -6
  225. package/dist/schemes/_entry-graph.sql +2 -6
  226. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  227. package/dist/schemes/_entry-manifest.js +12 -1
  228. package/dist/schemes/_entry-manifest.js.map +1 -1
  229. package/dist/schemes/_search-index.d.ts.map +1 -1
  230. package/dist/schemes/_search-index.js +37 -25
  231. package/dist/schemes/_search-index.js.map +1 -1
  232. package/dist/server/Daemon.d.ts +69 -12
  233. package/dist/server/Daemon.d.ts.map +1 -1
  234. package/dist/server/Daemon.js +737 -133
  235. package/dist/server/Daemon.js.map +1 -1
  236. package/dist/server/DaemonModule.d.ts +31 -3
  237. package/dist/server/DaemonModule.d.ts.map +1 -1
  238. package/dist/server/DrainSupervisor.d.ts +12 -9
  239. package/dist/server/DrainSupervisor.d.ts.map +1 -1
  240. package/dist/server/DrainSupervisor.js +59 -10
  241. package/dist/server/DrainSupervisor.js.map +1 -1
  242. package/dist/server/client-input.d.ts +7 -2
  243. package/dist/server/client-input.d.ts.map +1 -1
  244. package/dist/server/client-input.js +65 -34
  245. package/dist/server/client-input.js.map +1 -1
  246. package/dist/server/dispatch-as-plurnk.d.ts.map +1 -1
  247. package/dist/server/dispatch-as-plurnk.js +28 -6
  248. package/dist/server/dispatch-as-plurnk.js.map +1 -1
  249. package/dist/server/drain.sql +26 -11
  250. package/dist/server/envelope.d.ts +1 -1
  251. package/dist/server/envelope.d.ts.map +1 -1
  252. package/dist/server/envelope.js +4 -4
  253. package/dist/server/envelope.js.map +1 -1
  254. package/dist/server/envelope.sql +25 -1
  255. package/dist/server/lifecycle-recovery.sql +17 -1
  256. package/dist/server/logEntry.d.ts +2 -1
  257. package/dist/server/logEntry.d.ts.map +1 -1
  258. package/dist/server/logEntry.js +3 -2
  259. package/dist/server/logEntry.js.map +1 -1
  260. package/dist/server/logEntry.sql +7 -0
  261. package/dist/server/loop-model.d.ts +2 -2
  262. package/dist/server/loop-model.d.ts.map +1 -1
  263. package/dist/server/loop-model.js +23 -33
  264. package/dist/server/loop-model.js.map +1 -1
  265. package/dist/server/loopDocs.d.ts +1 -0
  266. package/dist/server/loopDocs.d.ts.map +1 -1
  267. package/dist/server/loopDocs.js +94 -14
  268. package/dist/server/loopDocs.js.map +1 -1
  269. package/dist/server/loopDocs.sql +12 -0
  270. package/dist/server/model-catalog.d.ts +3 -0
  271. package/dist/server/model-catalog.d.ts.map +1 -0
  272. package/dist/server/model-catalog.js +83 -0
  273. package/dist/server/model-catalog.js.map +1 -0
  274. package/dist/server/model-route.d.ts +7 -0
  275. package/dist/server/model-route.d.ts.map +1 -0
  276. package/dist/server/model-route.js +47 -0
  277. package/dist/server/model-route.js.map +1 -0
  278. package/dist/server/module-discovery.d.ts +13 -0
  279. package/dist/server/module-discovery.d.ts.map +1 -0
  280. package/dist/server/module-discovery.js +78 -0
  281. package/dist/server/module-discovery.js.map +1 -0
  282. package/dist/server/skillDocs.d.ts +9 -0
  283. package/dist/server/skillDocs.d.ts.map +1 -0
  284. package/dist/server/skillDocs.js +212 -0
  285. package/dist/server/skillDocs.js.map +1 -0
  286. package/dist/server/skillDocs.sql +14 -0
  287. package/dist/server/workspace-capabilities.sql +19 -0
  288. package/dist/service.d.ts.map +1 -1
  289. package/dist/service.js +182 -123
  290. package/dist/service.js.map +1 -1
  291. package/migrations/001_schema.sql +455 -65
  292. package/package.json +38 -37
  293. package/dist/core/ExecutableTools.d.ts +0 -10
  294. package/dist/core/ExecutableTools.d.ts.map +0 -1
  295. package/dist/core/ExecutableTools.js +0 -41
  296. package/dist/core/ExecutableTools.js.map +0 -1
  297. package/dist/core/JournalTurn.d.ts +0 -9
  298. package/dist/core/JournalTurn.d.ts.map +0 -1
  299. package/dist/core/JournalTurn.js +0 -14
  300. package/dist/core/JournalTurn.js.map +0 -1
  301. package/dist/core/JournalTurn.sql +0 -10
  302. package/dist/core/git-iso.d.ts +0 -13
  303. package/dist/core/git-iso.d.ts.map +0 -1
  304. package/dist/core/git-iso.js +0 -267
  305. package/dist/core/git-iso.js.map +0 -1
  306. package/dist/core/search-gate.d.ts +0 -17
  307. package/dist/core/search-gate.d.ts.map +0 -1
  308. package/dist/core/search-gate.js +0 -77
  309. package/dist/core/search-gate.js.map +0 -1
  310. package/dist/digest/Digest.sql +0 -1
@@ -8,27 +8,34 @@ import ExecutorRegistry from "../core/ExecutorRegistry.js";
8
8
  import SchemeRegistry from "../core/SchemeRegistry.js";
9
9
  import { Mimetypes } from "@plurnk/plurnk-mimetypes";
10
10
  import { RuntimeDeclaration } from "@plurnk/plurnk-execs";
11
+ import { projectModelRoute, routeForSpec, specForRoute } from "./model-route.js";
12
+ import { discoverDaemonModules } from "./module-discovery.js";
13
+ import WorkerSettingsReader from "../core/worker-settings.js";
14
+ import EffectPolicy from "../schemes/EffectPolicy.js";
15
+ import QuestionTool, { questionRuntimeDecl } from "../schemes/QuestionTool.js";
11
16
  import DrainSupervisor from "./DrainSupervisor.js";
12
17
  import { parsePath, Validator, } from "@plurnk/plurnk-contracts";
13
18
  import LogEntry from "./logEntry.js";
14
19
  import Envelope from "./envelope.js";
15
20
  import ClientInput from "./client-input.js";
16
- import JournalTurn from "../core/JournalTurn.js";
21
+ import Turn from "../core/Turn.js";
17
22
  import LoopDocs from "./loopDocs.js";
23
+ import SkillDocs from "./skillDocs.js";
18
24
  import GitMembership from "../core/git-membership.js";
19
25
  import Fork from "../core/fork.js";
20
26
  import WorkerName from "../core/WorkerName.js";
21
27
  import LoopLifecycle from "../core/LoopLifecycle.js";
22
28
  import { promptLoopPrefix } from "../core/plurnk-uri.js";
23
29
  import { contentWeight } from "../core/content-weight.js";
24
- import { parseAliasesFromEnv, resolveActiveAlias } from "@plurnk/plurnk-providers";
30
+ import { parseAliasesFromEnv, resolveActiveRoute, UnsupportedReasoningPolicyError, } from "@plurnk/plurnk-providers";
25
31
  import ProviderInstantiate from "../core/ProviderInstantiate.js";
26
- import { resolveLoopAlias } from "./loop-model.js";
32
+ import { resolveLoopRoute } from "./loop-model.js";
27
33
  import LoopFlagsReader from "../core/LoopFlagsReader.js";
28
34
  import Results, { OperationFailureError } from "../core/results.js";
29
35
  import WorkspaceGate from "../core/WorkspaceGate.js";
30
36
  import BranchBatches from "./BranchBatches.js";
31
37
  import { observed, observedSync } from "../observe/spans.js";
38
+ import { listModelCatalog } from "./model-catalog.js";
32
39
  const clientActionFailure = (error) => {
33
40
  if (error instanceof OperationFailureError)
34
41
  return error.result;
@@ -40,6 +47,9 @@ const clientActionFailure = (error) => {
40
47
  };
41
48
  const daemonFailure = (owner, code, status, detail, extensions = {}) => new OperationFailureError(Results.failure(owner, code, status, detail, {}, extensions));
42
49
  const entryReadResult = (result) => Validator.assertEntryReadResult(result);
50
+ const modelRouteLabel = (route) => route.alias === undefined
51
+ ? `model route '${route.provider}/${route.model}'`
52
+ : `provider alias '${route.alias}' (${route.provider}/${route.model})`;
43
53
  export default class Daemon {
44
54
  #db;
45
55
  #engine;
@@ -58,6 +68,7 @@ export default class Daemon {
58
68
  #modules = [];
59
69
  #moduleClosers = [];
60
70
  #moduleActions = new Map();
71
+ #workspaceCapabilityProviders = new Map();
61
72
  // {§methods-event-subscribe} — the broadcast's in-process event source. A transport
62
73
  // module (plurnk-agui) subscribes and fans out to its OWN clients; core emits, never owns
63
74
  // client transport or connection state.
@@ -82,7 +93,7 @@ export default class Daemon {
82
93
  defaultMimetype: "text/markdown",
83
94
  discoverOptions: { cwd: this.#discoveryCwd },
84
95
  });
85
- const bootSpec = resolveActiveAlias();
96
+ const bootSpec = resolveActiveRoute();
86
97
  if (this.#provider !== null && bootSpec !== null) {
87
98
  ProviderInstantiate.registerInstance(this.#provider, bootSpec);
88
99
  }
@@ -109,10 +120,19 @@ export default class Daemon {
109
120
  }))?.id;
110
121
  if (workerId === undefined)
111
122
  throw new Error("Branch worker insert returned no row");
123
+ // {§worker-model-selection} — lineage inheritance by value: the branch
124
+ // child begins with the spawning loop's effective spawn model.
125
+ await this.#db.worker_generation_policy_update.run({
126
+ id: workerId,
127
+ model_route_id: await routeForSpec(this.#db, providerSpec),
128
+ spawn_model_route_id: null,
129
+ reasoning_policy: parentPolicy.reasoningPolicy,
130
+ });
112
131
  const loopId = await this.#drains.enqueueFreshLoop({
113
132
  workerId,
114
133
  prompt,
115
134
  providerSpec,
135
+ reasoningPolicy: parentPolicy.reasoningPolicy,
116
136
  childProviderSpec: parentPolicy.childProviderSpec,
117
137
  flags,
118
138
  });
@@ -152,20 +172,44 @@ export default class Daemon {
152
172
  // independently (the sister is its own worker). {§machine-processes}
153
173
  injectWorker: async ({ workspaceId, workerId, prompt, flags, parentLoopId }) => {
154
174
  const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
155
- const parentPolicy = parentLoopId === undefined
156
- ? null
157
- : await this.#providerPolicyForLoop(parentLoopId);
158
- const providerSpec = parentPolicy === null
159
- ? resolveActiveAlias()
160
- : parentPolicy.childProviderSpec ?? parentPolicy.providerSpec;
161
- if (providerSpec === null)
162
- throw new Error("injectWorker: active provider has no resolvable alias");
175
+ let providerSpec;
176
+ let childProviderSpec;
177
+ let reasoningPolicy;
178
+ if (parentLoopId === undefined) {
179
+ // {§worker-model-selection} — the voice door addresses an
180
+ // existing worker. Its own durable generation policy is the
181
+ // receiver's identity; the daemon default and sender do not
182
+ // overwrite it merely because a new loop must be minted.
183
+ const targetPolicy = await this.#resolveWorkerModel(workerId, undefined);
184
+ if (targetPolicy === null) {
185
+ throw new Error(`injectWorker: worker ${workerId} has no resolvable model route`);
186
+ }
187
+ providerSpec = targetPolicy.providerSpec;
188
+ reasoningPolicy = targetPolicy.reasoningPolicy;
189
+ childProviderSpec = await this.#resolveWorkerSpawnModel(workerId, undefined);
190
+ }
191
+ else {
192
+ const parentPolicy = await this.#providerPolicyForLoop(parentLoopId);
193
+ providerSpec = parentPolicy.childProviderSpec ?? parentPolicy.providerSpec;
194
+ reasoningPolicy = parentPolicy.reasoningPolicy;
195
+ // {§worker-model-selection} — lineage inheritance by value:
196
+ // the child begins with the spawning loop's effective spawn
197
+ // model and no separate override of its own.
198
+ childProviderSpec = null;
199
+ await this.#db.worker_generation_policy_update.run({
200
+ id: workerId,
201
+ model_route_id: await routeForSpec(this.#db, providerSpec),
202
+ spawn_model_route_id: null,
203
+ reasoning_policy: reasoningPolicy,
204
+ });
205
+ }
163
206
  const { action, loopId } = await this.inject({
164
207
  workspaceId,
165
208
  workerId,
166
209
  prompt,
167
210
  providerSpec,
168
- ...(parentPolicy === null ? {} : { childProviderSpec: parentPolicy.childProviderSpec }),
211
+ reasoningPolicy,
212
+ childProviderSpec,
169
213
  systemPrompt,
170
214
  ...(flags === undefined ? {} : { flags }),
171
215
  });
@@ -174,7 +218,15 @@ export default class Daemon {
174
218
  branchWorker: async (args) => this.#branchBatches.enqueue(args),
175
219
  branchCompletionGate: async (workerId) => this.#branchBatches.completionGate(workerId),
176
220
  acquireWorkspaceTurn: async (workspaceId, workerId) => this.#workspaceGate.acquireTurn(workspaceId, workerId),
177
- workspaceTurnCompleted: async ({ turnId }) => this.#branchBatches.sealTurn(turnId),
221
+ // {§skills-materialization} — filesystem installers operate out of
222
+ // band. Refresh under the workspace turn gate before packet assembly
223
+ // so the first subsequent model turn sees their exact result.
224
+ workspaceTurnStarting: async ({ workspaceId }) => {
225
+ await SkillDocs.refreshIfChanged(this.#engine, this.#db, workspaceId);
226
+ },
227
+ workspaceTurnCompleted: async ({ turnId }) => {
228
+ await this.#branchBatches.sealTurn(turnId);
229
+ },
178
230
  // worker:// KILL (terminate) — cancel the addressed worker subtree and
179
231
  // tear down its held streams before the operation completes.
180
232
  cancelWorker: async (workerId, reason) => this.#drains.cancelWorkerTree(workerId, reason),
@@ -185,9 +237,14 @@ export default class Daemon {
185
237
  db,
186
238
  lifecycle: this.#lifecycle,
187
239
  injectPrompt: (workerId, prompt, openPaths) => this.#engine.inject(workerId, prompt, openPaths),
188
- assertInjectionCompatibility: async ({ workerId, loopId, providerSpec, childProviderSpec, turnCeiling, flags, }) => {
240
+ assertInjectionCompatibility: async ({ workerId, loopId, providerSpec, providerSpecExplicit, reasoningPolicy, childProviderSpec, turnCeiling, flags, }) => {
189
241
  await this.#assertFoldPosture(workerId, flags, loopId);
190
- await this.#assertLoopProvider(loopId, providerSpec);
242
+ // An omitted selector keeps the loop's durable provider; only an
243
+ // explicit selection is checked against it (a deliberate switch).
244
+ if (providerSpecExplicit !== false) {
245
+ await this.#assertLoopProvider(loopId, providerSpec);
246
+ }
247
+ await this.#assertLoopReasoningPolicy(loopId, reasoningPolicy);
191
248
  if (childProviderSpec !== undefined) {
192
249
  await this.#assertLoopChildProvider(loopId, childProviderSpec);
193
250
  }
@@ -207,7 +264,6 @@ export default class Daemon {
207
264
  { role: "system", content: systemPrompt },
208
265
  { role: "user", content: prompt },
209
266
  ],
210
- origin: "model",
211
267
  signal,
212
268
  onDispatch,
213
269
  });
@@ -219,7 +275,6 @@ export default class Daemon {
219
275
  this.#engine.parkDeadlines.delete(loopId);
220
276
  return deadline;
221
277
  },
222
- settleSearch: (target, status) => this.#engine.searchGate.settle(target, status),
223
278
  cancelSubscription: (subscriptionId) => this.#engine.cancelSubscription(subscriptionId),
224
279
  hasActiveStreams: (workerId) => this.#workerHasActiveStreams(workerId),
225
280
  readSystemPrompt: () => readFile(Paths.instructionsSystem, "utf8"),
@@ -235,6 +290,10 @@ export default class Daemon {
235
290
  const { workspaceId, ...proposal } = event;
236
291
  this.#broadcast({ workspaceId }, "loop/proposal", proposal);
237
292
  });
293
+ this.#engine.onClientInteractionPending((event) => {
294
+ const { workspaceId, ...interaction } = event;
295
+ this.#broadcast({ workspaceId }, "loop/interaction", interaction);
296
+ });
238
297
  }
239
298
  // {§methods-event-subscribe}. A transport module subscribes to the daemon's in-process
240
299
  // event source: it receives every workspace-scoped engine event as `(workspaceId, method, params)`
@@ -258,6 +317,15 @@ export default class Daemon {
258
317
  const checkedResolution = ClientInput.assertProposalResolution("resolveProposal", resolution);
259
318
  this.#engine.resolveProposal(checkedLogEntryId, checkedResolution);
260
319
  }
320
+ async pendingClientInteractions(workspaceId) {
321
+ const checkedWorkspaceId = ClientInput.assertId("pendingClientInteractions", "workspaceId", workspaceId);
322
+ return this.#engine.pendingClientInteractions(checkedWorkspaceId);
323
+ }
324
+ async resolveClientInteraction(interactionId, resolution) {
325
+ const checkedInteractionId = ClientInput.assertId("resolveClientInteraction", "interactionId", interactionId);
326
+ const checkedResolution = ClientInput.assertClientInteractionResolution("resolveClientInteraction", resolution);
327
+ await this.#engine.resolveClientInteraction(checkedInteractionId, checkedResolution);
328
+ }
261
329
  // {§methods-loop-run} — drive/steer a loop. The module supplies only workspace/worker/prompt;
262
330
  // the provider and the law-file system prompt are core's and stay inside. Returns immediately — the
263
331
  // loop runs async and its outcome arrives on the event source (loop/terminated). `cancelDrain` (public)
@@ -268,34 +336,25 @@ export default class Daemon {
268
336
  const prompt = ClientInput.assertPrompt("runLoop", args.prompt);
269
337
  const requestedMaxTurns = ClientInput.assertMaxTurns("runLoop", args.maxTurns);
270
338
  const openPaths = ClientInput.assertOpenPaths("runLoop", args.openPaths);
271
- const alias = ClientInput.assertOptionalSelector("runLoop", "alias", args.alias);
272
- const model = ClientInput.assertOptionalSelector("runLoop", "model", args.model);
273
- const childAlias = ClientInput.assertOptionalChildAlias("runLoop", args.childAlias);
274
- const childModel = ClientInput.assertOptionalSelector("runLoop", "childModel", args.childModel);
275
- if (childAlias === null && childModel !== undefined) {
276
- throw daemonFailure("daemon:input", "child-provider-conflict", 400, "childModel cannot accompany an inherited child provider policy.", { field: "childModel", recovery: "Omit childModel or select a child alias.", retryable: false });
277
- }
339
+ const selector = ClientInput.assertOptionalSelector("runLoop", "selector", args.selector);
340
+ const childSelector = ClientInput.assertOptionalChildSelector("runLoop", args.childSelector);
278
341
  const flags = ClientInput.normalizeLoopFlags("runLoop", args.flags);
279
- // {§methods-loop-run-model} — a client sends alias/model on every loop, so a
280
- // switch takes effect turn-to-turn. `model` (client-resolved <provider>/<model>) wins
281
- // over `alias`; neither → the boot default. Instantiation is cached, so ping-ponging
282
- // between two models is cheap, and an unresolvable alias/model fails loud here.
283
- const selection = await this.#resolveLoopProvider(alias, model);
284
- if (selection === null) {
285
- throw new OperationFailureError(Results.failure("daemon:provider", "not-configured", 501, "No provider is configured for this loop.", {}, {
342
+ // {§worker-model-selection} — the worker owns the model. An explicit selector
343
+ // persists onto the worker; an omitted selector resolves the worker's durable model
344
+ // (seeded once from the daemon default). The loop then snapshots the resolved route.
345
+ // A continuation's omitted selector must still keep the loop's durable provider.
346
+ const selectorExplicit = selector !== undefined;
347
+ const generationPolicy = await this.#resolveWorkerModel(workerId, selector);
348
+ if (generationPolicy === null) {
349
+ throw new OperationFailureError(Results.failure("daemon:provider", "not-configured", 501, "No provider is configured for this worker.", {}, {
286
350
  stage: "provider-selection",
287
351
  recovery: "Select a configured model provider.",
288
352
  retryable: false,
289
353
  }));
290
354
  }
291
- // {§methods-loop-run-child-provider}
292
- const configuredChildAlias = childAlias === undefined && childModel === undefined
293
- ? process.env.PLURNK_MODEL_CHILD
294
- : childAlias;
295
- const childSelection = configuredChildAlias === null
296
- || (configuredChildAlias === undefined && childModel === undefined)
297
- ? null
298
- : await this.#resolveLoopProvider(configuredChildAlias, childModel);
355
+ const { providerSpec: selection, reasoningPolicy } = generationPolicy;
356
+ // {§methods-loop-run-child-provider} — the worker's persistent spawn override.
357
+ const childSelection = await this.#resolveWorkerSpawnModel(workerId, childSelector);
299
358
  const systemPrompt = await readFile(Paths.instructionsSystem, "utf8");
300
359
  // {§machine-processes} — the model NEVER runs in a client-origin worker (its packets would carry
301
360
  // client-action rows). The module resolves the model worker via ensureModelWorker and passes it (or a
@@ -336,72 +395,182 @@ export default class Daemon {
336
395
  ...(openPaths !== undefined ? { openPaths } : {}),
337
396
  turnCeiling,
338
397
  providerSpec: selection,
398
+ providerSpecExplicit: selectorExplicit,
399
+ reasoningPolicy,
339
400
  childProviderSpec: childSelection,
340
401
  systemPrompt,
341
402
  });
342
403
  return { status: 100, action, loopId, ...(turnSeq !== undefined ? { turnSeq } : {}) };
343
404
  }
344
- // {§methods-loop-run-model} — resolve a per-loop model override to a cached Provider. `model`
345
- // (<provider>/<model>, client-resolved) wins over a named `alias`; absent both, the
346
- // boot default. A named alias missing from the env cascade, or a malformed model spec, throws
347
- // legibly rather than silently running the wrong model.
348
- async #resolveLoopProvider(alias, model) {
349
- const requested = resolveLoopAlias(alias, model, parseAliasesFromEnv());
405
+ // {§methods-loop-run-model} — resolve one alias-or-route selector to a cached
406
+ // Provider; absent uses the boot default. An unknown alias or malformed exact
407
+ // route throws legibly rather than silently running the wrong model.
408
+ async #resolveLoopProvider(selector, reasoningPolicy) {
409
+ const requested = resolveLoopRoute(selector, parseAliasesFromEnv());
350
410
  if (requested === null && this.#provider === null)
351
411
  return null;
352
- const spec = requested ?? resolveActiveAlias();
412
+ const spec = requested ?? resolveActiveRoute();
353
413
  if (spec === null) {
354
- throw daemonFailure("daemon:provider", "active-alias-unresolved", 500, "The active provider has no resolvable alias.", { stage: "provider-selection", retryable: false });
414
+ throw daemonFailure("daemon:provider", "active-model-unresolved", 500, "The active provider has no resolvable model route.", { stage: "provider-selection", retryable: false });
355
415
  }
356
- // Resolve eagerly so runLoop fails before enqueue when the provider
357
- // cannot be constructed. The drain later retrieves this cached handle
358
- // from the loop's durable spec at the claim boundary.
416
+ await this.#providerForPolicy(spec, reasoningPolicy);
417
+ return spec;
418
+ }
419
+ // Resolve eagerly so runLoop fails before enqueue when the selected route
420
+ // and durable reasoning policy cannot compose. The drain later retrieves
421
+ // this cached handle from the loop's immutable snapshot.
422
+ async #providerForPolicy(spec, reasoningPolicy) {
359
423
  try {
360
- await ProviderInstantiate.instantiateProvider(spec);
424
+ const provider = await ProviderInstantiate.instantiateProvider(spec, process.env, reasoningPolicy);
425
+ ProviderInstantiate.validateGrammarConfiguration(provider, process.env, reasoningPolicy);
426
+ return provider;
361
427
  }
362
428
  catch (cause) {
363
429
  if (cause instanceof OperationFailureError)
364
430
  throw cause;
365
- console.error(`Provider alias '${spec.alias}' could not be instantiated:`, cause);
366
- throw daemonFailure("daemon:provider", "provider-unavailable", 503, `Provider alias '${spec.alias}' is unavailable.`, {
367
- alias: spec.alias,
431
+ if (cause instanceof UnsupportedReasoningPolicyError) {
432
+ throw daemonFailure("daemon:provider", "reasoning-policy-unsupported", 409, `${modelRouteLabel(spec)} does not support reasoning policy '${cause.policy}'.`, {
433
+ ...(spec.alias === undefined ? {} : { alias: spec.alias }),
434
+ provider: spec.provider,
435
+ model: spec.model,
436
+ reasoningPolicy: cause.policy,
437
+ supportedReasoningPolicies: cause.supported,
438
+ stage: "provider-selection",
439
+ recovery: "Select one of the provider's supported reasoning policies.",
440
+ retryable: false,
441
+ });
442
+ }
443
+ console.error(`${modelRouteLabel(spec)} could not be instantiated:`, cause);
444
+ throw daemonFailure("daemon:provider", "provider-unavailable", 503, `${modelRouteLabel(spec)} is unavailable.`, {
445
+ ...(spec.alias === undefined ? {} : { alias: spec.alias }),
368
446
  provider: spec.provider,
369
447
  model: spec.model,
370
448
  stage: "provider-selection",
371
449
  retryable: false,
372
450
  });
373
451
  }
374
- return spec;
375
452
  }
376
- #parseProviderSpec(loopId, field, encoded) {
377
- let parsed;
378
- try {
379
- parsed = JSON.parse(encoded);
453
+ // {§worker-model-selection} — a model worker owns one durable model. An explicit
454
+ // selector persists onto the worker; an omitted selector resolves the worker's
455
+ // durable model, seeded once from the daemon default. A deliberately modelless
456
+ // daemon leaves the worker unset until an explicit selection arrives.
457
+ async #resolveWorkerModel(workerId, selector) {
458
+ const worker = await this.#db.worker_generation_policy_read.get({ id: workerId });
459
+ if (worker === undefined)
460
+ throw new Error(`worker ${workerId}: model route row missing`);
461
+ if (selector !== undefined) {
462
+ const spec = await this.#resolveLoopProvider(selector, worker.reasoning_policy ?? undefined);
463
+ if (spec === null)
464
+ return null;
465
+ const reasoningPolicy = worker.reasoning_policy
466
+ ?? ProviderInstantiate.configuredReasoningPolicy(spec);
467
+ if (worker.spawn_model_route_id !== null) {
468
+ const spawnSpec = await specForRoute(this.#db, worker.spawn_model_route_id);
469
+ if (spawnSpec === null)
470
+ throw new Error(`worker ${workerId}: spawn model route is missing`);
471
+ await this.#providerForPolicy(spawnSpec, reasoningPolicy);
472
+ }
473
+ await this.#db.worker_generation_policy_update.run({
474
+ id: workerId,
475
+ model_route_id: await routeForSpec(this.#db, spec),
476
+ spawn_model_route_id: worker.spawn_model_route_id,
477
+ reasoning_policy: reasoningPolicy,
478
+ });
479
+ return { providerSpec: spec, reasoningPolicy };
380
480
  }
381
- catch {
382
- throw new Error(`loop ${loopId}: persisted ${field} is malformed`);
481
+ if (worker.model_route_id !== null) {
482
+ if (worker.reasoning_policy === null) {
483
+ throw new Error(`worker ${workerId}: durable model has no reasoning policy`);
484
+ }
485
+ const spec = await specForRoute(this.#db, worker.model_route_id);
486
+ if (spec === null)
487
+ throw new Error(`worker ${workerId}: model route is missing`);
488
+ await this.#providerForPolicy(spec, worker.reasoning_policy);
489
+ return { providerSpec: spec, reasoningPolicy: worker.reasoning_policy };
383
490
  }
384
- if (parsed === null)
491
+ if (this.#provider === null)
385
492
  return null;
386
- if (typeof parsed.alias !== "string" || parsed.alias.length === 0
387
- || typeof parsed.provider !== "string" || parsed.provider.length === 0
388
- || typeof parsed.model !== "string" || parsed.model.length === 0
389
- || (parsed.baseUrl !== undefined && typeof parsed.baseUrl !== "string")) {
390
- throw new Error(`loop ${loopId}: persisted ${field} is invalid`);
493
+ const spec = resolveActiveRoute();
494
+ if (spec !== null) {
495
+ const reasoningPolicy = ProviderInstantiate.configuredReasoningPolicy(spec);
496
+ await this.#providerForPolicy(spec, reasoningPolicy);
497
+ if (worker.spawn_model_route_id !== null) {
498
+ const spawnSpec = await specForRoute(this.#db, worker.spawn_model_route_id);
499
+ if (spawnSpec === null)
500
+ throw new Error(`worker ${workerId}: spawn model route is missing`);
501
+ await this.#providerForPolicy(spawnSpec, reasoningPolicy);
502
+ }
503
+ await this.#db.worker_generation_policy_update.run({
504
+ id: workerId,
505
+ model_route_id: await routeForSpec(this.#db, spec),
506
+ spawn_model_route_id: worker.spawn_model_route_id,
507
+ reasoning_policy: reasoningPolicy,
508
+ });
509
+ return { providerSpec: spec, reasoningPolicy };
391
510
  }
392
- return parsed;
511
+ return null;
393
512
  }
513
+ // {§worker-model-selection} — the persistent spawn override. An explicit child
514
+ // selector persists onto the worker (null clears it back to inherit); an omitted
515
+ // selector resolves the persisted override, seeded once from the operator's
516
+ // PLURNK_MODEL_CHILD default.
517
+ async #resolveWorkerSpawnModel(workerId, childSelector) {
518
+ const worker = await this.#db.worker_generation_policy_read.get({ id: workerId });
519
+ if (worker === undefined)
520
+ throw new Error(`worker ${workerId}: model route row missing`);
521
+ if (childSelector !== undefined) {
522
+ const spec = childSelector === null
523
+ ? null
524
+ : await this.#resolveLoopProvider(childSelector, worker.reasoning_policy ?? undefined);
525
+ await this.#db.worker_generation_policy_update.run({
526
+ id: workerId,
527
+ model_route_id: worker.model_route_id,
528
+ spawn_model_route_id: spec === null ? null : await routeForSpec(this.#db, spec),
529
+ reasoning_policy: worker.reasoning_policy,
530
+ });
531
+ return spec;
532
+ }
533
+ if (worker.spawn_model_route_id !== null) {
534
+ const spec = await specForRoute(this.#db, worker.spawn_model_route_id);
535
+ if (spec === null)
536
+ throw new Error(`worker ${workerId}: spawn model route is missing`);
537
+ if (worker.reasoning_policy !== null) {
538
+ await this.#providerForPolicy(spec, worker.reasoning_policy);
539
+ }
540
+ return spec;
541
+ }
542
+ const configured = process.env.PLURNK_MODEL_CHILD;
543
+ if (configured === undefined || configured.length === 0)
544
+ return null;
545
+ const spec = await this.#resolveLoopProvider(configured, worker.reasoning_policy ?? undefined);
546
+ if (spec !== null) {
547
+ await this.#db.worker_generation_policy_update.run({
548
+ id: workerId,
549
+ model_route_id: worker.model_route_id,
550
+ spawn_model_route_id: await routeForSpec(this.#db, spec),
551
+ reasoning_policy: worker.reasoning_policy,
552
+ });
553
+ }
554
+ return spec;
555
+ }
556
+ // {§worker-model-selection} — the loop's durable snapshot is the immutable route ids,
557
+ // resolved through model_routes at the claim boundary; never re-resolved through the
558
+ // alias cascade (a changed alias declaration must not rewrite history).
394
559
  async #providerPolicyForLoop(loopId) {
395
- const row = await this.#db.drain_loop_provider_spec.get({ loop_id: loopId });
560
+ const row = await this.#db.drain_loop_generation_policy.get({ loop_id: loopId });
396
561
  if (row === undefined)
397
562
  throw new Error(`loop ${loopId}: provider selection row is missing`);
398
- const providerSpec = this.#parseProviderSpec(loopId, "provider_spec", row.provider_spec);
563
+ const providerSpec = await specForRoute(this.#db, row.model_route_id);
399
564
  if (providerSpec === null) {
400
- throw new Error(`loop ${loopId}: persisted provider selection is missing or invalid — refusing boot-default substitution`);
565
+ throw new Error(`loop ${loopId}: persisted provider selection is missing — refusing boot-default substitution`);
566
+ }
567
+ if (row.reasoning_policy === null) {
568
+ throw new Error(`loop ${loopId}: persisted reasoning policy is missing`);
401
569
  }
402
570
  return {
403
571
  providerSpec,
404
- childProviderSpec: this.#parseProviderSpec(loopId, "child_provider_spec", row.child_provider_spec),
572
+ childProviderSpec: await specForRoute(this.#db, row.spawn_model_route_id),
573
+ reasoningPolicy: row.reasoning_policy,
405
574
  };
406
575
  }
407
576
  async #providerSpecForLoop(loopId) {
@@ -409,20 +578,20 @@ export default class Daemon {
409
578
  }
410
579
  async #providersForLoop(loopId) {
411
580
  const policy = await this.#providerPolicyForLoop(loopId);
412
- const provider = await ProviderInstantiate.instantiateProvider(policy.providerSpec);
581
+ const provider = await this.#providerForPolicy(policy.providerSpec, policy.reasoningPolicy);
413
582
  const childProvider = policy.childProviderSpec === null
414
583
  ? provider
415
- : await ProviderInstantiate.instantiateProvider(policy.childProviderSpec);
584
+ : await this.#providerForPolicy(policy.childProviderSpec, policy.reasoningPolicy);
416
585
  return { provider, childProvider };
417
586
  }
418
587
  async #assertLoopProvider(loopId, requested) {
419
588
  const selected = await this.#providerSpecForLoop(loopId);
420
589
  if (JSON.stringify(selected) !== JSON.stringify(requested)) {
421
- throw daemonFailure("daemon:provider", "loop-provider-conflict", 409, `Loop ${loopId} uses provider alias '${selected.alias}', not '${requested.alias}'.`, {
590
+ throw daemonFailure("daemon:provider", "loop-provider-conflict", 409, `Loop ${loopId} uses ${modelRouteLabel(selected)}, not ${modelRouteLabel(requested)}.`, {
422
591
  loopId,
423
- selectedAlias: selected.alias,
592
+ ...(selected.alias === undefined ? {} : { selectedAlias: selected.alias }),
424
593
  selectedModel: `${selected.provider}/${selected.model}`,
425
- requestedAlias: requested.alias,
594
+ ...(requested.alias === undefined ? {} : { requestedAlias: requested.alias }),
426
595
  requestedModel: `${requested.provider}/${requested.model}`,
427
596
  stage: "loop-injection",
428
597
  recovery: "Cancel or conclude the loop before selecting another provider.",
@@ -430,13 +599,28 @@ export default class Daemon {
430
599
  });
431
600
  }
432
601
  }
602
+ async #assertLoopReasoningPolicy(loopId, requested) {
603
+ const selected = (await this.#providerPolicyForLoop(loopId)).reasoningPolicy;
604
+ if (selected !== requested) {
605
+ throw daemonFailure("daemon:provider", "loop-reasoning-policy-conflict", 409, `Loop ${loopId} uses reasoning policy '${selected}', not '${requested}'.`, {
606
+ loopId,
607
+ selectedReasoningPolicy: selected,
608
+ requestedReasoningPolicy: requested,
609
+ stage: "loop-injection",
610
+ recovery: "Cancel or conclude the loop before selecting another reasoning policy.",
611
+ retryable: false,
612
+ });
613
+ }
614
+ }
433
615
  async #assertLoopChildProvider(loopId, requested) {
434
616
  const selected = (await this.#providerPolicyForLoop(loopId)).childProviderSpec;
435
617
  if (JSON.stringify(selected) !== JSON.stringify(requested)) {
436
618
  throw daemonFailure("daemon:provider", "loop-child-provider-conflict", 409, `Loop ${loopId} already has a different child provider policy.`, {
437
619
  loopId,
438
620
  selectedChildAlias: selected?.alias ?? null,
621
+ selectedChildModel: selected === null ? null : `${selected.provider}/${selected.model}`,
439
622
  requestedChildAlias: requested?.alias ?? null,
623
+ requestedChildModel: requested === null ? null : `${requested.provider}/${requested.model}`,
440
624
  stage: "loop-injection",
441
625
  recovery: "Cancel or conclude the loop before changing its child provider policy.",
442
626
  retryable: false,
@@ -462,10 +646,180 @@ export default class Daemon {
462
646
  }
463
647
  // {§methods-model-worker} — the workspace's model worker (created on first use), distinct from the client
464
648
  // worker so the model's packets never carry client-action rows. The module binds its threads to this.
465
- ensureModelWorker(workspaceId) {
466
- return Envelope.ensureModelWorker(this.#db, ClientInput.assertId("worker.ensure-model", "workspaceId", workspaceId));
649
+ // Optional worker settings ({§worker-settings}) ride the client's per-run declaration: merged in on
650
+ // creation AND on every subsequent ensure, so a client can change its mind between loops.
651
+ async ensureModelWorker(workspaceId, settings) {
652
+ const checked = ClientInput.assertId("worker.ensure-model", "workspaceId", workspaceId);
653
+ const created = await Envelope.ensureModelWorker(this.#db, checked);
654
+ if (settings !== undefined)
655
+ await this.#mergeWorkerSettings(created, settings);
656
+ return created;
657
+ }
658
+ // {§worker-settings} — merge known keys into the worker's behavioral-rules bag.
659
+ // Validated at the boundary; unprovided keys keep their durable value.
660
+ async #mergeWorkerSettings(workerId, settings) {
661
+ const current = await WorkerSettingsReader.read(this.#db, workerId);
662
+ const merged = {
663
+ requestUserInput: settings.requestUserInput ?? current.requestUserInput,
664
+ };
665
+ await this.#db.worker_settings_update.run({ id: workerId, settings: JSON.stringify(merged) });
666
+ }
667
+ // {§worker-settings} — project the worker's behavioral rules for a client-interface surface.
668
+ async readWorkerSettings(args) {
669
+ const workspaceId = ClientInput.assertId("worker.settings.get", "workspaceId", args.workspaceId);
670
+ const workerId = ClientInput.assertId("worker.settings.get", "workerId", args.workerId);
671
+ await this.#assertWorkerOwned(workspaceId, workerId);
672
+ const settings = await WorkerSettingsReader.read(this.#db, workerId);
673
+ return { requestUserInput: settings.requestUserInput };
674
+ }
675
+ // {§worker-settings} — persist known behavioral-rule keys; unknown keys are
676
+ // rejected at the input boundary, never persisted.
677
+ async setWorkerSettings(args) {
678
+ const workspaceId = ClientInput.assertId("worker.settings.set", "workspaceId", args.workspaceId);
679
+ const workerId = ClientInput.assertId("worker.settings.set", "workerId", args.workerId);
680
+ await this.#assertWorkerOwned(workspaceId, workerId);
681
+ ClientInput.parseWorkerSettings(args.settings);
682
+ await this.#mergeWorkerSettings(workerId, args.settings);
683
+ const settings = await WorkerSettingsReader.read(this.#db, workerId);
684
+ return { requestUserInput: settings.requestUserInput };
685
+ }
686
+ // {§worker-model-selection} — project a worker's durable model and spawn override
687
+ // for a client-interface get/set surface. Ownership is checked at this boundary.
688
+ async readWorkerModel(args) {
689
+ const workspaceId = ClientInput.assertId("worker.model.get", "workspaceId", args.workspaceId);
690
+ const workerId = ClientInput.assertId("worker.model.get", "workerId", args.workerId);
691
+ await this.#assertWorkerOwned(workspaceId, workerId);
692
+ const row = await this.#db.worker_generation_policy_read.get({ id: workerId });
693
+ if (row === undefined)
694
+ throw new Error(`worker ${workerId}: model route row missing`);
695
+ const modelSpec = await specForRoute(this.#db, row.model_route_id);
696
+ const spawnModelSpec = await specForRoute(this.#db, row.spawn_model_route_id);
697
+ return {
698
+ model: modelSpec === null ? null : projectModelRoute(modelSpec),
699
+ spawnModel: spawnModelSpec === null ? null : projectModelRoute(spawnModelSpec),
700
+ };
701
+ }
702
+ // {§worker-model-selection} — persist an explicit model selection onto the worker
703
+ // and return the resolved spec. An unresolvable selector fails loud here.
704
+ async setWorkerModel(args) {
705
+ const workspaceId = ClientInput.assertId("worker.model.set", "workspaceId", args.workspaceId);
706
+ const workerId = ClientInput.assertId("worker.model.set", "workerId", args.workerId);
707
+ await this.#assertWorkerOwned(workspaceId, workerId);
708
+ await this.#assertWorkerSelectable(workerId);
709
+ const selector = ClientInput.assertSelector("worker.model.set", "selector", args.selector);
710
+ const policy = await this.#resolveWorkerModel(workerId, selector);
711
+ if (policy === null) {
712
+ throw new OperationFailureError(Results.failure("daemon:provider", "not-configured", 501, "No provider is configured for this worker.", {}, { stage: "provider-selection", recovery: "Select a configured model provider.", retryable: false }));
713
+ }
714
+ return projectModelRoute(policy.providerSpec);
715
+ }
716
+ async #reasoningSupportForWorker(workerId, row, providerSpec, policy) {
717
+ const provider = await this.#providerForPolicy(providerSpec, policy);
718
+ if (row.spawn_model_route_id === null)
719
+ return provider.supportedReasoningPolicies;
720
+ const spawnSpec = await specForRoute(this.#db, row.spawn_model_route_id);
721
+ if (spawnSpec === null)
722
+ throw new Error(`worker ${workerId}: spawn model route is missing`);
723
+ const spawnProvider = await this.#providerForPolicy(spawnSpec, policy);
724
+ return provider.supportedReasoningPolicies.filter((candidate) => spawnProvider.supportedReasoningPolicies.includes(candidate));
725
+ }
726
+ // {§worker-reasoning-policy} — the worker's reasoning policy is durable and
727
+ // provider-relative. A model-less worker remains explicitly uninitialized.
728
+ async readWorkerReasoning(args) {
729
+ const workspaceId = ClientInput.assertId("worker.reasoning.get", "workspaceId", args.workspaceId);
730
+ const workerId = ClientInput.assertId("worker.reasoning.get", "workerId", args.workerId);
731
+ await this.#assertWorkerOwned(workspaceId, workerId);
732
+ await this.#resolveWorkerModel(workerId, undefined);
733
+ const row = await this.#db.worker_generation_policy_read.get({ id: workerId });
734
+ if (row === undefined)
735
+ throw new Error(`worker ${workerId}: generation policy row missing`);
736
+ if (row.model_route_id === null) {
737
+ if (row.reasoning_policy !== null) {
738
+ throw new Error(`worker ${workerId}: reasoning policy exists without a model route`);
739
+ }
740
+ return { policy: null, supportedPolicies: [] };
741
+ }
742
+ if (row.reasoning_policy === null) {
743
+ throw new Error(`worker ${workerId}: durable model has no reasoning policy`);
744
+ }
745
+ const spec = await specForRoute(this.#db, row.model_route_id);
746
+ if (spec === null)
747
+ throw new Error(`worker ${workerId}: model route is missing`);
748
+ return {
749
+ policy: row.reasoning_policy,
750
+ supportedPolicies: await this.#reasoningSupportForWorker(workerId, row, spec, row.reasoning_policy),
751
+ };
752
+ }
753
+ // {§worker-reasoning-policy} — mutate between loops, validate against both
754
+ // the worker model and its optional spawn model, then persist atomically.
755
+ async setWorkerReasoning(args) {
756
+ const workspaceId = ClientInput.assertId("worker.reasoning.set", "workspaceId", args.workspaceId);
757
+ const workerId = ClientInput.assertId("worker.reasoning.set", "workerId", args.workerId);
758
+ let policy;
759
+ try {
760
+ policy = Validator.assertReasoningPolicy(args.policy);
761
+ }
762
+ catch (cause) {
763
+ throw daemonFailure("daemon:input", "reasoning-policy-invalid", 400, cause instanceof Error ? cause.message : "The reasoning policy is invalid.", { field: "policy", retryable: false });
764
+ }
765
+ await this.#assertWorkerOwned(workspaceId, workerId);
766
+ await this.#assertWorkerSelectable(workerId);
767
+ await this.#resolveWorkerModel(workerId, undefined);
768
+ const row = await this.#db.worker_generation_policy_read.get({ id: workerId });
769
+ if (row === undefined)
770
+ throw new Error(`worker ${workerId}: generation policy row missing`);
771
+ const spec = await specForRoute(this.#db, row.model_route_id);
772
+ if (spec === null) {
773
+ throw daemonFailure("daemon:provider", "worker-model-unset", 409, `Worker ${workerId} has no model against which to validate a reasoning policy.`, {
774
+ workerId,
775
+ stage: "provider-selection",
776
+ recovery: "Select a model before selecting its reasoning policy.",
777
+ retryable: false,
778
+ });
779
+ }
780
+ const supportedPolicies = await this.#reasoningSupportForWorker(workerId, row, spec, policy);
781
+ await this.#db.worker_generation_policy_update.run({
782
+ id: workerId,
783
+ model_route_id: row.model_route_id,
784
+ spawn_model_route_id: row.spawn_model_route_id,
785
+ reasoning_policy: policy,
786
+ });
787
+ return { policy, supportedPolicies };
788
+ }
789
+ // {§worker-model-selection} — persist the worker's spawn override; a null
790
+ // selector means inherit (clears the override).
791
+ async setWorkerSpawnModel(args) {
792
+ const workspaceId = ClientInput.assertId("worker.child.set", "workspaceId", args.workspaceId);
793
+ const workerId = ClientInput.assertId("worker.child.set", "workerId", args.workerId);
794
+ await this.#assertWorkerOwned(workspaceId, workerId);
795
+ await this.#assertWorkerSelectable(workerId);
796
+ const selector = ClientInput.assertChildSelector("worker.child.set", args.selector);
797
+ const spec = await this.#resolveWorkerSpawnModel(workerId, selector);
798
+ return spec === null ? null : projectModelRoute(spec);
799
+ }
800
+ // {§worker-model-selection} — a selection must not mutate underneath active
801
+ // work: a live or parked loop is a precise 409, never a silent retroactive
802
+ // switch of the immutable loop snapshot.
803
+ async #assertWorkerSelectable(workerId) {
804
+ if (await this.#drains.hasLiveWork(workerId)) {
805
+ throw daemonFailure("daemon:worker", "worker-loop-active", 409, `Worker ${workerId} has a live or parked loop; select a model after concluding or cancelling it.`, {
806
+ workerId,
807
+ stage: "model-selection",
808
+ recovery: "Conclude or cancel the active loop before selecting a model.",
809
+ retryable: false,
810
+ });
811
+ }
812
+ }
813
+ async #assertWorkerOwned(workspaceId, workerId) {
814
+ const owner = await this.#db.envelope_get_worker_by_id.get({ id: workerId });
815
+ if (owner === undefined) {
816
+ throw daemonFailure("daemon:worker", "worker-not-found", 404, `Worker ${workerId} does not exist.`, { workerId });
817
+ }
818
+ if (owner.workspace_id !== workspaceId) {
819
+ throw daemonFailure("daemon:worker", "workspace-mismatch", 409, `Worker ${workerId} does not belong to workspace ${workspaceId}.`, { workerId, workspaceId, actualWorkspaceId: owner.workspace_id });
820
+ }
467
821
  }
468
- // {§methods-op-mirror} — execute parsed ops on behalf of a client, journaled as a
822
+ // {§methods-op-mirror} — execute parsed ops on behalf of a client as a
469
823
  // client-origin turn (the log is core's, a client op is a first-class citizen), dispatched through
470
824
  // the engine, then emitted as log/entry on the event source. One seam op backs the whole op_*
471
825
  // family (read/edit/copy/find/fold/look/move/open/send/exec); the module parses at its edge with the
@@ -485,10 +839,10 @@ export default class Daemon {
485
839
  throw error;
486
840
  }
487
841
  }
488
- // The client-interface action contract: one AG-UI action owns one journal segment,
489
- // regardless of how many statements op.parse produced. A proposed statement may
490
- // keep this promise (and segment) open across interrupt/resume; settlement closes
491
- // it. The journal is durable evidence for the action, not a second client lifecycle.
842
+ // The client-interface action contract: one AG-UI action owns one administrative
843
+ // loop, regardless of how many statements op.parse produced. Each statement is one
844
+ // ordinary operation turn; a proposal may keep that turn and loop open across
845
+ // interrupt/resume until settlement.
492
846
  async dispatchClientAction(args) {
493
847
  const workspaceId = ClientInput.assertId("operation.dispatch-batch", "workspaceId", args.workspaceId);
494
848
  const workerId = ClientInput.assertId("operation.dispatch-batch", "workerId", args.workerId);
@@ -513,18 +867,38 @@ export default class Daemon {
513
867
  const { workspaceId, workerId, loopId, statement } = args;
514
868
  const release = await this.#workspaceGate.acquireTurn(workspaceId, workerId);
515
869
  try {
516
- const { id: turnId } = await JournalTurn.insert(this.#db, loopId);
517
- const entryIds = [];
518
- const result = await this.#engine.dispatch({
519
- statement, workspaceId, workerId, loopId, turnId, sequence: 1,
520
- origin: "client", onDispatch: (logEntryId) => { entryIds.push(logEntryId); },
870
+ const { id: turnId } = await Turn.open(this.#db, {
871
+ loopId,
872
+ producer: "client",
873
+ kind: "operation",
521
874
  });
522
- await this.#branchBatches.sealTurn(turnId);
523
- for (const logEntryId of entryIds) {
524
- const entry = await LogEntry.fetchLogEntry(this.#db, logEntryId);
525
- this.#broadcast({ workspaceId }, "log/entry", { entry });
875
+ let turnOpen = true;
876
+ try {
877
+ const entryIds = [];
878
+ const result = await this.#engine.dispatch({
879
+ statement, workspaceId, workerId, loopId, turnId, sequence: 1,
880
+ origin: "client", onDispatch: (logEntryId) => { entryIds.push(logEntryId); },
881
+ });
882
+ await Turn.complete(this.#db, turnId, result.status);
883
+ turnOpen = false;
884
+ await this.#branchBatches.sealTurn(turnId);
885
+ for (const logEntryId of entryIds) {
886
+ const entry = await LogEntry.fetchLogEntry(this.#db, logEntryId);
887
+ this.#broadcast({ workspaceId }, "log/entry", { entry });
888
+ }
889
+ return result;
890
+ }
891
+ catch (cause) {
892
+ if (turnOpen) {
893
+ try {
894
+ await Turn.complete(this.#db, turnId, 500);
895
+ }
896
+ catch (completionCause) {
897
+ throw new AggregateError([cause, completionCause], `client operation turn ${turnId} failed and could not be completed`);
898
+ }
899
+ }
900
+ throw cause;
526
901
  }
527
- return result;
528
902
  }
529
903
  finally {
530
904
  release();
@@ -616,7 +990,7 @@ export default class Daemon {
616
990
  // {§methods} — the module's render surface beyond the journal. Thin delegations
617
991
  // into core's envelope / membership / provider machinery; the module fans the results into its own views.
618
992
  listProviders() {
619
- const active = resolveActiveAlias();
993
+ const active = resolveActiveRoute();
620
994
  return {
621
995
  aliases: parseAliasesFromEnv().map((a) => {
622
996
  const isActive = active !== null && active.alias === a.alias;
@@ -627,6 +1001,11 @@ export default class Daemon {
627
1001
  }),
628
1002
  };
629
1003
  }
1004
+ // {§model-catalog} The catalog is a bounded, worldless local projection.
1005
+ // It performs no provider I/O and never changes the worker's selection.
1006
+ listModels(query) {
1007
+ return listModelCatalog(query);
1008
+ }
630
1009
  // {§client-display-capabilities} Core composes the installed family
631
1010
  // declarations; interface modules expose this contracts-owned wire without
632
1011
  // inventing presentation policy. `exec` is operation machinery, not an
@@ -699,7 +1078,11 @@ export default class Daemon {
699
1078
  }
700
1079
  if (constraints.length > 0)
701
1080
  await GitMembership.resolveGitMembership(this.#db, envelope.workspaceId, undefined);
1081
+ for (const provider of this.#workspaceCapabilityProviders.values()) {
1082
+ await provider.hydrate(envelope.workspaceId);
1083
+ }
702
1084
  await LoopDocs.materialize(this.#engine, this.#db, envelope.workspaceId);
1085
+ await SkillDocs.materialize(this.#engine, this.#db, envelope.workspaceId);
703
1086
  void this.#engine.warmWorkspaceDerivations(envelope.workspaceId).catch(() => { });
704
1087
  this.#broadcast("all", "workspace/created", { id: envelope.workspaceId, name: envelope.workspaceName, projectRoot: envelope.projectRoot });
705
1088
  return envelope;
@@ -898,6 +1281,9 @@ export default class Daemon {
898
1281
  }
899
1282
  }
900
1283
  const worker = await Envelope.createModelWorker(this.#db, workspaceId, name);
1284
+ if (args.settings !== undefined) {
1285
+ await this.#mergeWorkerSettings(worker.id, args.settings);
1286
+ }
901
1287
  return { workerId: worker.id, workerName: worker.name };
902
1288
  }
903
1289
  // {§worker-scheme-fork} — branch a worker's log while sharing the workspace world.
@@ -928,50 +1314,200 @@ export default class Daemon {
928
1314
  const branch = await this.#db.envelope_get_worker_by_id.get({ id: branchWorkerId });
929
1315
  return { workerId: branchWorkerId, workerName: branch?.name ?? null, parentWorkerId: workerId };
930
1316
  }
931
- async registerRuntime({ namespaceOwner, decl, executor, availability, scheme }) {
932
- if (typeof namespaceOwner !== "string" || namespaceOwner.trim().length === 0) {
933
- throw new Error("registerRuntime: namespaceOwner must be a non-empty string");
934
- }
935
- const runtime = RuntimeDeclaration.assert(decl, namespaceOwner);
936
- this.#engine.registerRuntime(runtime.name, {
937
- executor,
938
- namespaceOwner: { kind: "module", name: namespaceOwner },
939
- glyph: runtime.glyph ?? "",
940
- invocation: runtime.invocation,
941
- documentation: runtime.documentation ?? "",
942
- available: availability.available,
943
- detail: availability.detail,
944
- }, scheme);
1317
+ async registerRuntimes(registrations) {
1318
+ const normalized = registrations.map((registration) => this.#normalizeRuntime(registration));
1319
+ this.#engine.registerRuntimes(normalized);
945
1320
  if (this.#capabilitiesPublished) {
946
1321
  for (const workspace of await Envelope.listWorkspaces(this.#db)) {
947
1322
  await LoopDocs.materialize(this.#engine, this.#db, workspace.id);
1323
+ await SkillDocs.materialize(this.#engine, this.#db, workspace.id);
948
1324
  }
949
1325
  }
950
1326
  }
1327
+ async registerRuntime(registration) {
1328
+ await this.registerRuntimes([registration]);
1329
+ }
951
1330
  async registerScheme(name, handler) {
952
1331
  this.#schemes.register(name, handler);
953
1332
  if (this.#capabilitiesPublished) {
954
1333
  await this.#schemes.ready();
955
1334
  for (const workspace of await Envelope.listWorkspaces(this.#db)) {
956
1335
  await LoopDocs.materialize(this.#engine, this.#db, workspace.id);
1336
+ await SkillDocs.materialize(this.#engine, this.#db, workspace.id);
957
1337
  }
958
1338
  }
959
1339
  }
960
- registerModuleAction(name, handler) {
1340
+ #normalizeRuntime({ namespaceOwner, decl, executor, availability, scheme }) {
1341
+ if (typeof namespaceOwner !== "string" || namespaceOwner.trim().length === 0) {
1342
+ throw new Error("runtime registration namespaceOwner must be a non-empty string");
1343
+ }
1344
+ const runtime = RuntimeDeclaration.assert(decl, namespaceOwner);
1345
+ return {
1346
+ tag: runtime.name,
1347
+ entry: {
1348
+ executor,
1349
+ namespaceOwner: { kind: "module", name: namespaceOwner },
1350
+ glyph: runtime.glyph ?? "",
1351
+ summary: runtime.summary,
1352
+ invocation: runtime.invocation,
1353
+ details: runtime.details ?? "",
1354
+ ...(runtime.resourcesPath === undefined ? {} : { resourcesPath: runtime.resourcesPath }),
1355
+ ...(runtime.expandTools === undefined ? {} : { expandTools: runtime.expandTools }),
1356
+ available: availability.available,
1357
+ detail: availability.detail,
1358
+ },
1359
+ scheme,
1360
+ };
1361
+ }
1362
+ registerModuleAction(registration) {
1363
+ const { name, scope, handler } = registration;
961
1364
  if (name.length === 0)
962
1365
  throw new Error("registerModuleAction: action name must not be empty");
1366
+ if (scope !== "worldless" && scope !== "workspace") {
1367
+ throw new Error(`module action '${name}' has invalid scope '${String(scope)}'`);
1368
+ }
1369
+ if (typeof handler !== "function")
1370
+ throw new Error(`module action '${name}' has no handler`);
963
1371
  if (this.#moduleActions.has(name))
964
1372
  throw new Error(`module action '${name}' is already registered`);
965
- this.#moduleActions.set(name, handler);
1373
+ this.#moduleActions.set(name, registration);
966
1374
  }
967
1375
  listModuleActions() {
968
- return [...this.#moduleActions.keys()].toSorted();
1376
+ return [...this.#moduleActions.values()]
1377
+ .map(({ name, scope }) => ({ name, scope }))
1378
+ .toSorted((left, right) => left.name.localeCompare(right.name));
969
1379
  }
970
- async invokeModuleAction(name, params) {
971
- const handler = this.#moduleActions.get(name);
972
- if (handler === undefined)
1380
+ async invokeModuleAction(name, params, context) {
1381
+ const registration = this.#moduleActions.get(name);
1382
+ if (registration === undefined)
973
1383
  throw new Error(`module action '${name}' is not registered`);
974
- return handler(params);
1384
+ if (registration.scope !== context.scope) {
1385
+ throw new Error(`module action '${name}' requires ${registration.scope} context, not ${context.scope}`);
1386
+ }
1387
+ if (context.scope === "workspace") {
1388
+ ClientInput.assertId(`module action '${name}'`, "workspaceId", context.workspaceId);
1389
+ }
1390
+ return registration.handler(params, context);
1391
+ }
1392
+ registerWorkspaceCapabilityProvider(namespaceOwner, provider) {
1393
+ if (namespaceOwner.trim().length === 0) {
1394
+ throw new Error("workspace capability provider requires a non-empty namespace owner");
1395
+ }
1396
+ if (this.#workspaceCapabilityProviders.has(namespaceOwner)) {
1397
+ throw new Error(`workspace capability provider '${namespaceOwner}' is already registered`);
1398
+ }
1399
+ this.#workspaceCapabilityProviders.set(namespaceOwner, provider);
1400
+ }
1401
+ async readWorkspaceModuleState(workspaceId, namespaceOwner) {
1402
+ const checkedWorkspaceId = ClientInput.assertId("workspace module state", "workspaceId", workspaceId);
1403
+ if (namespaceOwner.trim().length === 0) {
1404
+ throw new Error("workspace module state requires a non-empty namespace owner");
1405
+ }
1406
+ const row = await this.#db.workspace_module_state_get.get({
1407
+ workspace_id: checkedWorkspaceId,
1408
+ namespace_owner: namespaceOwner,
1409
+ });
1410
+ return row === undefined ? null : JSON.parse(row.state);
1411
+ }
1412
+ async replaceWorkspaceCapabilities({ workspaceId, namespaceOwner, state, runtimes, }) {
1413
+ const checkedWorkspaceId = ClientInput.assertId("workspace capability replacement", "workspaceId", workspaceId);
1414
+ if (namespaceOwner.trim().length === 0) {
1415
+ throw new Error("workspace capability replacement requires a non-empty namespace owner");
1416
+ }
1417
+ const workspace = await this.#db.envelope_get_workspace.get({ id: checkedWorkspaceId });
1418
+ if (workspace === undefined) {
1419
+ throw daemonFailure("daemon:workspace-capability", "workspace-not-found", 404, `Workspace ${checkedWorkspaceId} does not exist.`, { workspaceId: checkedWorkspaceId, retryable: false });
1420
+ }
1421
+ const encoded = state === null ? null : JSON.stringify(state);
1422
+ if (state !== null && encoded === undefined) {
1423
+ throw daemonFailure("daemon:workspace-capability", "state-not-json", 400, "Workspace module state is not JSON-serializable.", { namespaceOwner, retryable: false });
1424
+ }
1425
+ if (encoded !== null)
1426
+ JSON.parse(encoded);
1427
+ const normalized = runtimes.map((registration) => {
1428
+ if (registration.namespaceOwner !== namespaceOwner) {
1429
+ throw new Error(`workspace runtime owner '${registration.namespaceOwner}' does not match '${namespaceOwner}'`);
1430
+ }
1431
+ return this.#normalizeRuntime(registration);
1432
+ });
1433
+ const gate = this.#workspaceGate.tryExclusive(checkedWorkspaceId);
1434
+ if (gate === null) {
1435
+ throw daemonFailure("daemon:workspace-capability", "workspace-busy", 409, `Workspace ${checkedWorkspaceId} is running an operation or another capability change.`, {
1436
+ workspaceId: checkedWorkspaceId,
1437
+ namespaceOwner,
1438
+ recovery: "Settle the current operation and retry the capability change.",
1439
+ retryable: true,
1440
+ });
1441
+ }
1442
+ await gate.acquired;
1443
+ const prior = await this.#db.workspace_module_state_get.get({
1444
+ workspace_id: checkedWorkspaceId,
1445
+ namespace_owner: namespaceOwner,
1446
+ });
1447
+ let rollbackRuntimes;
1448
+ let stateChanged = false;
1449
+ try {
1450
+ const commitRuntimes = await this.#engine.prepareWorkspaceRuntimes(checkedWorkspaceId, namespaceOwner, normalized);
1451
+ if (encoded === null) {
1452
+ await this.#db.workspace_module_state_delete.run({
1453
+ workspace_id: checkedWorkspaceId,
1454
+ namespace_owner: namespaceOwner,
1455
+ });
1456
+ }
1457
+ else {
1458
+ await this.#db.workspace_module_state_put.run({
1459
+ workspace_id: checkedWorkspaceId,
1460
+ namespace_owner: namespaceOwner,
1461
+ state: encoded,
1462
+ });
1463
+ }
1464
+ stateChanged = true;
1465
+ rollbackRuntimes = commitRuntimes();
1466
+ if (this.#capabilitiesPublished) {
1467
+ await LoopDocs.materialize(this.#engine, this.#db, checkedWorkspaceId);
1468
+ await SkillDocs.materialize(this.#engine, this.#db, checkedWorkspaceId);
1469
+ }
1470
+ }
1471
+ catch (cause) {
1472
+ rollbackRuntimes?.();
1473
+ const rollbackErrors = [];
1474
+ if (stateChanged) {
1475
+ try {
1476
+ if (prior === undefined) {
1477
+ await this.#db.workspace_module_state_delete.run({
1478
+ workspace_id: checkedWorkspaceId,
1479
+ namespace_owner: namespaceOwner,
1480
+ });
1481
+ }
1482
+ else {
1483
+ await this.#db.workspace_module_state_put.run({
1484
+ workspace_id: checkedWorkspaceId,
1485
+ namespace_owner: namespaceOwner,
1486
+ state: prior.state,
1487
+ });
1488
+ }
1489
+ }
1490
+ catch (rollbackCause) {
1491
+ rollbackErrors.push(rollbackCause);
1492
+ }
1493
+ if (this.#capabilitiesPublished) {
1494
+ try {
1495
+ await LoopDocs.materialize(this.#engine, this.#db, checkedWorkspaceId);
1496
+ await SkillDocs.materialize(this.#engine, this.#db, checkedWorkspaceId);
1497
+ }
1498
+ catch (rollbackCause) {
1499
+ rollbackErrors.push(rollbackCause);
1500
+ }
1501
+ }
1502
+ }
1503
+ if (rollbackErrors.length > 0) {
1504
+ throw new AggregateError([cause, ...rollbackErrors], "Workspace capability replacement and rollback failed");
1505
+ }
1506
+ throw cause;
1507
+ }
1508
+ finally {
1509
+ gate.release();
1510
+ }
975
1511
  }
976
1512
  get engine() { return this.#engine; }
977
1513
  get provider() { return this.#provider; }
@@ -998,6 +1534,23 @@ export default class Daemon {
998
1534
  // shell is the default runtime, so its executor must boot usable.
999
1535
  const executors = await ExecutorRegistry.build({ defaultRuntime: "sh", cwd: this.#discoveryCwd });
1000
1536
  this.#engine.setExecutors(executors);
1537
+ // {§question-tool} — the native request-user-input runtime, process-wide;
1538
+ // per-worker admission gates its doc visibility and dispatch ({§worker-settings}).
1539
+ await this.#engine.registerRuntimes([{
1540
+ tag: "question",
1541
+ entry: {
1542
+ executor: new QuestionTool({ runtime: "question", glyph: "❓" }),
1543
+ namespaceOwner: { kind: "module", name: "core" },
1544
+ glyph: "❓",
1545
+ summary: questionRuntimeDecl.summary,
1546
+ invocation: questionRuntimeDecl.invocation,
1547
+ details: questionRuntimeDecl.details ?? "",
1548
+ available: true,
1549
+ detail: "in-process",
1550
+ },
1551
+ }]);
1552
+ // {§effect-policy-tunable} — invalid operator policy fails boot, not the first EXEC.
1553
+ EffectPolicy.validateConfiguration();
1001
1554
  // {§exec} — mint a scheme per runtime tag so exec output entries address by tag
1002
1555
  // authority (sh:///l/t/s). The "exec" scheme stays for the EXEC op dispatch.
1003
1556
  this.#schemes.registerRuntimeSchemes(executors);
@@ -1005,18 +1558,34 @@ export default class Daemon {
1005
1558
  // (agnostic, by plurnk.kind:"scheme"). They light up http://, etc. with
1006
1559
  // no further engine change — #run wraps their context in SchemeCtxImpl ({§plugin-discovery}).
1007
1560
  await this.#schemes.discoverExternal(this.#discoveryCwd);
1561
+ // {§module-discovery} — third-party daemon-module composition: trusted
1562
+ // packages declaring `plurnk.kind: "module"` register beside the
1563
+ // service's explicit composition before any module setup runs.
1564
+ const discoveredModules = await discoverDaemonModules({ cwd: this.#discoveryCwd });
1565
+ for (const packageName of discoveredModules.skipped) {
1566
+ console.warn(`module discovery: '${packageName}' is discovered but untrusted (PLURNK_PLUGINS_TRUSTED_ONLY); not registered`);
1567
+ }
1568
+ for (const module of discoveredModules.modules) {
1569
+ this.#modules.push(module);
1570
+ }
1008
1571
  const setupSeam = this;
1009
1572
  for (const module of this.#modules) {
1010
1573
  if (module.close !== undefined)
1011
1574
  this.#moduleClosers.push(module);
1012
1575
  await module.setup?.(setupSeam);
1013
1576
  }
1577
+ for (const workspace of await Envelope.listWorkspaces(this.#db)) {
1578
+ for (const provider of this.#workspaceCapabilityProviders.values()) {
1579
+ await provider.hydrate(workspace.id);
1580
+ }
1581
+ }
1014
1582
  await this.#schemes.ready();
1015
1583
  // Reconcile the kernel-published documentation surface once per existing workspace.
1016
1584
  // Installed capabilities and operator configuration are now fully known; model loops
1017
1585
  // consume this workspace state but never republish it.
1018
1586
  for (const workspace of await Envelope.listWorkspaces(this.#db)) {
1019
1587
  await LoopDocs.materialize(this.#engine, this.#db, workspace.id);
1588
+ await SkillDocs.materialize(this.#engine, this.#db, workspace.id);
1020
1589
  }
1021
1590
  this.#capabilitiesPublished = true;
1022
1591
  await this.#recoverLifecycle();
@@ -1033,7 +1602,9 @@ export default class Daemon {
1033
1602
  await this.#db.recovery_fail_active_loops.run({});
1034
1603
  await this.#db.recovery_settle_open_provider_requests.run({});
1035
1604
  await this.#db.recovery_fail_open_model_calls.run({});
1605
+ await this.#db.recovery_fail_open_turns.run({});
1036
1606
  await this.#db.recovery_fail_ownerless_proposals.run({});
1607
+ await this.#db.recovery_remove_ownerless_client_interactions.run({});
1037
1608
  await this.#db.recovery_error_orphan_subscription_channels.run({});
1038
1609
  await this.#db.recovery_fail_orphan_subscriptions.run({});
1039
1610
  await this.#db.recovery_resume_unblocked_parks.run({});
@@ -1059,10 +1630,34 @@ export default class Daemon {
1059
1630
  await this.#drains.schedulePollWake(row.workspace_id, row.worker_id, systemPrompt);
1060
1631
  }
1061
1632
  }
1633
+ // {§crash-only-stop} — the settle deadline. Default 30s; an operator can
1634
+ // raise it for slow hosts or lower it for tests. 0 is refused (an unbounded
1635
+ // stop is exactly the wedge this exists to prevent).
1636
+ static #stopDeadlineMs() {
1637
+ const raw = Number(process.env.PLURNK_SERVICE_STOP_TIMEOUT_MS ?? 30_000);
1638
+ if (!Number.isSafeInteger(raw) || raw <= 0) {
1639
+ throw new Error(`PLURNK_SERVICE_STOP_TIMEOUT_MS must be a positive integer; got ${JSON.stringify(process.env.PLURNK_SERVICE_STOP_TIMEOUT_MS)}.`);
1640
+ }
1641
+ return raw;
1642
+ }
1062
1643
  async stop() {
1063
1644
  if (!this.#started)
1064
1645
  return;
1065
1646
  this.#started = false;
1647
+ const stopDeadlineMs = Daemon.#stopDeadlineMs();
1648
+ const deadline = Date.now() + stopDeadlineMs;
1649
+ const settle = (label, wait) => new Promise((resolve) => {
1650
+ let settled = false;
1651
+ const finish = (result) => {
1652
+ if (settled)
1653
+ return;
1654
+ settled = true;
1655
+ clearTimeout(timer);
1656
+ resolve(result);
1657
+ };
1658
+ const timer = setTimeout(() => finish({ status: "rejected", reason: new Error(`stop deadline exceeded waiting for ${label}`) }), Math.max(0, deadline - Date.now()));
1659
+ Promise.resolve().then(wait).then((value) => finish({ status: "fulfilled", value }), (reason) => finish({ status: "rejected", reason }));
1660
+ });
1066
1661
  // Stop accepting external work immediately, but do not await listener
1067
1662
  // closure before cancelling active workers: an SSE connection may itself be
1068
1663
  // waiting for the worker cancellation that follows.
@@ -1087,27 +1682,35 @@ export default class Daemon {
1087
1682
  this.#engine.cancelDerivations(derivationAbort);
1088
1683
  this.#branchBatches.beginStop();
1089
1684
  this.#drains.beginStop("daemon_stopping");
1090
- await this.#branchBatches.idle();
1091
- const [drainResult] = await Promise.allSettled([this.#drains.idle()]);
1092
- const closeResults = await moduleClose;
1093
- const [streamingResult] = await Promise.allSettled([this.#drainStreamingSchemes()]);
1094
- const [derivationResult] = await Promise.allSettled([
1095
- this.#engine.drainDerivations(derivationAbort), // active workspace warms settle before the db closes upstream
1096
- ]);
1097
- const mimetypeResults = this.#ownsMimetypes
1098
- ? await Promise.allSettled([this.#mimetypes.dispose()])
1099
- : [];
1100
- const [schemeResult] = await Promise.allSettled([this.#schemes.close()]);
1685
+ // {§crash-only-stop} — the settle sequence is DEADLINE-BOUNDED: a child
1686
+ // that never closes (a wedged MCP server, a stuck stream) must not hang
1687
+ // the daemon forever. Past the deadline the waits are abandoned; the
1688
+ // process may exit with the WAL in place (SQLite recovers) rather than
1689
+ // leak as a live-but-wedged tree.
1690
+ const branchResult = await settle("branch batches idle", () => this.#branchBatches.idle());
1691
+ const drainResult = await settle("drains idle", () => this.#drains.idle());
1692
+ const moduleResult = await settle("modules close", async () => {
1693
+ const results = await moduleClose;
1694
+ if (results.some((r) => r.status === "rejected"))
1695
+ throw new AggregateError(results.filter((r) => r.status === "rejected").map((r) => r.reason), "module close failed");
1696
+ });
1697
+ const streamingResult = await settle("streaming schemes idle", () => this.#drainStreamingSchemes());
1698
+ const derivationResult = await settle("derivation drain", () => this.#engine.drainDerivations(derivationAbort));
1699
+ const mimetypeResult = this.#ownsMimetypes
1700
+ ? await settle("mimetypes dispose", () => this.#mimetypes.dispose())
1701
+ : null;
1702
+ const schemeResult = await settle("schemes close", () => this.#schemes.close());
1101
1703
  // Streaming and scheme closure are the last producers of synchronous
1102
1704
  // conclusion notifications. Join the supervisor-owned async tails only
1103
1705
  // after those producers settle, before the caller may close SQLite.
1104
- const [wakeResult] = await Promise.allSettled([this.#drains.idle()]);
1706
+ const wakeResult = await settle("drains idle (wake)", () => this.#drains.idle());
1105
1707
  const closeErrors = [
1106
- ...closeResults,
1708
+ moduleResult,
1709
+ branchResult,
1107
1710
  drainResult,
1108
1711
  streamingResult,
1109
1712
  derivationResult,
1110
- ...mimetypeResults,
1713
+ ...(mimetypeResult === null ? [] : [mimetypeResult]),
1111
1714
  schemeResult,
1112
1715
  wakeResult,
1113
1716
  ]
@@ -1179,8 +1782,9 @@ export default class Daemon {
1179
1782
  sequence: seqRow.next,
1180
1783
  prompt: first.body,
1181
1784
  flags: first.flags,
1182
- provider_spec: first.provider_spec,
1183
- child_provider_spec: first.child_provider_spec,
1785
+ model_route_id: first.model_route_id,
1786
+ spawn_model_route_id: first.spawn_model_route_id,
1787
+ reasoning_policy: first.reasoning_policy,
1184
1788
  max_turns: first.max_turns,
1185
1789
  open_paths: first.open_paths ?? "[]",
1186
1790
  orphan_source_loop_id: endedLoopId,