prompt_objects 0.6.0 → 0.7.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 (291) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +126 -0
  3. data/README.md +93 -22
  4. data/exe/poop +5 -0
  5. data/exe/prompt_objects +1 -1108
  6. data/exe/prompt_objects_mcp +5 -3
  7. data/lib/prompt_objects/capability.rb +33 -0
  8. data/lib/prompt_objects/cli/diagnostics_command.rb +69 -0
  9. data/lib/prompt_objects/cli/env_command.rb +435 -0
  10. data/lib/prompt_objects/cli/events_command.rb +145 -0
  11. data/lib/prompt_objects/cli/explore_command.rb +155 -0
  12. data/lib/prompt_objects/cli/helpers.rb +91 -0
  13. data/lib/prompt_objects/cli/message_command.rb +216 -0
  14. data/lib/prompt_objects/cli/repl_command.rb +366 -0
  15. data/lib/prompt_objects/cli/serve_command.rb +162 -0
  16. data/lib/prompt_objects/cli.rb +64 -422
  17. data/lib/prompt_objects/connectors/base.rb +16 -29
  18. data/lib/prompt_objects/connectors/mcp.rb +82 -98
  19. data/lib/prompt_objects/domain/records.rb +45 -0
  20. data/lib/prompt_objects/environment/exporter.rb +5 -4
  21. data/lib/prompt_objects/environment/git.rb +21 -1
  22. data/lib/prompt_objects/environment/importer.rb +5 -4
  23. data/lib/prompt_objects/environment/manager.rb +44 -6
  24. data/lib/prompt_objects/environment/manifest.rb +26 -4
  25. data/lib/prompt_objects/environment.rb +1337 -20
  26. data/lib/prompt_objects/execution/blocking_executor.rb +88 -0
  27. data/lib/prompt_objects/execution/context.rb +137 -0
  28. data/lib/prompt_objects/execution/contracts.rb +59 -0
  29. data/lib/prompt_objects/execution/permit_pool.rb +49 -0
  30. data/lib/prompt_objects/execution/policy.rb +47 -0
  31. data/lib/prompt_objects/execution/resource_lock_manager.rb +63 -0
  32. data/lib/prompt_objects/execution/runner.rb +498 -0
  33. data/lib/prompt_objects/execution/scheduler.rb +325 -0
  34. data/lib/prompt_objects/llm/factory.rb +4 -4
  35. data/lib/prompt_objects/llm/gemini_adapter.rb +25 -8
  36. data/lib/prompt_objects/llm/openai_adapter.rb +9 -1
  37. data/lib/prompt_objects/llm/pricing.rb +9 -2
  38. data/lib/prompt_objects/llm/response.rb +9 -4
  39. data/lib/prompt_objects/loader.rb +28 -1
  40. data/lib/prompt_objects/mcp/server.rb +6 -3
  41. data/lib/prompt_objects/mcp/tools/get_conversation.rb +15 -11
  42. data/lib/prompt_objects/mcp/tools/get_pending_requests.rb +2 -2
  43. data/lib/prompt_objects/mcp/tools/respond_to_request.rb +3 -3
  44. data/lib/prompt_objects/mcp/tools/send_message.rb +42 -32
  45. data/lib/prompt_objects/operations/diagnostics.rb +115 -0
  46. data/lib/prompt_objects/operations/retention.rb +75 -0
  47. data/lib/prompt_objects/operations/run_trace_builder.rb +152 -0
  48. data/lib/prompt_objects/primitives/http_get.rb +2 -0
  49. data/lib/prompt_objects/primitives/list_files.rb +2 -0
  50. data/lib/prompt_objects/primitives/read_file.rb +2 -0
  51. data/lib/prompt_objects/primitives/think.rb +36 -0
  52. data/lib/prompt_objects/primitives/write_file.rb +9 -0
  53. data/lib/prompt_objects/prompt_object.rb +171 -519
  54. data/lib/prompt_objects/repositories/artifact_repository.rb +109 -0
  55. data/lib/prompt_objects/repositories/base_repository.rb +37 -0
  56. data/lib/prompt_objects/repositories/definition_repository.rb +36 -0
  57. data/lib/prompt_objects/repositories/env_data_repository.rb +103 -0
  58. data/lib/prompt_objects/repositories/event_repository.rb +105 -0
  59. data/lib/prompt_objects/repositories/message_repository.rb +73 -0
  60. data/lib/prompt_objects/repositories/run_repository.rb +254 -0
  61. data/lib/prompt_objects/repositories/thread_repository.rb +87 -0
  62. data/lib/prompt_objects/repositories/tool_execution_repository.rb +88 -0
  63. data/lib/prompt_objects/repositories/workspace_session_repository.rb +61 -0
  64. data/lib/prompt_objects/server/api/routes.rb +549 -53
  65. data/lib/prompt_objects/server/app.rb +133 -14
  66. data/lib/prompt_objects/server/file_watcher.rb +9 -0
  67. data/lib/prompt_objects/server/public/assets/index-BjKlODDD.js +90 -0
  68. data/lib/prompt_objects/server/public/assets/index-w3fpIb5c.css +1 -0
  69. data/lib/prompt_objects/server/public/index.html +2 -2
  70. data/lib/prompt_objects/server/websocket_handler.rb +202 -227
  71. data/lib/prompt_objects/server.rb +170 -1
  72. data/lib/prompt_objects/service.rb +129 -0
  73. data/lib/prompt_objects/session/store.rb +600 -13
  74. data/lib/prompt_objects/universal/add_capability.rb +1 -1
  75. data/lib/prompt_objects/universal/create_capability.rb +4 -2
  76. data/lib/prompt_objects/universal/delete_env_data.rb +23 -15
  77. data/lib/prompt_objects/universal/delete_primitive.rb +1 -1
  78. data/lib/prompt_objects/universal/dispatch_po.rb +54 -0
  79. data/lib/prompt_objects/universal/get_env_data.rb +8 -14
  80. data/lib/prompt_objects/universal/get_run_status.rb +30 -0
  81. data/lib/prompt_objects/universal/join_runs.rb +35 -0
  82. data/lib/prompt_objects/universal/list_env_data.rb +10 -14
  83. data/lib/prompt_objects/universal/modify_primitive.rb +1 -1
  84. data/lib/prompt_objects/universal/render_artifact.rb +91 -0
  85. data/lib/prompt_objects/universal/request_primitive.rb +1 -1
  86. data/lib/prompt_objects/universal/{store_env_data.rb → set_env_data.rb} +34 -20
  87. data/lib/prompt_objects/version.rb +5 -0
  88. data/lib/prompt_objects.rb +33 -8
  89. data/objects/coordinator.md +8 -4
  90. data/templates/arc-agi-1/objects/data_manager.md +2 -2
  91. data/templates/arc-agi-1/objects/observer.md +2 -2
  92. data/templates/arc-agi-1/objects/solver.md +6 -5
  93. data/templates/arc-agi-1/objects/verifier.md +2 -2
  94. data/templates/arc-agi-3/manifest.yml +19 -0
  95. data/templates/arc-agi-3/objects/analyst.md +79 -0
  96. data/templates/arc-agi-3/objects/player.md +76 -0
  97. data/templates/arc-agi-3/objects/strategist.md +97 -0
  98. data/templates/arc-agi-3/primitives/action_log.rb +133 -0
  99. data/templates/arc-agi-3/primitives/arc3_api.rb +223 -0
  100. data/templates/arc-agi-3/primitives/arc3_scorecard.rb +131 -0
  101. data/templates/arc-agi-3/primitives/frame_diff.rb +143 -0
  102. data/templates/arc-agi-3/primitives/frame_info.rb +96 -0
  103. data/templates/arc-agi-3/primitives/render_frame.rb +140 -0
  104. data/templates/basic/objects/basic.md +11 -3
  105. data/templates/chiptune/extras/sonic_pi_boot.rb +220 -0
  106. data/templates/chiptune/manifest.yml +20 -0
  107. data/templates/chiptune/objects/conductor.md +184 -0
  108. data/templates/chiptune/objects/sonic_pi.md +65 -0
  109. data/templates/chiptune/objects/voice_bass.md +44 -0
  110. data/templates/chiptune/objects/voice_lead.md +44 -0
  111. data/templates/chiptune/objects/voice_pad.md +43 -0
  112. data/templates/chiptune/objects/voice_perc.md +45 -0
  113. data/templates/chiptune/primitives/play_note.rb +59 -0
  114. data/templates/chiptune/primitives/play_sequence.rb +55 -0
  115. data/templates/chiptune/primitives/query_sonic_pi.rb +141 -0
  116. data/templates/chiptune/primitives/set_synth.rb +57 -0
  117. data/templates/chiptune/primitives/set_tempo.rb +45 -0
  118. data/templates/chiptune/primitives/stop_voice.rb +46 -0
  119. data/templates/chiptune/primitives/support/osc_bridge.rb +33 -0
  120. data/templates/chiptune/vendor/osc-ruby-1.1.5/LICENSE +19 -0
  121. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/address_pattern.rb +58 -0
  122. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/broadcast_client.rb +24 -0
  123. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/bundle.rb +46 -0
  124. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/client.rb +13 -0
  125. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/core_ext/time.rb +6 -0
  126. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/em_server.rb +63 -0
  127. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/message.rb +46 -0
  128. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/network_packet.rb +42 -0
  129. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_argument.rb +26 -0
  130. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_packet.rb +140 -0
  131. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_types.rb +53 -0
  132. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/server.rb +91 -0
  133. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby.rb +18 -0
  134. data/templates/developer/objects/code_reviewer.md +1 -1
  135. data/templates/developer/objects/coordinator.md +16 -3
  136. data/templates/developer/objects/debugger.md +3 -3
  137. data/templates/empty/objects/assistant.md +10 -5
  138. data/templates/minimal/objects/assistant.md +10 -5
  139. data/templates/music/extras/sonic_pi_boot.rb +220 -0
  140. data/templates/music/manifest.yml +16 -0
  141. data/templates/music/objects/conductor.md +154 -0
  142. data/templates/music/objects/sonic_pi.md +65 -0
  143. data/templates/music/primitives/play_note.rb +55 -0
  144. data/templates/music/primitives/play_sequence.rb +51 -0
  145. data/templates/music/primitives/query_sonic_pi.rb +139 -0
  146. data/templates/music/primitives/set_synth.rb +53 -0
  147. data/templates/music/primitives/set_tempo.rb +43 -0
  148. data/templates/music/primitives/stop_voice.rb +44 -0
  149. data/templates/music/primitives/support/osc_bridge.rb +33 -0
  150. data/templates/music/vendor/osc-ruby-1.1.5/LICENSE +19 -0
  151. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/address_pattern.rb +58 -0
  152. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/broadcast_client.rb +24 -0
  153. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/bundle.rb +46 -0
  154. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/client.rb +13 -0
  155. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/core_ext/time.rb +6 -0
  156. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/em_server.rb +63 -0
  157. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/message.rb +46 -0
  158. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/network_packet.rb +42 -0
  159. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_argument.rb +26 -0
  160. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_packet.rb +140 -0
  161. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_types.rb +53 -0
  162. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/server.rb +91 -0
  163. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby.rb +18 -0
  164. data/templates/music-beginner/README.md +100 -0
  165. data/templates/music-beginner/extras/README.md +27 -0
  166. data/templates/music-beginner/extras/sonic_pi_boot.rb +297 -0
  167. data/templates/music-beginner/manifest.yml +25 -0
  168. data/templates/music-beginner/objects/conductor.md +222 -0
  169. data/templates/music-beginner/objects/engine_setup.md +54 -0
  170. data/templates/music-beginner/objects/voice_bass.md +43 -0
  171. data/templates/music-beginner/objects/voice_keys.md +48 -0
  172. data/templates/music-beginner/objects/voice_pad.md +46 -0
  173. data/templates/music-beginner/objects/voice_percussion.md +45 -0
  174. data/templates/music-beginner/objects/voice_texture.md +37 -0
  175. data/templates/music-beginner/primitives/play_pattern.rb +118 -0
  176. data/templates/music-beginner/primitives/query_music_engine.rb +94 -0
  177. data/templates/music-beginner/primitives/set_instrument.rb +47 -0
  178. data/templates/music-beginner/primitives/set_tempo.rb +43 -0
  179. data/templates/music-beginner/primitives/set_voice_level.rb +46 -0
  180. data/templates/music-beginner/primitives/stop_voice.rb +37 -0
  181. data/templates/music-beginner/primitives/support/osc_bridge.rb +25 -0
  182. data/templates/music-beginner/vendor/osc-ruby-1.1.5/LICENSE +19 -0
  183. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/address_pattern.rb +58 -0
  184. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/broadcast_client.rb +24 -0
  185. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/bundle.rb +46 -0
  186. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/client.rb +13 -0
  187. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/core_ext/time.rb +6 -0
  188. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/em_server.rb +63 -0
  189. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/message.rb +46 -0
  190. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/network_packet.rb +42 -0
  191. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_argument.rb +26 -0
  192. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_packet.rb +140 -0
  193. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_types.rb +53 -0
  194. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/server.rb +91 -0
  195. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby.rb +18 -0
  196. data/templates/music-pro/extras/AUDITION.md +100 -0
  197. data/templates/music-pro/extras/sonic_pi_boot.rb +546 -0
  198. data/templates/music-pro/manifest.yml +19 -0
  199. data/templates/music-pro/objects/conductor.md +126 -0
  200. data/templates/music-pro/objects/sonic_pi.md +35 -0
  201. data/templates/music-pro/objects/voice_harmony.md +48 -0
  202. data/templates/music-pro/objects/voice_rhythm.md +43 -0
  203. data/templates/music-pro/objects/voice_texture.md +42 -0
  204. data/templates/music-pro/primitives/define_patch.rb +46 -0
  205. data/templates/music-pro/primitives/query_music_engine.rb +69 -0
  206. data/templates/music-pro/primitives/set_live_pattern.rb +70 -0
  207. data/templates/music-pro/primitives/set_tempo.rb +31 -0
  208. data/templates/music-pro/primitives/stop_voice.rb +31 -0
  209. data/templates/music-pro/primitives/support/music_protocol.rb +136 -0
  210. data/templates/music-pro/primitives/support/osc_bridge.rb +33 -0
  211. data/templates/music-pro/primitives/trigger_event.rb +44 -0
  212. data/templates/music-pro/vendor/osc-ruby-1.1.5/LICENSE +19 -0
  213. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/address_pattern.rb +58 -0
  214. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/broadcast_client.rb +24 -0
  215. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/bundle.rb +46 -0
  216. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/client.rb +13 -0
  217. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/core_ext/time.rb +6 -0
  218. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/em_server.rb +63 -0
  219. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/message.rb +46 -0
  220. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/network_packet.rb +42 -0
  221. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_argument.rb +26 -0
  222. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_packet.rb +140 -0
  223. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_types.rb +53 -0
  224. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/server.rb +91 -0
  225. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby.rb +18 -0
  226. data/templates/writer/objects/coordinator.md +14 -3
  227. data/templates/writer/objects/editor.md +1 -1
  228. data/templates/writer/objects/researcher.md +2 -2
  229. data/tools/thread-explorer.html +1 -1
  230. metadata +172 -65
  231. data/CLAUDE.md +0 -177
  232. data/Gemfile +0 -10
  233. data/Rakefile +0 -27
  234. data/frontend/.gitignore +0 -3
  235. data/frontend/index.html +0 -17
  236. data/frontend/package-lock.json +0 -4540
  237. data/frontend/package.json +0 -36
  238. data/frontend/postcss.config.js +0 -6
  239. data/frontend/src/App.tsx +0 -104
  240. data/frontend/src/canvas/CanvasView.tsx +0 -113
  241. data/frontend/src/canvas/ForceLayout.ts +0 -115
  242. data/frontend/src/canvas/SceneManager.ts +0 -587
  243. data/frontend/src/canvas/canvasStore.ts +0 -47
  244. data/frontend/src/canvas/constants.ts +0 -95
  245. data/frontend/src/canvas/controls/CameraControls.ts +0 -98
  246. data/frontend/src/canvas/edges/MessageArc.ts +0 -149
  247. data/frontend/src/canvas/inspector/InspectorPanel.tsx +0 -31
  248. data/frontend/src/canvas/inspector/POInspector.tsx +0 -262
  249. data/frontend/src/canvas/inspector/ToolCallInspector.tsx +0 -67
  250. data/frontend/src/canvas/nodes/PONode.ts +0 -249
  251. data/frontend/src/canvas/nodes/ToolCallNode.ts +0 -116
  252. data/frontend/src/canvas/types.ts +0 -24
  253. data/frontend/src/components/ContextMenu.tsx +0 -68
  254. data/frontend/src/components/EnvDataPane.tsx +0 -69
  255. data/frontend/src/components/Inspector.tsx +0 -263
  256. data/frontend/src/components/MarkdownMessage.tsx +0 -155
  257. data/frontend/src/components/MethodList.tsx +0 -90
  258. data/frontend/src/components/ModelSelector.tsx +0 -115
  259. data/frontend/src/components/NotificationPanel.tsx +0 -130
  260. data/frontend/src/components/ObjectList.tsx +0 -78
  261. data/frontend/src/components/PaneSlot.tsx +0 -30
  262. data/frontend/src/components/SourcePane.tsx +0 -202
  263. data/frontend/src/components/SystemBar.tsx +0 -74
  264. data/frontend/src/components/Transcript.tsx +0 -76
  265. data/frontend/src/components/UsagePanel.tsx +0 -105
  266. data/frontend/src/components/Workspace.tsx +0 -260
  267. data/frontend/src/components/index.ts +0 -12
  268. data/frontend/src/hooks/useResize.ts +0 -55
  269. data/frontend/src/hooks/useWebSocket.ts +0 -559
  270. data/frontend/src/index.css +0 -102
  271. data/frontend/src/main.tsx +0 -10
  272. data/frontend/src/store/index.ts +0 -311
  273. data/frontend/src/types/index.ts +0 -176
  274. data/frontend/tailwind.config.js +0 -44
  275. data/frontend/tsconfig.json +0 -30
  276. data/frontend/vite.config.ts +0 -29
  277. data/lib/prompt_objects/server/public/assets/index-DEPawnfZ.js +0 -4345
  278. data/lib/prompt_objects/server/public/assets/index-oMrRce1m.css +0 -1
  279. data/lib/prompt_objects/universal/add_primitive.rb +0 -118
  280. data/lib/prompt_objects/universal/ask_human.rb +0 -109
  281. data/lib/prompt_objects/universal/create_primitive.rb +0 -174
  282. data/lib/prompt_objects/universal/list_primitives.rb +0 -145
  283. data/lib/prompt_objects/universal/think.rb +0 -41
  284. data/lib/prompt_objects/universal/update_env_data.rb +0 -88
  285. data/prompt_objects.gemspec +0 -50
  286. data/templates/basic/.gitignore +0 -2
  287. data/templates/developer/.gitignore +0 -5
  288. data/templates/empty/.gitignore +0 -5
  289. data/templates/empty/objects/.gitkeep +0 -0
  290. data/templates/minimal/.gitignore +0 -5
  291. data/templates/writer/.gitignore +0 -5
@@ -16,6 +16,19 @@ module PromptObjects
16
16
  @tui_mode = false
17
17
  end
18
18
 
19
+ # Resolve the workspace scope for shared environment data.
20
+ # @return [String, nil] Scope id, or nil if it can't be resolved
21
+ def env_data_scope
22
+ store = env.session_store
23
+ return nil unless store
24
+
25
+ if env.respond_to?(:env_data_scope)
26
+ env.env_data_scope
27
+ elsif env.respond_to?(:active_session_id) && env.active_session_id
28
+ env.active_session_id
29
+ end
30
+ end
31
+
19
32
  # Log a message to the bus.
20
33
  # @param to [String] Destination capability
21
34
  # @param message [String, Hash] The message
@@ -34,15 +47,31 @@ module PromptObjects
34
47
  # The runtime environment that holds all capabilities and coordinates execution.
35
48
  # Can be initialized either from:
36
49
  # - A user environment directory (with manifest.yml, git integration)
37
- # - A simple objects directory (legacy/development mode)
50
+ # - A simple objects directory backed by an in-memory runtime store
38
51
  class Runtime
52
+ DEFAULT_SCHEDULER_OPTIONS = {
53
+ worker_limit: 4,
54
+ provider_limit: 4,
55
+ tool_limit: 8,
56
+ cpu_limit: 2,
57
+ max_active_runs: 64,
58
+ max_active_per_po: 4,
59
+ max_queued_per_thread: 20,
60
+ max_delegation_depth: 8,
61
+ max_descendants: 64,
62
+ run_event_retention: Operations::Retention::DEFAULT_RUN_EVENT_LIMIT,
63
+ workspace_event_retention: Operations::Retention::DEFAULT_WORKSPACE_EVENT_LIMIT
64
+ }.freeze
65
+
39
66
  attr_reader :llm, :registry, :objects_dir, :bus, :primitives_dir, :human_queue,
40
67
  :manifest, :env_path, :auto_commit, :session_store,
41
- :current_provider, :current_model
68
+ :current_provider, :current_model, :services, :connectors, :active_session_id,
69
+ :runtime_model
42
70
  attr_accessor :on_po_registered # Callback for when a PO is registered
43
71
  attr_accessor :on_po_modified # Callback for when a PO is modified (capabilities changed, etc.)
44
72
  attr_accessor :on_delegation_event # Callback for PO-to-PO delegation start/complete
45
73
  attr_accessor :on_env_data_changed # Callback for env data store/update/delete
74
+ attr_accessor :on_broadcast # Callback for services to push messages to all WebSocket clients
46
75
 
47
76
  # Initialize from an environment path (with manifest) or objects directory.
48
77
  # @param env_path [String, nil] Path to environment directory (preferred)
@@ -52,12 +81,15 @@ module PromptObjects
52
81
  # @param provider [String, nil] LLM provider (openai, anthropic, gemini)
53
82
  # @param model [String, nil] Model name
54
83
  # @param auto_commit [Boolean] Auto-commit changes to git (default: true for env_path)
55
- def initialize(env_path: nil, objects_dir: nil, primitives_dir: nil, llm: nil, provider: nil, model: nil, auto_commit: nil)
84
+ def initialize(env_path: nil, objects_dir: nil, primitives_dir: nil, llm: nil, provider: nil, model: nil,
85
+ auto_commit: nil, runtime_model: nil, scheduler_options: nil)
56
86
  if env_path
57
87
  # Environment-based initialization
58
88
  @env_path = env_path
59
89
  @objects_dir = File.join(env_path, "objects")
60
90
  @primitives_dir = primitives_dir || File.join(env_path, "primitives")
91
+ @services_dir = File.join(env_path, "services")
92
+ @connectors_dir = File.join(env_path, "connectors")
61
93
  @manifest = Env::Manifest.load_from_dir(env_path)
62
94
  @auto_commit = auto_commit.nil? ? true : auto_commit
63
95
  @manifest.touch_opened!
@@ -71,10 +103,20 @@ module PromptObjects
71
103
  @env_path = nil
72
104
  @objects_dir = objects_dir || "objects"
73
105
  @primitives_dir = primitives_dir || File.join(File.dirname(@objects_dir), "primitives")
106
+ @services_dir = nil
107
+ @connectors_dir = nil
74
108
  @manifest = nil
75
109
  @auto_commit = auto_commit || false
76
- @session_store = nil # No persistent sessions in legacy mode
110
+ @session_store = Session::Store.new(":memory:")
111
+ end
112
+
113
+ @runtime_model = "runs"
114
+ if @manifest && @manifest.runtime_model != "runs"
115
+ @manifest.runtime_model = "runs"
116
+ @manifest.save_to_dir(@env_path)
77
117
  end
118
+ @scheduler_options = DEFAULT_SCHEDULER_OPTIONS.merge(scheduler_options || {})
119
+ @child_admission_mutex = Mutex.new
78
120
 
79
121
  # Initialize LLM - prefer explicit llm, then use factory with provider/model
80
122
  if llm
@@ -82,17 +124,43 @@ module PromptObjects
82
124
  @current_provider = nil
83
125
  @current_model = nil
84
126
  else
85
- @current_provider = provider || LLM::Factory::DEFAULT_PROVIDER
86
- @current_model = model || LLM::Factory.default_model(@current_provider)
127
+ manifest_llm = @manifest&.llm || {}
128
+ @current_provider = provider || manifest_llm["provider"] || LLM::Factory::DEFAULT_PROVIDER
129
+ manifest_model = manifest_llm["model"] unless provider
130
+ @current_model = model || manifest_model || LLM::Factory.default_model(@current_provider)
87
131
  @llm = LLM::Factory.create(provider: @current_provider, model: @current_model)
88
132
  end
89
133
 
90
134
  @registry = Registry.new
91
135
  @bus = MessageBus.new(session_store: @session_store)
92
136
  @human_queue = HumanQueue.new
137
+ # In-memory boot-cycle activity. Autonomous POs can opt to stay dormant
138
+ # until a human has initialized them during this server run; persisted
139
+ # sessions from a previous run intentionally do not arm the timer.
140
+ @human_message_received = {}
141
+ @autonomous_schedule_states = {}
142
+ @autonomous_schedule_mutex = Mutex.new
143
+
144
+ # Paths the app just wrote itself (via PO#save / #update_source). The
145
+ # FileWatcher consults this so an in-app save doesn't trigger a redundant,
146
+ # disruptive reload of a PO that's mid-conversation — the in-memory PO is
147
+ # already up to date. Manual/external edits aren't registered, so they
148
+ # still hot-reload normally.
149
+ @suppressed_reloads = {}
150
+ @suppressed_reloads_mutex = Mutex.new
151
+
152
+ validate_dependencies if @manifest&.dependencies&.any?
93
153
 
94
154
  register_primitives
95
155
  register_universal_capabilities
156
+
157
+ @services = []
158
+ load_custom_services
159
+
160
+ @connectors = []
161
+ load_custom_connectors
162
+
163
+ recover_interrupted_runs
96
164
  end
97
165
 
98
166
  # Create runtime from a user environment by name.
@@ -112,7 +180,7 @@ module PromptObjects
112
180
  @manifest&.name || File.basename(@objects_dir)
113
181
  end
114
182
 
115
- # Check if this is an environment-based runtime (vs legacy objects_dir).
183
+ # Check if this runtime has a persisted environment directory.
116
184
  # @return [Boolean]
117
185
  def environment?
118
186
  !@env_path.nil?
@@ -140,15 +208,24 @@ module PromptObjects
140
208
  # @param model [String, nil] Model name (defaults to provider's default)
141
209
  # @return [Hash] New provider/model info
142
210
  def switch_llm(provider:, model: nil)
143
- @current_provider = provider
144
- @current_model = model || LLM::Factory.default_model(provider)
145
- @llm = LLM::Factory.create(provider: @current_provider, model: @current_model)
211
+ new_provider = provider
212
+ new_model = model || LLM::Factory.default_model(provider)
213
+ new_llm = LLM::Factory.create(provider: new_provider, model: new_model)
214
+
215
+ @current_provider = new_provider
216
+ @current_model = new_model
217
+ @llm = new_llm
146
218
 
147
219
  # Update all loaded POs to use the new LLM
148
220
  @registry.prompt_objects.each do |po|
149
221
  po.instance_variable_set(:@llm, @llm)
150
222
  end
151
223
 
224
+ if @manifest
225
+ @manifest.llm = { "provider" => @current_provider, "model" => @current_model }
226
+ @manifest.save_to_dir(@env_path)
227
+ end
228
+
152
229
  { provider: @current_provider, model: @current_model }
153
230
  end
154
231
 
@@ -217,6 +294,27 @@ module PromptObjects
217
294
  @on_po_modified&.call(po)
218
295
  end
219
296
 
297
+ # Mark a file path as just-written-by-the-app, so the FileWatcher skips the
298
+ # next reload event for it. Idempotent; absolute paths recommended.
299
+ # @param path [String]
300
+ def suppress_next_reload(path)
301
+ return unless path
302
+
303
+ key = File.expand_path(path)
304
+ @suppressed_reloads_mutex.synchronize { @suppressed_reloads[key] = true }
305
+ end
306
+
307
+ # Whether a pending in-app save should suppress the next reload of this path.
308
+ # Consumes the suppression (returns true at most once per registration).
309
+ # @param path [String]
310
+ # @return [Boolean]
311
+ def reload_suppressed?(path)
312
+ return false unless path
313
+
314
+ key = File.expand_path(path)
315
+ @suppressed_reloads_mutex.synchronize { !!@suppressed_reloads.delete(key) }
316
+ end
317
+
220
318
  # Notify that a PO-to-PO delegation has started or completed.
221
319
  # @param event_type [Symbol] :started or :completed
222
320
  # @param payload [Hash] { target:, caller:, thread_id:, tool_call_id: }
@@ -277,22 +375,1162 @@ module PromptObjects
277
375
  @registry.primitives.map(&:name)
278
376
  end
279
377
 
378
+ # --- Environment Session API ---
379
+
380
+ # Get or create the active environment session.
381
+ # @param initiated_by [String] Who initiated the session (default: "human")
382
+ # @return [Hash] Session data
383
+ def active_session(initiated_by: "human")
384
+ @active_session_id ||= find_or_create_environment_session(initiated_by: initiated_by)
385
+ @session_store&.get_session(@active_session_id)
386
+ end
387
+
388
+ # Resolve the env_data scope id for a given session, preferring the
389
+ # environment-level session (v8). Used by server-side readers (REST API,
390
+ # WebSocket, broadcasts) so they read from the same scope writers use.
391
+ # @param session_id [String, nil] A session to fall back to if no env session
392
+ # @return [String, nil] Scope id
393
+ def env_data_scope(session_id = nil)
394
+ return @active_session_id if @active_session_id
395
+ return nil unless @session_store && session_id
396
+
397
+ @session_store.resolve_root_thread(session_id)
398
+ end
399
+
400
+ # Create a new environment session, making it the active one.
401
+ # @param name [String, nil] Optional session name
402
+ # @param initiated_by [String] Who initiated (human, timer, api, etc.)
403
+ # @return [Hash] Session data
404
+ def create_environment_session(name: nil, initiated_by: "human")
405
+ return nil unless @session_store
406
+
407
+ id = SecureRandom.uuid
408
+ workspace_repository.create(id: id, name: name)
409
+ id = @session_store.create_session(
410
+ id: id,
411
+ po_name: nil,
412
+ name: name,
413
+ source: "web",
414
+ thread_type: "root",
415
+ workspace_session_id: id
416
+ )
417
+ @active_session_id = id
418
+ @session_store.get_session(id)
419
+ end
420
+
421
+ # List the environment-level sessions (the shared-context "jams") with a
422
+ # flag marking which one is active. Side-effect-free: reads the already-
423
+ # resolved @active_session_id rather than calling active_session (which
424
+ # would create a session on read), and queries the store once.
425
+ # @return [Array<Hash>]
426
+ def environment_sessions
427
+ return [] unless @session_store
428
+
429
+ active = @active_session_id
430
+ @session_store.list_environment_sessions.map do |s|
431
+ s.merge(active: s[:id] == active)
432
+ end
433
+ end
434
+
435
+ # Switch the active environment session (changes the shared env_data scope).
436
+ # @param session_id [String]
437
+ # @return [Hash, nil] The now-active session, or nil if not found
438
+ def switch_active_session(session_id)
439
+ return nil unless @session_store
440
+
441
+ session = @session_store.get_session(session_id)
442
+ return nil unless session
443
+
444
+ @active_session_id = session_id
445
+ session
446
+ end
447
+
448
+ # Rename an environment session.
449
+ # @return [Hash, nil] The updated session
450
+ def rename_session(session_id, name)
451
+ return nil unless @session_store && @session_store.get_session(session_id)
452
+
453
+ @session_store.update_session(session_id, name: name)
454
+ @session_store.get_session(session_id)
455
+ end
456
+
457
+ # Delete an environment session (and its messages). If it was active, the
458
+ # next active_session call will find or create another.
459
+ # @return [Boolean]
460
+ def delete_environment_session(session_id)
461
+ return false unless @session_store && @session_store.get_session(session_id)
462
+
463
+ @session_store.delete_session(session_id)
464
+ @active_session_id = nil if @active_session_id == session_id
465
+ true
466
+ end
467
+
468
+ # Inject a message through the run scheduler. All sources share this entry
469
+ # point and differ only in their provenance and source-specific thread.
470
+ #
471
+ # @param target_po [String] Name of the PO to receive the message
472
+ # @param content [String] Message content
473
+ # @param from_source [String] Message source (human, timer:name, service:name, api)
474
+ # @return [String] The PO's response
475
+ def inject_message(target_po:, content:, from_source: "human",
476
+ workspace_session_id: nil, thread_id: nil, client_request_id: nil, deadline: nil)
477
+ po = @registry.get(target_po)
478
+ raise Error, "Unknown prompt object: #{target_po}" unless po.is_a?(PromptObject)
479
+
480
+ inject_run_message(
481
+ po: po,
482
+ content: content,
483
+ from_source: from_source,
484
+ workspace_session_id: workspace_session_id,
485
+ thread_id: thread_id,
486
+ client_request_id: client_request_id,
487
+ deadline: deadline
488
+ )
489
+ end
490
+
491
+ def enqueue_run(target_po:, content:, from_source: "human", workspace_session_id: nil,
492
+ thread_id: nil, client_request_id: nil, deadline: nil)
493
+ po = @registry.get(target_po)
494
+ raise Error, "Unknown prompt object: #{target_po}" unless po.is_a?(PromptObject)
495
+
496
+ workspace_id = resolve_workspace_id(workspace_session_id, initiated_by: from_source)
497
+ thread = resolve_run_thread(
498
+ workspace_id: workspace_id,
499
+ po: po,
500
+ from_source: from_source,
501
+ thread_id: thread_id
502
+ )
503
+ # enqueue_run is the canonical entry point used by WebSocket, REST, CLI,
504
+ # connectors, and inject_message. Arm wait-for-human schedules here so a
505
+ # valid human run wakes autonomous POs regardless of which surface sent it.
506
+ @human_message_received[target_po] = true if from_source == "human"
507
+ definition = execution_definition_for(po)
508
+ source_type = source_type_for(from_source)
509
+ if source_type == "autonomous"
510
+ active = run_repository.active_for_source(
511
+ workspace_session_id: workspace_id,
512
+ po_name: po.name,
513
+ source_type: source_type,
514
+ source_name: from_source
515
+ )
516
+ return active if active
517
+ end
518
+ normalized_deadline = normalize_deadline(deadline)
519
+ run, created = run_repository.enqueue_with_result(
520
+ workspace_session_id: workspace_id,
521
+ thread_id: thread.id,
522
+ po_name: po.name,
523
+ input_content: content,
524
+ definition_version: definition.version,
525
+ provider: definition.provider,
526
+ model: definition.model,
527
+ capability_policy_version: definition.capability_policy_version,
528
+ source_type: source_type,
529
+ source_name: from_source,
530
+ client_request_id: client_request_id,
531
+ priority: priority_for(from_source),
532
+ metadata: { deadline: normalized_deadline&.iso8601(6) }.compact
533
+ )
534
+ definition_repository.attach(run_id: run.id, definition: definition) unless definition_repository.get(run.id)
535
+ if created
536
+ broadcast_workspace_snapshot(workspace_id)
537
+ publish_run_event(event_repository.append_run_event(
538
+ run_id: run.id,
539
+ kind: "run_queued",
540
+ data: { source_type: run.source_type, turn_ordinal: run.turn_ordinal }
541
+ ))
542
+ broadcast_po_runtime_status(run)
543
+ end
544
+ run
545
+ end
546
+
547
+ def execution_definition_for(po)
548
+ po.execution_definition(
549
+ provider: current_provider,
550
+ model: current_model,
551
+ capability_policy_version: capability_policy_version_for_config(po.config)
552
+ )
553
+ end
554
+
555
+ def capability_policy_version_for_config(config)
556
+ names = Array(config["capabilities"] || config[:capabilities]) + UNIVERSAL_CAPABILITIES
557
+ signatures = names.uniq.sort.map do |name|
558
+ capability = registry.get(name)
559
+ [name, capability&.execution_policy_signature || "missing"]
560
+ end
561
+ Digest::SHA256.hexdigest(JSON.generate(signatures))
562
+ end
563
+
564
+ def execute_run(run_id, tui_mode: true)
565
+ run = run_repository.get(run_id)
566
+ raise Error, "Unknown run: #{run_id}" unless run
567
+ return run.final_response if run.status == "completed"
568
+ return :queued unless run.status == "queued"
569
+
570
+ run_scheduler.submit(run, deadline: run_deadline(run), tui_mode: tui_mode).wait
571
+ rescue Execution::Scheduler::RejectedError => e
572
+ reject_queued_run(run, e.message)
573
+ raise Error, e.message
574
+ end
575
+
576
+ def execute_message_run(target_po:, content:, from_source:, workspace_session_id: nil,
577
+ thread_id: nil, client_request_id: nil, deadline: nil, tui_mode: false)
578
+ run = enqueue_run(
579
+ target_po: target_po,
580
+ content: content,
581
+ from_source: from_source,
582
+ workspace_session_id: workspace_session_id,
583
+ thread_id: thread_id,
584
+ client_request_id: client_request_id,
585
+ deadline: deadline
586
+ )
587
+ response = execute_run(run.id, tui_mode: tui_mode)
588
+ [run_repository.get(run.id), response]
589
+ end
590
+
591
+ def cancel_run(run_id, reason: "cancelled")
592
+ run = run_repository.get(run_id)
593
+ return false unless run
594
+
595
+ cancelled = @run_scheduler&.cancel(run_id, reason: reason)
596
+ unless cancelled
597
+ return false unless run.status == "queued"
598
+
599
+ cancel_queued_run(run, reason)
600
+ cancelled = true
601
+ end
602
+ run_repository.active_children(parent_run_id: run_id).each do |child|
603
+ next if metadata_value(child, :child_lifetime) == "detached"
604
+
605
+ cancel_run(child.id, reason: "parent cancelled: #{reason}")
606
+ end
607
+ cancelled
608
+ end
609
+
610
+ def schedule_run(run_id, tui_mode: true)
611
+ run = run_repository.get(run_id)
612
+ raise Error, "Unknown run: #{run_id}" unless run
613
+ return nil unless run.status == "queued"
614
+
615
+ run_scheduler.submit(run, deadline: run_deadline(run), tui_mode: tui_mode)
616
+ rescue Execution::Scheduler::RejectedError => e
617
+ reject_queued_run(run, e.message)
618
+ raise Error, e.message
619
+ end
620
+
621
+ def with_child_run_admission(parent)
622
+ @child_admission_mutex.synchronize do
623
+ depth = run_depth(parent) + 1
624
+ raise Error, "Maximum delegation depth exceeded" if depth > @scheduler_options.fetch(:max_delegation_depth)
625
+ if run_repository.descendant_count(root_run_id: parent.root_run_id) >= @scheduler_options.fetch(:max_descendants)
626
+ raise Error, "Maximum run descendant count exceeded"
627
+ end
628
+
629
+ yield depth
630
+ end
631
+ end
632
+
633
+ def dispatch_run(target_po:, content:, context:, thread_id: nil, lifetime: "attached", on_complete: nil)
634
+ raise Error, "Dispatch requires a run context" unless context.run_id
635
+ raise Error, "lifetime must be attached or detached" unless %w[attached detached].include?(lifetime)
636
+
637
+ parent = run_repository.get(context.run_id)
638
+ raise Error, "Unknown parent run: #{context.run_id}" unless parent
639
+ validate_dispatch_target!(parent.po_name, target_po)
640
+ target = registry.get(target_po)
641
+ definition = execution_definition_for(target)
642
+ thread = nil
643
+ child = nil
644
+ with_child_run_admission(parent) do |depth|
645
+ thread = if thread_id
646
+ required_dispatch_thread(thread_id, parent, target_po)
647
+ else
648
+ thread_repository.create(
649
+ workspace_session_id: parent.workspace_session_id,
650
+ po_name: target_po,
651
+ thread_type: "delegation",
652
+ parent_thread_id: parent.thread_id,
653
+ parent_run_id: parent.id,
654
+ retention_policy: "full"
655
+ )
656
+ end
657
+ child = run_repository.enqueue(
658
+ workspace_session_id: parent.workspace_session_id,
659
+ thread_id: thread.id,
660
+ po_name: target_po,
661
+ input_content: content,
662
+ definition_version: definition.version,
663
+ provider: definition.provider,
664
+ model: definition.model,
665
+ capability_policy_version: definition.capability_policy_version,
666
+ source_type: "delegation",
667
+ source_name: parent.po_name,
668
+ parent_run_id: parent.id,
669
+ priority: 80,
670
+ metadata: {
671
+ dispatch_mode: "dispatch",
672
+ child_lifetime: lifetime,
673
+ delegation_depth: depth,
674
+ deadline: context.deadline&.iso8601(6),
675
+ on_complete: on_complete
676
+ }
677
+ )
678
+ end
679
+ definition_repository.attach(run_id: child.id, definition: definition)
680
+ publish_workspace_snapshot(parent.workspace_session_id)
681
+ publish_run_event(event_repository.append_run_event(
682
+ run_id: child.id,
683
+ kind: "run_queued",
684
+ data: { source_type: child.source_type, turn_ordinal: child.turn_ordinal, mode: "dispatch" }
685
+ ))
686
+ publish_run_event(event_repository.append_run_event(
687
+ run_id: parent.id,
688
+ kind: "delegation_started",
689
+ data: {
690
+ child_run_id: child.id,
691
+ child_thread_id: thread.id,
692
+ target_po: target_po,
693
+ mode: "dispatch",
694
+ lifetime: lifetime
695
+ }
696
+ ))
697
+ schedule_run(child.id, tui_mode: context.tui_mode)
698
+ { run_id: child.id, thread_id: thread.id, status: child.status, lifetime: lifetime }
699
+ end
700
+
701
+ def join_runs(run_ids, context:, timeout: nil)
702
+ Array(run_ids).map { |run_id| join_run(run_id, context: context, timeout: timeout) }
703
+ end
704
+
705
+ def run_status(run_id, context:)
706
+ run = authorized_related_run(run_id, context)
707
+ serialize_runtime_value(run.to_h)
708
+ end
709
+
710
+ def join_run(run_id, context:, timeout: nil)
711
+ run = authorized_related_run(run_id, context)
712
+ unless terminal_run_status?(run.status)
713
+ ticket = run_scheduler.ticket(run.id)
714
+ ticket ||= schedule_run(run.id, tui_mode: context.tui_mode)
715
+ ticket = run_scheduler.help(run.id) || ticket
716
+ ticket&.wait(timeout: timeout)
717
+ run = run_repository.get(run.id)
718
+ end
719
+ {
720
+ run_id: run.id,
721
+ thread_id: run.thread_id,
722
+ status: run.status,
723
+ response: run.final_response,
724
+ error: run.error_message
725
+ }
726
+ rescue Timeout::Error
727
+ { run_id: run_id, status: "waiting", error: "join timed out" }
728
+ end
729
+
730
+ def shutdown_execution(wait: true)
731
+ @run_scheduler&.shutdown(wait: wait)
732
+ @run_scheduler = nil
733
+ end
734
+
735
+ def operational_diagnostics
736
+ Operations::Diagnostics.new(@session_store).snapshot(
737
+ scheduler: {
738
+ queued: @run_scheduler&.queued_count || 0,
739
+ running: @run_scheduler&.running_count || 0,
740
+ provider_permits_available: provider_permits.available,
741
+ tool_permits_available: tool_permits.available,
742
+ cpu_permits_available: cpu_permits.available,
743
+ locked_resource_count: resource_lock_manager.locked_keys.length
744
+ },
745
+ limits: @scheduler_options.slice(
746
+ :worker_limit, :provider_limit, :tool_limit, :cpu_limit, :max_active_runs,
747
+ :max_active_per_po, :max_queued_per_thread, :max_delegation_depth, :max_descendants,
748
+ :run_event_retention, :workspace_event_retention
749
+ )
750
+ )
751
+ end
752
+
753
+ def enforce_retention
754
+ Operations::Retention.new(@session_store).prune_events!(
755
+ run_event_limit: @scheduler_options.fetch(:run_event_retention),
756
+ workspace_event_limit: @scheduler_options.fetch(:workspace_event_retention)
757
+ )
758
+ end
759
+
760
+ def create_runtime_thread(workspace_session_id:, po_name:, thread_type: "human", name: nil,
761
+ parent_thread_id: nil, parent_run_id: nil)
762
+ workspace_id = resolve_workspace_id(workspace_session_id, initiated_by: "human")
763
+ po = @registry.get(po_name)
764
+ raise Error, "Unknown prompt object: #{po_name}" unless po.is_a?(PromptObject)
765
+
766
+ thread = thread_repository.create(
767
+ workspace_session_id: workspace_id,
768
+ po_name: po_name,
769
+ thread_type: thread_type,
770
+ name: name,
771
+ parent_thread_id: parent_thread_id,
772
+ parent_run_id: parent_run_id,
773
+ retention_policy: thread_type == "autonomous" ? "summary" : "full"
774
+ )
775
+ broadcast_workspace_snapshot(workspace_id)
776
+ broadcast(**thread_snapshot_envelope(thread))
777
+ thread
778
+ end
779
+
780
+ def rename_runtime_thread(thread_id, name:)
781
+ thread = required_runtime_thread(thread_id)
782
+ renamed = thread_repository.rename(thread.id, name)
783
+ broadcast_workspace_snapshot(thread.workspace_session_id)
784
+ renamed
785
+ end
786
+
787
+ def archive_runtime_thread(thread_id)
788
+ thread = required_runtime_thread(thread_id)
789
+ archived = thread_repository.archive(thread.id)
790
+ broadcast_workspace_snapshot(thread.workspace_session_id)
791
+ archived
792
+ end
793
+
794
+ def runtime_snapshot_envelopes(workspace_session_id: nil)
795
+ workspace_id = resolve_workspace_id(workspace_session_id, initiated_by: "reconnect")
796
+ threads = thread_repository.list(workspace_session_id: workspace_id, include_archived: true)
797
+ active_runs = run_repository.list(
798
+ workspace_session_id: workspace_id,
799
+ statuses: %w[queued running waiting],
800
+ limit: 500
801
+ )
802
+ events = active_runs.flat_map do |run|
803
+ event_repository.run_events(run_id: run.id).map do |event|
804
+ {
805
+ version: Execution::Contracts::PROTOCOL_VERSION,
806
+ type: "run_event",
807
+ payload: serialize_runtime_value(event.to_h)
808
+ }
809
+ end
810
+ end
811
+ [workspace_snapshot_envelope(workspace_id)] +
812
+ threads.map { |thread| thread_snapshot_envelope(thread) } +
813
+ events +
814
+ [env_data_snapshot_envelope(workspace_id)]
815
+ end
816
+
817
+ def publish_workspace_snapshot(workspace_session_id)
818
+ broadcast_workspace_snapshot(workspace_session_id)
819
+ end
820
+
821
+ def publish_thread_snapshot(thread_id, po_name: nil)
822
+ thread = thread_repository.get(thread_id)
823
+ raise Error, "Unknown thread: #{thread_id}" unless thread
824
+
825
+ broadcast_thread_snapshot(thread, po_name || thread.po_name)
826
+ end
827
+
828
+ # Persist an artifact before exposing it to clients. The artifact repository
829
+ # is authoritative for identity, provenance, and revision history.
830
+ def persist_artifact(title:, html:, mode:, context:)
831
+ workspace_id = artifact_workspace_id(context)
832
+ owner_po = context.calling_po || (context.respond_to?(:target_po) ? context.target_po : nil) || "unknown"
833
+ owner_thread_id = artifact_owner_thread_id(context, owner_po)
834
+ producing_run_id = context.respond_to?(:run_id) ? context.run_id : nil
835
+ mutation = artifact_repository.upsert_with_cursor(
836
+ workspace_session_id: workspace_id,
837
+ owner_po: owner_po,
838
+ owner_thread_id: owner_thread_id,
839
+ producing_run_id: producing_run_id,
840
+ title: title,
841
+ html: html,
842
+ mode: mode,
843
+ metadata: {}
844
+ )
845
+ artifact = mutation.artifact
846
+
847
+ broadcast(
848
+ version: Execution::Contracts::PROTOCOL_VERSION,
849
+ type: "artifact_upserted",
850
+ payload: {
851
+ cursor: mutation.cursor,
852
+ artifact: serialize_runtime_value(artifact.to_h)
853
+ }
854
+ )
855
+ if producing_run_id
856
+ publish_run_event(event_repository.append_run_event(
857
+ run_id: producing_run_id,
858
+ kind: "artifact_upserted",
859
+ data: {
860
+ artifact_id: artifact.id,
861
+ title: artifact.title,
862
+ revision: artifact.revision,
863
+ owner_thread_id: artifact.owner_thread_id,
864
+ mode: artifact.mode
865
+ }
866
+ ))
867
+ end
868
+ artifact
869
+ end
870
+
871
+ def set_environment_data(key:, short_description:, value:, context:)
872
+ workspace_id = environment_data_workspace_id(context)
873
+ stored_by = context.calling_po || (context.respond_to?(:target_po) ? context.target_po : nil) || "unknown"
874
+ unless workspace_repository.get(workspace_id)
875
+ @session_store.store_env_data(
876
+ root_thread_id: workspace_id,
877
+ key: key,
878
+ short_description: short_description,
879
+ value: value,
880
+ stored_by: stored_by
881
+ )
882
+ notify_env_data_changed(
883
+ action: "set", root_thread_id: workspace_id, key: key, stored_by: stored_by
884
+ )
885
+ return @session_store.get_env_data(root_thread_id: workspace_id, key: key)
886
+ end
887
+ mutation = env_data_repository.set_with_cursor(
888
+ workspace_session_id: workspace_id,
889
+ key: key,
890
+ short_description: short_description,
891
+ value: value,
892
+ stored_by: stored_by
893
+ )
894
+ publish_env_data_snapshot(workspace_id, cursor: mutation.cursor)
895
+ publish_environment_data_run_event(context, action: "set", key: key, stored_by: stored_by)
896
+ notify_env_data_changed(
897
+ action: "set",
898
+ root_thread_id: workspace_id,
899
+ key: key,
900
+ stored_by: stored_by
901
+ )
902
+ mutation.entry
903
+ end
904
+
905
+ def get_environment_data(key:, context:)
906
+ workspace_id = environment_data_workspace_id(context)
907
+ if workspace_repository.get(workspace_id)
908
+ env_data_repository.get(workspace_session_id: workspace_id, key: key)
909
+ else
910
+ @session_store.get_env_data(root_thread_id: workspace_id, key: key)
911
+ end
912
+ end
913
+
914
+ def list_environment_data(context:)
915
+ workspace_id = environment_data_workspace_id(context)
916
+ if workspace_repository.get(workspace_id)
917
+ env_data_repository.list(workspace_session_id: workspace_id)
918
+ else
919
+ @session_store.list_env_data_full(root_thread_id: workspace_id)
920
+ end
921
+ end
922
+
923
+ def delete_environment_data(key:, context:)
924
+ workspace_id = environment_data_workspace_id(context)
925
+ stored_by = context.calling_po || (context.respond_to?(:target_po) ? context.target_po : nil) || "unknown"
926
+ unless workspace_repository.get(workspace_id)
927
+ deleted = @session_store.delete_env_data(root_thread_id: workspace_id, key: key)
928
+ if deleted
929
+ notify_env_data_changed(
930
+ action: "delete", root_thread_id: workspace_id, key: key, stored_by: stored_by
931
+ )
932
+ end
933
+ return deleted
934
+ end
935
+ removal = env_data_repository.delete_with_cursor(
936
+ workspace_session_id: workspace_id,
937
+ key: key,
938
+ stored_by: stored_by
939
+ )
940
+ return false unless removal.deleted
941
+
942
+ publish_env_data_snapshot(workspace_id, cursor: removal.cursor)
943
+ publish_environment_data_run_event(context, action: "delete", key: key, stored_by: stored_by)
944
+ notify_env_data_changed(
945
+ action: "delete",
946
+ root_thread_id: workspace_id,
947
+ key: key,
948
+ stored_by: stored_by
949
+ )
950
+ true
951
+ end
952
+
953
+ def inject_run_message(po:, content:, from_source:, workspace_session_id: nil,
954
+ thread_id: nil, client_request_id: nil, deadline: nil)
955
+ run = enqueue_run(
956
+ target_po: po.name,
957
+ content: content,
958
+ from_source: from_source,
959
+ workspace_session_id: workspace_session_id,
960
+ thread_id: thread_id,
961
+ client_request_id: client_request_id,
962
+ deadline: deadline
963
+ )
964
+ execute_run(run.id)
965
+ end
966
+
967
+ # Whether a PO has received a new human message during this runtime boot.
968
+ # Used by opt-in autonomous schedules that must not preempt initialization.
969
+ def human_message_received?(po_name)
970
+ !!@human_message_received[po_name]
971
+ end
972
+
973
+ # Publish truthful autonomous scheduling state for reconnectable UI
974
+ # countdowns. The scheduler owns these transitions; clients only render the
975
+ # server-provided next_run_at instead of guessing from page-load time.
976
+ def update_autonomous_schedule(po_name, **state)
977
+ payload = @autonomous_schedule_mutex.synchronize do
978
+ current = @autonomous_schedule_states[po_name] || { po_name: po_name }
979
+ @autonomous_schedule_states[po_name] = current.merge(state).freeze
980
+ end
981
+ broadcast(type: "autonomous_schedule", payload: payload)
982
+ payload
983
+ end
984
+
985
+ def autonomous_schedule_states
986
+ @autonomous_schedule_mutex.synchronize do
987
+ @autonomous_schedule_states.transform_values(&:dup)
988
+ end
989
+ end
990
+
991
+ # Broadcast a message to all connected WebSocket clients.
992
+ # No-op if on_broadcast callback isn't set (e.g., in tests or CLI mode).
993
+ # @param message [Hash] WebSocket message to broadcast
994
+ def broadcast(**message)
995
+ @on_broadcast&.call(message)
996
+ rescue => e
997
+ # Don't crash if broadcast fails
998
+ end
999
+
1000
+ def publish_run_event(event)
1001
+ broadcast(
1002
+ version: Execution::Contracts::PROTOCOL_VERSION,
1003
+ type: "run_event",
1004
+ payload: serialize_runtime_value(event.to_h)
1005
+ )
1006
+ end
1007
+
280
1008
  private
281
1009
 
1010
+ def normalize_deadline(deadline)
1011
+ return nil if deadline.nil? || deadline.to_s.empty?
1012
+ return deadline if deadline.is_a?(Time)
1013
+
1014
+ Time.iso8601(deadline.to_s)
1015
+ rescue ArgumentError
1016
+ raise Error, "deadline must be an ISO 8601 timestamp"
1017
+ end
1018
+
1019
+ def run_deadline(run)
1020
+ normalize_deadline(metadata_value(run, :deadline))
1021
+ end
1022
+
1023
+ def workspace_repository
1024
+ @workspace_repository ||= Repositories::WorkspaceSessionRepository.new(@session_store)
1025
+ end
1026
+
1027
+ def thread_repository
1028
+ @thread_repository ||= Repositories::ThreadRepository.new(@session_store)
1029
+ end
1030
+
1031
+ def run_repository
1032
+ @run_repository ||= Repositories::RunRepository.new(@session_store)
1033
+ end
1034
+
1035
+ def definition_repository
1036
+ @definition_repository ||= Repositories::DefinitionRepository.new(@session_store)
1037
+ end
1038
+
1039
+ def runtime_message_repository
1040
+ @runtime_message_repository ||= Repositories::MessageRepository.new(@session_store)
1041
+ end
1042
+
1043
+ def event_repository
1044
+ @event_repository ||= Repositories::EventRepository.new(@session_store)
1045
+ end
1046
+
1047
+ def artifact_repository
1048
+ @artifact_repository ||= Repositories::ArtifactRepository.new(@session_store)
1049
+ end
1050
+
1051
+ def env_data_repository
1052
+ @env_data_repository ||= Repositories::EnvDataRepository.new(@session_store)
1053
+ end
1054
+
1055
+ def artifact_workspace_id(context)
1056
+ context_workspace = context.respond_to?(:workspace_session_id) ? context.workspace_session_id : nil
1057
+ workspace_id = context_workspace || active_session(initiated_by: "artifact")&.dig(:id)
1058
+ raise Error, "No active workspace for artifact" unless workspace_id
1059
+
1060
+ workspace_id
1061
+ end
1062
+
1063
+ def artifact_owner_thread_id(context, owner_po)
1064
+ context_thread = context.respond_to?(:thread_id) ? context.thread_id : nil
1065
+ return context_thread if context_thread
1066
+
1067
+ nil
1068
+ end
1069
+
1070
+ def environment_data_workspace_id(context)
1071
+ context_workspace = context.respond_to?(:workspace_session_id) ? context.workspace_session_id : nil
1072
+ workspace_id = context_workspace || context.env_data_scope
1073
+ raise Error, "No active workspace for environment data" unless workspace_id
1074
+
1075
+ workspace_id
1076
+ end
1077
+
1078
+ def publish_env_data_snapshot(workspace_id, cursor: nil)
1079
+ broadcast(**env_data_snapshot_envelope(workspace_id, cursor: cursor))
1080
+ end
1081
+
1082
+ def publish_environment_data_run_event(context, action:, key:, stored_by:)
1083
+ run_id = context.respond_to?(:run_id) ? context.run_id : nil
1084
+ return unless run_id
1085
+
1086
+ publish_run_event(event_repository.append_run_event(
1087
+ run_id: run_id,
1088
+ kind: "env_data_changed",
1089
+ data: { action: action, key: key, stored_by: stored_by }
1090
+ ))
1091
+ end
1092
+
1093
+ def execution_runner
1094
+ @execution_runner ||= Execution::Runner.new(
1095
+ self,
1096
+ event_publisher: method(:publish_run_event),
1097
+ provider_permits: provider_permits,
1098
+ tool_permits: tool_permits,
1099
+ cpu_permits: cpu_permits,
1100
+ resource_lock_manager: resource_lock_manager
1101
+ )
1102
+ end
1103
+
1104
+ def provider_permits
1105
+ @provider_permits ||= Execution::PermitPool.new(@scheduler_options.fetch(:provider_limit))
1106
+ end
1107
+
1108
+ def tool_permits
1109
+ @tool_permits ||= Execution::PermitPool.new(@scheduler_options.fetch(:tool_limit))
1110
+ end
1111
+
1112
+ def cpu_permits
1113
+ @cpu_permits ||= Execution::PermitPool.new(@scheduler_options.fetch(:cpu_limit))
1114
+ end
1115
+
1116
+ def resource_lock_manager
1117
+ @resource_lock_manager ||= Execution::ResourceLockManager.new
1118
+ end
1119
+
1120
+ def run_scheduler
1121
+ @run_scheduler ||= begin
1122
+ Execution::Scheduler.new(
1123
+ worker_limit: @scheduler_options.fetch(:worker_limit),
1124
+ max_active_runs: @scheduler_options.fetch(:max_active_runs),
1125
+ max_active_per_po: @scheduler_options.fetch(:max_active_per_po),
1126
+ max_queued_per_thread: @scheduler_options.fetch(:max_queued_per_thread),
1127
+ execute: method(:execute_scheduled_run),
1128
+ ready: ->(run) { run_repository.thread_head?(run) },
1129
+ cancel_queued: method(:cancel_queued_run)
1130
+ )
1131
+ end
1132
+ end
1133
+
1134
+ def execute_scheduled_run(run, cancellation_token, options)
1135
+ thread = thread_repository.get(run.thread_id)
1136
+ raise Error, "Unknown thread: #{run.thread_id}" unless thread
1137
+
1138
+ broadcast_po_runtime_status(run)
1139
+ execution_runner.execute(
1140
+ run.id,
1141
+ cancellation_token: cancellation_token,
1142
+ tui_mode: options.fetch(:tui_mode, true)
1143
+ )
1144
+ ensure
1145
+ publish_dispatch_completion(run)
1146
+ broadcast_thread_snapshot(thread, run.po_name) if thread && thread.thread_type != "autonomous"
1147
+ broadcast_workspace_snapshot(run.workspace_session_id)
1148
+ broadcast_po_runtime_status(run)
1149
+ end
1150
+
1151
+ def cancel_queued_run(run, reason)
1152
+ cancelled = run_repository.transition(run.id, status: "cancelled", cancellation_reason: reason)
1153
+ publish_run_event(event_repository.append_run_event(
1154
+ run_id: run.id,
1155
+ kind: "run_cancelled",
1156
+ data: { reason: reason }
1157
+ ))
1158
+ broadcast_workspace_snapshot(run.workspace_session_id)
1159
+ broadcast_po_runtime_status(cancelled)
1160
+ @run_scheduler&.wake
1161
+ publish_dispatch_completion(cancelled)
1162
+ cancelled
1163
+ end
1164
+
1165
+ def reject_queued_run(run, reason)
1166
+ skipped = run_repository.transition(run.id, status: "skipped", cancellation_reason: reason)
1167
+ publish_run_event(event_repository.append_run_event(
1168
+ run_id: run.id,
1169
+ kind: "run_skipped",
1170
+ data: { reason: reason }
1171
+ ))
1172
+ broadcast_workspace_snapshot(run.workspace_session_id)
1173
+ broadcast_po_runtime_status(skipped)
1174
+ @run_scheduler&.wake
1175
+ publish_dispatch_completion(skipped)
1176
+ skipped
1177
+ end
1178
+
1179
+ def recover_interrupted_runs
1180
+ return if @recovery_complete
1181
+
1182
+ workspace_id = active_session(initiated_by: "recovery")[:id]
1183
+ run_repository.list(
1184
+ workspace_session_id: workspace_id,
1185
+ statuses: %w[running waiting],
1186
+ limit: 10_000
1187
+ ).each do |run|
1188
+ run_repository.transition(run.id, status: "interrupted")
1189
+ publish_run_event(event_repository.append_run_event(
1190
+ run_id: run.id,
1191
+ kind: "run_interrupted",
1192
+ data: { reason: "process restarted" }
1193
+ ))
1194
+ end
1195
+ @recovery_complete = true
1196
+ end
1197
+
1198
+ def broadcast_po_runtime_status(run)
1199
+ active = run_repository.active_count(
1200
+ workspace_session_id: run.workspace_session_id,
1201
+ po_name: run.po_name
1202
+ )
1203
+ broadcast(
1204
+ type: "po_state",
1205
+ payload: { name: run.po_name, state: { status: active.positive? ? "thinking" : "idle" } }
1206
+ )
1207
+ end
1208
+
1209
+ def validate_dispatch_target!(caller_name, target_name)
1210
+ target = registry.get(target_name)
1211
+ raise Error, "Unknown dispatch target: #{target_name}" unless target.is_a?(PromptObject)
1212
+
1213
+ caller = registry.get(caller_name)
1214
+ allowed = Array(caller&.config&.dig("capabilities"))
1215
+ raise Error, "#{caller_name} cannot dispatch to #{target_name}" unless allowed.include?(target_name)
1216
+ end
1217
+
1218
+ def required_dispatch_thread(thread_id, parent, target_po)
1219
+ thread = thread_repository.get(thread_id)
1220
+ unless thread && thread.workspace_session_id == parent.workspace_session_id && thread.po_name == target_po
1221
+ raise Error, "Thread not found for dispatch target #{target_po}: #{thread_id}"
1222
+ end
1223
+
1224
+ thread
1225
+ end
1226
+
1227
+ def run_depth(run)
1228
+ depth = metadata_value(run, :delegation_depth)
1229
+ return depth.to_i if depth
1230
+
1231
+ current = run
1232
+ count = 0
1233
+ while current.parent_run_id
1234
+ count += 1
1235
+ current = run_repository.get(current.parent_run_id)
1236
+ break unless current
1237
+ end
1238
+ count
1239
+ end
1240
+
1241
+ def authorized_related_run(run_id, context)
1242
+ parent = run_repository.get(context.run_id)
1243
+ run = run_repository.get(run_id)
1244
+ raise Error, "Unknown run: #{run_id}" unless run
1245
+ unless parent && run.workspace_session_id == parent.workspace_session_id && run.root_run_id == parent.root_run_id
1246
+ raise Error, "Run is outside the caller's execution tree: #{run_id}"
1247
+ end
1248
+
1249
+ run
1250
+ end
1251
+
1252
+ def terminal_run_status?(status)
1253
+ %w[completed failed cancelled skipped interrupted].include?(status)
1254
+ end
1255
+
1256
+ def publish_dispatch_completion(run)
1257
+ return unless metadata_value(run, :dispatch_mode) == "dispatch" && run.parent_run_id
1258
+ return unless terminal_run_status?(run_repository.get(run.id)&.status)
1259
+
1260
+ existing = event_repository.run_events(run_id: run.parent_run_id).any? do |event|
1261
+ event.kind == "delegation_completed" && event.data[:child_run_id] == run.id
1262
+ end
1263
+ return if existing
1264
+
1265
+ current = run_repository.get(run.id)
1266
+ publish_run_event(event_repository.append_run_event(
1267
+ run_id: run.parent_run_id,
1268
+ kind: "delegation_completed",
1269
+ data: {
1270
+ child_run_id: run.id,
1271
+ child_thread_id: run.thread_id,
1272
+ target_po: run.po_name,
1273
+ mode: "dispatch",
1274
+ status: current.status
1275
+ }
1276
+ ))
1277
+ enqueue_dispatch_followup(current)
1278
+ end
1279
+
1280
+ def metadata_value(run, key)
1281
+ return nil unless run
1282
+
1283
+ run.metadata.key?(key) ? run.metadata[key] : run.metadata[key.to_s]
1284
+ end
1285
+
1286
+ def enqueue_dispatch_followup(child)
1287
+ instruction = metadata_value(child, :on_complete)
1288
+ return if instruction.to_s.empty?
1289
+
1290
+ parent = run_repository.get(child.parent_run_id)
1291
+ return unless parent
1292
+ return if %w[cancelled interrupted].include?(parent.status)
1293
+
1294
+ target = registry.get(parent.po_name)
1295
+ return unless target.is_a?(PromptObject)
1296
+
1297
+ definition = execution_definition_for(target)
1298
+ followup = run_repository.enqueue(
1299
+ workspace_session_id: parent.workspace_session_id,
1300
+ thread_id: parent.thread_id,
1301
+ po_name: parent.po_name,
1302
+ input_content: "#{instruction}\n\nDispatched run #{child.id} finished with status #{child.status}.",
1303
+ definition_version: definition.version,
1304
+ provider: definition.provider,
1305
+ model: definition.model,
1306
+ capability_policy_version: definition.capability_policy_version,
1307
+ source_type: "delegation",
1308
+ source_name: child.po_name,
1309
+ client_request_id: "dispatch_completion:#{child.id}",
1310
+ parent_run_id: child.id,
1311
+ priority: 80,
1312
+ metadata: { completion_trigger_for: child.id }
1313
+ )
1314
+ definition_repository.attach(run_id: followup.id, definition: definition) unless definition_repository.get(followup.id)
1315
+ return unless followup.status == "queued" && event_repository.run_events(run_id: followup.id).empty?
1316
+
1317
+ publish_workspace_snapshot(parent.workspace_session_id)
1318
+ publish_run_event(event_repository.append_run_event(
1319
+ run_id: followup.id,
1320
+ kind: "run_queued",
1321
+ data: { source_type: followup.source_type, turn_ordinal: followup.turn_ordinal, trigger_run_id: child.id }
1322
+ ))
1323
+ schedule_run(followup.id)
1324
+ end
1325
+
1326
+ def resolve_workspace_id(requested_id, initiated_by:)
1327
+ active_id = active_session(initiated_by: initiated_by)[:id]
1328
+ if requested_id && requested_id != active_id
1329
+ raise Error, "Workspace session is not active: #{requested_id}"
1330
+ end
1331
+
1332
+ active_id
1333
+ end
1334
+
1335
+ def required_runtime_thread(thread_id)
1336
+ thread = thread_repository.get(thread_id)
1337
+ raise Error, "Unknown thread: #{thread_id}" unless thread
1338
+
1339
+ resolve_workspace_id(thread.workspace_session_id, initiated_by: "human")
1340
+ thread
1341
+ end
1342
+
1343
+ def resolve_run_thread(workspace_id:, po:, from_source:, thread_id:)
1344
+ if thread_id
1345
+ thread = thread_repository.get(thread_id)
1346
+ unless thread && thread.workspace_session_id == workspace_id && thread.po_name == po.name
1347
+ raise Error, "Thread not found for #{po.name}: #{thread_id}"
1348
+ end
1349
+ return thread
1350
+ end
1351
+
1352
+ thread_type = thread_type_for(from_source)
1353
+ existing = thread_repository.list(workspace_session_id: workspace_id, po_name: po.name)
1354
+ .find { |thread| thread.thread_type == thread_type }
1355
+ existing || thread_repository.create(
1356
+ workspace_session_id: workspace_id,
1357
+ po_name: po.name,
1358
+ thread_type: thread_type,
1359
+ retention_policy: thread_type == "autonomous" ? "summary" : "full"
1360
+ )
1361
+ end
1362
+
1363
+ def thread_type_for(from_source)
1364
+ return "human" if from_source == "human"
1365
+ return "autonomous" if from_source.start_with?("timer:")
1366
+ return "artifact" if from_source.start_with?("artifact:")
1367
+ return "service" if from_source.start_with?("service:")
1368
+
1369
+ "api"
1370
+ end
1371
+
1372
+ def source_type_for(from_source)
1373
+ thread_type_for(from_source)
1374
+ end
1375
+
1376
+ def priority_for(from_source)
1377
+ case thread_type_for(from_source)
1378
+ when "human" then 100
1379
+ when "artifact" then 90
1380
+ when "delegation" then 80
1381
+ when "api" then 60
1382
+ when "service" then 40
1383
+ else 10
1384
+ end
1385
+ end
1386
+
1387
+ def broadcast_thread_snapshot(thread, _po_name)
1388
+ messages = runtime_message_repository.list(thread_id: thread.id, limit: 10_000)
1389
+ broadcast(**thread_snapshot_envelope(thread, messages: messages))
1390
+ end
1391
+
1392
+ def broadcast_workspace_snapshot(workspace_id)
1393
+ broadcast(**workspace_snapshot_envelope(workspace_id))
1394
+ end
1395
+
1396
+ def workspace_snapshot_envelope(workspace_id)
1397
+ workspace = workspace_repository.get(workspace_id)
1398
+ raise Error, "Unknown workspace session: #{workspace_id}" unless workspace
1399
+
1400
+ {
1401
+ version: Execution::Contracts::PROTOCOL_VERSION,
1402
+ type: "workspace_snapshot",
1403
+ payload: {
1404
+ cursor: event_repository.current_cursor(workspace_session_id: workspace_id),
1405
+ workspace: serialize_runtime_value(workspace.to_h),
1406
+ threads: thread_repository.list(
1407
+ workspace_session_id: workspace_id,
1408
+ include_archived: true
1409
+ ).map { |thread| serialize_runtime_value(thread.to_h) },
1410
+ runs: run_repository.list(
1411
+ workspace_session_id: workspace_id,
1412
+ limit: 500
1413
+ ).map { |run| serialize_runtime_value(run.to_h) },
1414
+ artifacts: artifact_repository.list(
1415
+ workspace_session_id: workspace_id
1416
+ ).map { |artifact| serialize_runtime_value(artifact.to_h) }
1417
+ }
1418
+ }
1419
+ end
1420
+
1421
+ def env_data_snapshot_envelope(workspace_id, cursor: nil)
1422
+ {
1423
+ version: Execution::Contracts::PROTOCOL_VERSION,
1424
+ type: "env_data_snapshot",
1425
+ payload: {
1426
+ workspace_session_id: workspace_id,
1427
+ cursor: cursor || event_repository.current_cursor(workspace_session_id: workspace_id),
1428
+ entries: env_data_repository.list(
1429
+ workspace_session_id: workspace_id
1430
+ ).map { |entry| serialize_runtime_value(entry) }
1431
+ }
1432
+ }
1433
+ end
1434
+
1435
+ def thread_snapshot_envelope(thread, messages: nil)
1436
+ messages ||= runtime_message_repository.list(thread_id: thread.id, limit: 10_000)
1437
+ {
1438
+ version: Execution::Contracts::PROTOCOL_VERSION,
1439
+ type: "thread_snapshot",
1440
+ payload: {
1441
+ cursor: event_repository.current_cursor(workspace_session_id: thread.workspace_session_id),
1442
+ thread: serialize_runtime_value(thread.to_h),
1443
+ messages: messages.map { |message| serialize_runtime_message(message) }
1444
+ }
1445
+ }
1446
+ end
1447
+
1448
+ def serialize_runtime_value(value)
1449
+ case value
1450
+ when Time then value.iso8601
1451
+ when Hash then value.to_h { |key, nested| [key, serialize_runtime_value(nested)] }
1452
+ when Array then value.map { |nested| serialize_runtime_value(nested) }
1453
+ else value
1454
+ end
1455
+ end
1456
+
1457
+ def serialize_runtime_message(message)
1458
+ serialize_runtime_value(message).merge(
1459
+ id: message[:message_uid] || message[:id].to_s,
1460
+ cursor_id: message[:id]
1461
+ )
1462
+ end
1463
+
1464
+ # Find the most recent environment session or create one. Looks only at
1465
+ # environment-level sessions (po_name IS NULL), not per-PO sessions, so the
1466
+ # active session is a real shared-context container.
1467
+ def find_or_create_environment_session(initiated_by: "human")
1468
+ return nil unless @session_store
1469
+
1470
+ existing = @session_store.list_environment_sessions(limit: 1)
1471
+ return existing.first[:id] if existing.any?
1472
+
1473
+ create_environment_session(initiated_by: initiated_by)[:id]
1474
+ end
1475
+
1476
+ # Load vendored gems and validate declared dependencies.
1477
+ # Templates can vendor gems in vendor/<gem-name>/lib/ to avoid
1478
+ # requiring users to install gems or fight Bundler isolation.
1479
+ # Falls back to system gem activation for non-vendored deps.
1480
+ def validate_dependencies
1481
+ load_vendored_gems
1482
+
1483
+ gems = @manifest.dependencies["gems"]
1484
+ return unless gems.is_a?(Array) && gems.any?
1485
+
1486
+ missing = gems.filter_map do |gem_info|
1487
+ name = gem_info["name"]
1488
+ # Check if the gem is already loadable (vendored or system)
1489
+ begin
1490
+ require name
1491
+ nil
1492
+ rescue LoadError
1493
+ gem_info
1494
+ end
1495
+ end
1496
+
1497
+ return if missing.empty?
1498
+
1499
+ install_cmds = missing.map { |g| " gem install #{g['name']} # #{g['purpose']}" }
1500
+ warn "Warning: Missing dependencies for environment '#{@manifest.name}':"
1501
+ install_cmds.each { |cmd| warn cmd }
1502
+ warn "\nSome primitives may not load. Install the gems and restart."
1503
+ end
1504
+
1505
+ # Add vendor/<gem>/lib directories to $LOAD_PATH so vendored
1506
+ # gems are requireable without system installation.
1507
+ def load_vendored_gems
1508
+ return unless @env_path
1509
+
1510
+ vendor_dir = File.join(@env_path, "vendor")
1511
+ return unless Dir.exist?(vendor_dir)
1512
+
1513
+ Dir.glob(File.join(vendor_dir, "*", "lib")).sort.each do |lib_path|
1514
+ $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
1515
+ end
1516
+ end
1517
+
282
1518
  # Register built-in primitive capabilities.
283
1519
  def register_primitives
284
1520
  @registry.register(Primitives::ReadFile.new)
285
1521
  @registry.register(Primitives::ListFiles.new)
286
1522
  @registry.register(Primitives::WriteFile.new)
287
1523
  @registry.register(Primitives::HttpGet.new)
1524
+ @registry.register(Primitives::Think.new)
288
1525
 
289
1526
  # Load custom primitives from environment's primitives directory
290
1527
  load_custom_primitives
291
1528
  end
292
1529
 
293
1530
  # Load custom primitives from the environment's primitives/ directory.
294
- # Follows the same pattern as Universal::CreatePrimitive for class naming
295
- # and loading. Warns on errors rather than crashing so a broken primitive
1531
+ # Derives the class name from the filename (snake_case -> CamelCase), the
1532
+ # same convention create_capability uses when it generates a primitive.
1533
+ # Warns on errors rather than crashing so a broken primitive
296
1534
  # file doesn't prevent the environment from starting (a PO can fix it).
297
1535
  def load_custom_primitives
298
1536
  return unless @primitives_dir && Dir.exist?(@primitives_dir)
@@ -304,7 +1542,7 @@ module PromptObjects
304
1542
  load(path)
305
1543
  klass = PromptObjects::Primitives.const_get(class_name)
306
1544
  @registry.register(klass.new)
307
- rescue SyntaxError, StandardError => e
1545
+ rescue SyntaxError, LoadError, StandardError => e
308
1546
  warn "Warning: Failed to load custom primitive #{path}: #{e.message}"
309
1547
  end
310
1548
  end
@@ -312,25 +1550,104 @@ module PromptObjects
312
1550
 
313
1551
  # Register universal capabilities (available to all prompt objects).
314
1552
  def register_universal_capabilities
315
- @registry.register(Universal::AskHuman.new)
316
- @registry.register(Universal::Think.new)
317
1553
  @registry.register(Universal::CreateCapability.new)
318
1554
  @registry.register(Universal::AddCapability.new)
319
1555
  @registry.register(Universal::RemoveCapability.new)
320
1556
  @registry.register(Universal::ListCapabilities.new)
321
- @registry.register(Universal::ListPrimitives.new)
322
- @registry.register(Universal::AddPrimitive.new)
323
- @registry.register(Universal::CreatePrimitive.new)
324
1557
  @registry.register(Universal::DeletePrimitive.new)
325
1558
  @registry.register(Universal::VerifyPrimitive.new)
326
1559
  @registry.register(Universal::ModifyPrimitive.new)
327
1560
  @registry.register(Universal::RequestPrimitive.new)
328
1561
  @registry.register(Universal::ModifyPrompt.new)
329
- @registry.register(Universal::StoreEnvData.new)
1562
+ @registry.register(Universal::SetEnvData.new)
330
1563
  @registry.register(Universal::GetEnvData.new)
331
1564
  @registry.register(Universal::ListEnvData.new)
332
- @registry.register(Universal::UpdateEnvData.new)
333
1565
  @registry.register(Universal::DeleteEnvData.new)
1566
+ @registry.register(Universal::RenderArtifact.new)
1567
+ @registry.register(Universal::DispatchPO.new)
1568
+ @registry.register(Universal::GetRunStatus.new)
1569
+ @registry.register(Universal::JoinRuns.new)
1570
+ end
1571
+
1572
+ # Load custom services from the environment's services/ directory.
1573
+ # Services are background processes with a tick loop, following
1574
+ # the same loading pattern as custom primitives.
1575
+ def load_custom_services
1576
+ return unless @services_dir && Dir.exist?(@services_dir)
1577
+
1578
+ Dir.glob(File.join(@services_dir, "*.rb")).sort.each do |path|
1579
+ begin
1580
+ filename = File.basename(path, ".rb")
1581
+ class_name = filename.split("_").map(&:capitalize).join
1582
+ load(path)
1583
+ klass = PromptObjects::Services.const_get(class_name)
1584
+ service = klass.new(runtime: self)
1585
+ @services << service
1586
+ rescue SyntaxError, LoadError, StandardError => e
1587
+ warn "Warning: Failed to load service #{path}: #{e.message}"
1588
+ end
1589
+ end
1590
+ end
1591
+
1592
+ public
1593
+
1594
+ # Start all loaded services by calling their start method.
1595
+ # Does NOT schedule tick loops — that's the server's responsibility
1596
+ # using Async tasks (see Server.start).
1597
+ def start_services
1598
+ @services.each do |service|
1599
+ begin
1600
+ service.start
1601
+ rescue => e
1602
+ warn "Warning: Failed to start service #{service.name}: #{e.message}"
1603
+ end
1604
+ end
1605
+ end
1606
+
1607
+ # Stop all running services.
1608
+ def stop_services
1609
+ @services.each do |service|
1610
+ service.stop
1611
+ rescue => e
1612
+ warn "Warning: Failed to stop service #{service.name}: #{e.message}"
1613
+ end
1614
+ end
1615
+
1616
+ # Load connectors from the environment's connectors/ directory. A connector
1617
+ # is a Connectors::Base subclass that bridges an external system (Discord,
1618
+ # Slack, a webhook listener, …) to POs by calling inject_message. Loaded
1619
+ # like services; started/stopped with the server.
1620
+ def load_custom_connectors
1621
+ return unless @connectors_dir && Dir.exist?(@connectors_dir)
1622
+
1623
+ Dir.glob(File.join(@connectors_dir, "*.rb")).sort.each do |path|
1624
+ begin
1625
+ class_name = File.basename(path, ".rb").split("_").map(&:capitalize).join
1626
+ load(path)
1627
+ klass = PromptObjects::Connectors.const_get(class_name)
1628
+ @connectors << klass.new(runtime: self)
1629
+ rescue SyntaxError, LoadError, StandardError => e
1630
+ warn "Warning: Failed to load connector #{path}: #{e.message}"
1631
+ end
1632
+ end
1633
+ end
1634
+
1635
+ # Start all loaded connectors. Each connector manages its own loop/thread.
1636
+ def start_connectors
1637
+ @connectors.each do |connector|
1638
+ connector.start
1639
+ rescue => e
1640
+ warn "Warning: Failed to start connector #{connector.source_name}: #{e.message}"
1641
+ end
1642
+ end
1643
+
1644
+ # Stop all running connectors.
1645
+ def stop_connectors
1646
+ @connectors.each do |connector|
1647
+ connector.stop
1648
+ rescue => e
1649
+ warn "Warning: Failed to stop connector #{connector.source_name}: #{e.message}"
1650
+ end
334
1651
  end
335
1652
  end
336
1653