samagotchi 0.2.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 (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. metadata +374 -0
@@ -0,0 +1,993 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "json"
5
+ require "uri"
6
+ require "fileutils"
7
+ require "securerandom"
8
+ require "time"
9
+
10
+ require_relative "bridge/bounded_queue"
11
+ require_relative "bridge/card_store"
12
+ require_relative "bridge/event_id"
13
+ require_relative "bridge/ring_buffer"
14
+ require_relative "bridge/sse_writer"
15
+ require_relative "bridge/turn_accumulator"
16
+ require_relative "session"
17
+ require_relative "engine"
18
+ require_relative "session_commands"
19
+ require_relative "image_store"
20
+ require_relative "log"
21
+
22
+ module Samagotchi
23
+ # Bridge is an optional HTTP transport that lets an external web / desktop
24
+ # client reach the owner process (the forked session-worker) and receive the
25
+ # Engine's live event stream via Server-Sent Events, and optionally create a
26
+ # turn via HTTP POST.
27
+ #
28
+ # Design invariants (see multi_ui_architecture):
29
+ # * Reuses `Engine#subscribe` for SSE fan-out — never a second Engine, never
30
+ # `run_turn` across the thread/process boundary. POST reuses the
31
+ # `SessionManager` file-IPC input path.
32
+ # * One capture observer fills a shared ring buffer; each connection gets a
33
+ # per-connection live queue (see SSEWriter).
34
+ # * Binds 127.0.0.1 only. There is NO auth: binding 0.0.0.0 exposes remote
35
+ # turn-execution (RCE). Documented, not fixed.
36
+ # * Optional at runtime: this module is never loaded unless explicitly
37
+ # engaged as a transport.
38
+ class Bridge
39
+ DEFAULT_BIND = "127.0.0.1"
40
+ SIDECAR_FILE = "bridge.json"
41
+ DEFAULT_RING_CAPACITY = 256
42
+ DEFAULT_HEARTBEAT_INTERVAL = 15.0
43
+ # Cancel reasons a client may name (the web sends user, an attached TUI
44
+ # ctrl_c); anything else is :manual, so client input never mints symbols.
45
+ CANCEL_REASONS = %w[manual user ctrl_c].freeze
46
+ # How long #stop waits for requests it is answering (not open streams).
47
+ REQUEST_GRACE_SECONDS = 1.0
48
+ # The largest request body read (images travel as refs, never bytes).
49
+ MAX_BODY_BYTES = 1_000_000
50
+ MAX_TURN_IMAGES = 20
51
+ # A request's deadline (see #handle_post_turn) that isn't epoch seconds.
52
+ BAD_DEADLINE = [{ "Allow" => "POST" }, 400, { error: "bad_deadline", detail: "deadline must be epoch seconds" }].freeze
53
+
54
+ # @param engine [Samagotchi::Engine] the owning engine (must already live
55
+ # in this process)
56
+ # @param state_dir [String] the session state directory root
57
+ # @param session_id [String] this bridge's session id
58
+ # @param bind [String] bind address (127.0.0.1 only)
59
+ # @param port [Integer] port to bind (0 → OS-assigned, read back)
60
+ # @param ring_capacity [Integer] shared ring-buffer capacity
61
+ # @param heartbeat_interval [Float] idle `: ping` seconds
62
+ # @param input_format [Integer, nil] the input-file format the owning
63
+ # worker reads, advertised in the sidecar for writers (see
64
+ # SessionManager.write_turn_input); nil advertises none (plain text)
65
+ # @param on_input [#call, nil] called once a turn for this session is
66
+ # queued, to wake the worker loop (Worker::Waker#wake)
67
+ # @param on_command [#call, nil] takes a session command
68
+ # ({command_id:, client_id:, line:}) for the worker loop to run; without
69
+ # one, POST /command answers 501
70
+ # @param on_exit_request [#call, nil] a client asks the worker to exit
71
+ # now: takes the client_id and delete: (the exit is to delete the
72
+ # session, /exit --delete), returns nil when the worker will leave or
73
+ # the Symbol that keeps it up (WorkerIdleExit#hold_for_request); called
74
+ # with the event log held. Without one, POST /exit answers 501
75
+ # @param exit_discards [#call, nil] after an exit the worker agreed to:
76
+ # whether it will delete the session as empty (the 200 says
77
+ # +discard+); without one the reply leaves the field out
78
+ def initialize(engine:, state_dir:, session_id:, bind: DEFAULT_BIND,
79
+ port: 0, ring_capacity: DEFAULT_RING_CAPACITY,
80
+ heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL, input_format: nil, on_input: nil,
81
+ on_command: nil, on_exit_request: nil, exit_discards: nil)
82
+ @engine = engine
83
+ @on_input = on_input
84
+ @on_command = on_command
85
+ @on_exit_request = on_exit_request
86
+ @exit_discards = exit_discards
87
+ @input_format = input_format
88
+ @state_dir = state_dir
89
+ @session_id = session_id
90
+ @bind = bind
91
+ @port = port
92
+ @ring = RingBuffer.new(capacity: ring_capacity)
93
+ @accumulator = TurnAccumulator.new
94
+ @cards = CardStore.new
95
+ @heartbeat_interval = heartbeat_interval
96
+ @epoch = SecureRandom.hex(4)
97
+
98
+ @capture_handle = nil
99
+ @server = nil
100
+ @accept_thread = nil
101
+ @connection_threads = []
102
+ @stopped = false
103
+ @mutex = Monitor.new
104
+ @open_streams = 0
105
+ # Open streams by the client_id a stream named (`?client_id=`); web
106
+ # tabs, through the `chi web` proxy, name none.
107
+ @streams_by_client = Hash.new(0)
108
+ @last_client_activity_at = monotonic_now
109
+ end
110
+
111
+ # @return [Boolean] whether the server has been stopped.
112
+ def stopped?
113
+ @mutex.synchronize { @stopped }
114
+ end
115
+
116
+ # This worker's epoch: event_seq starts over in each worker, so every
117
+ # event id and snapshot carries it (see EventId).
118
+ # @return [String]
119
+ attr_reader :epoch
120
+
121
+ # @return [Integer] SSE streams open now: one per attached TUI or web tab
122
+ def open_streams
123
+ @mutex.synchronize { @open_streams }
124
+ end
125
+
126
+ # Streams held by anyone but +client_id+: a client's own stream (or two,
127
+ # while it reconnects and the old one isn't noticed dead yet) doesn't
128
+ # count. A closed stream counts until its next write fails (heartbeat).
129
+ # @return [Integer]
130
+ def open_streams_except(client_id)
131
+ @mutex.synchronize { @open_streams - (client_id ? @streams_by_client[client_id] : 0) }
132
+ end
133
+
134
+ # Monotonic time of the last request, stream open or stream close. The
135
+ # worker's idle exit counts from it. A connect that sends no request (the
136
+ # sidecar liveness probe) doesn't count.
137
+ # @return [Float]
138
+ def last_client_activity_at
139
+ @mutex.synchronize { @last_client_activity_at }
140
+ end
141
+
142
+ # Bind the listen socket (OS-assigned when port == 0), register the shared
143
+ # capture observer, start the acceptor thread, and write the port sidecar.
144
+ # Non-blocking: returns once the socket is listening.
145
+ # @return [self]
146
+ def start
147
+ @server = TCPServer.new(@bind, @port)
148
+ @port = @server.local_address.ip_port
149
+ @capture_handle = @engine.subscribe(observer: capture_observer)
150
+ @accumulator_handle = @engine.subscribe(observer: @accumulator)
151
+ # A plugin's ctx.messages mid-turn holds the turn so far (plan O1).
152
+ @engine.running_turn_messages = -> { @accumulator.current_messages } if @engine.respond_to?(:running_turn_messages=)
153
+ @cards_handle = @engine.subscribe(observer: @cards)
154
+ @accept_thread = Thread.new { accept_loop }
155
+ @accept_thread.report_on_exception = false
156
+ write_sidecar
157
+ self
158
+ rescue StandardError => e
159
+ stop
160
+ raise e
161
+ end
162
+
163
+ # Stop the acceptor, release the socket and remove the sidecar, so clients
164
+ # don't have to find it stale. Does not stop the owning turn. Joins the
165
+ # acceptor thread so the process can exit cleanly (Ruby waits for a thread
166
+ # blocked in IO.select at VM shutdown). Safe to call again.
167
+ def stop
168
+ @mutex.synchronize { @stopped = true }
169
+ begin
170
+ @server&.close
171
+ rescue StandardError
172
+ nil
173
+ end
174
+ @capture_handle&.unsubscribe
175
+ @accumulator_handle&.unsubscribe
176
+ @engine.running_turn_messages = nil if @engine.respond_to?(:running_turn_messages=)
177
+ @cards_handle&.unsubscribe
178
+ # A turn post killed between its enqueue and its reply looks failed to
179
+ # the web, which then queues the prompt again from the input file.
180
+ await_answers(REQUEST_GRACE_SECONDS)
181
+ @connection_threads.each { |t| t.kill rescue nil }
182
+ @connection_threads.clear
183
+ @accept_thread&.join(2)
184
+ remove_sidecar
185
+ nil
186
+ end
187
+
188
+ # What a joining client needs to render the session now, consistent with
189
+ # the event log: the Engine's messages (not the lagging copy on disk),
190
+ # the turn in progress, turns queued behind it, the idle recap since the
191
+ # last turn, the recap saved with the session (also from before the last
192
+ # turns: {text:, covered:, turns_since:, created_at:}), a pending continue offer, the guardrail and plugin load warnings,
193
+ # the plugins' init tasks still running (Engine#init_tasks), the
194
+ # last cards and between-turns notices (CardStore#list), the session's commands (Commands::Registry#listing:
195
+ # what an attached TUI routes and completes, the web's autocomplete), and the event_seq it all covers.
196
+ # Taken with the log held, so no event is half-applied.
197
+ # @return [Hash] {messages:, current_turn:, queued:, recap:, saved_recap:, continue_offer:, guardrail_warning:,
198
+ # plugin_warning:, init_tasks:, cards:, commands:, event_seq:, event_id:}
199
+ def snapshot
200
+ @engine.synchronize_events do
201
+ seq = @engine.event_count
202
+ {
203
+ messages: @engine.messages_checkpoint,
204
+ current_turn: @accumulator.current_turn,
205
+ queued: @accumulator.queued,
206
+ recap: @accumulator.recap,
207
+ saved_recap: @engine.saved_recap,
208
+ continue_offer: @accumulator.continue_offer,
209
+ guardrail_warning: @engine.guardrail_warning,
210
+ plugin_warning: @engine.plugin_warning,
211
+ init_tasks: @engine.respond_to?(:init_tasks) ? @engine.init_tasks : [],
212
+ cards: @cards.list,
213
+ commands: command_registry.listing,
214
+ event_seq: seq,
215
+ event_id: event_id(seq)
216
+ }
217
+ end
218
+ end
219
+
220
+ private
221
+
222
+ # The Engine's commands: the built-ins and its plugins'.
223
+ def command_registry
224
+ @engine.respond_to?(:command_registry) ? @engine.command_registry : SessionCommands.builtin_registry
225
+ end
226
+
227
+ # The single shared capture observer: appends every event to the ring.
228
+ # O(1) and non-blocking.
229
+ def capture_observer
230
+ @capture_proc ||= proc do |event|
231
+ @ring.push(seq: event[:event_seq], data: event)
232
+ rescue StandardError
233
+ nil
234
+ end
235
+ end
236
+
237
+ # Wait up to +timeout+ seconds for connection threads still answering a
238
+ # request (see handle_connection).
239
+ def await_answers(timeout)
240
+ deadline = monotonic_now + timeout
241
+ while monotonic_now < deadline
242
+ busy = @connection_threads.any? { |t| t != Thread.current && t.alive? && t[:bridge_answering] }
243
+ break unless busy
244
+
245
+ sleep(0.01)
246
+ end
247
+ end
248
+
249
+ def accept_loop
250
+ loop do
251
+ break if stopped? || @server.closed?
252
+
253
+ begin
254
+ rs, = IO.select([@server], nil, nil, 0.5)
255
+ rescue IOError
256
+ break
257
+ end
258
+
259
+ next unless rs
260
+
261
+ client =
262
+ begin
263
+ @server.accept
264
+ rescue IOError, Errno::EBADF
265
+ break
266
+ end
267
+
268
+ Thread.new { handle_connection(client) }.tap do |t|
269
+ t.report_on_exception = false
270
+ @connection_threads << t
271
+ end
272
+ end
273
+ rescue StandardError => e
274
+ # No accept loop, no bridge: every client of this worker loses it.
275
+ # (#stop closing the server ends the loop the same way: not a failure.)
276
+ Log.exception(:bridge, "accept_loop_failed", e) unless stopped?
277
+ end
278
+
279
+ # One thread per connection. Short-lived endpoints (POST / state) answer
280
+ # and keep the connection open for another request; the SSE endpoint owns
281
+ # the connection until the client disconnects or the bridge stops.
282
+ def handle_connection(io)
283
+ io.binmode
284
+ loop do
285
+ request = read_request(io)
286
+ break if request.nil?
287
+
288
+ note_client_activity
289
+ started = monotonic_now
290
+
291
+ method = request[:method].to_s.upcase
292
+ headers = request[:headers]
293
+ # Every request but a stream gets its answer before #stop kills this
294
+ # thread; a stream never ends on its own.
295
+ Thread.current[:bridge_answering] = !(stream_match(request[:path]) && method == "GET")
296
+
297
+ if request[:too_large]
298
+ write_json(io, 413, { "Connection" => "close" }, { error: "too_large", detail: "request body over #{MAX_BODY_BYTES} bytes" })
299
+ break
300
+ elsif method == "OPTIONS"
301
+ write_json(io, 204, cors, {})
302
+ elsif (m = stream_match(request[:path])) && method == "GET"
303
+ cursor = reconnect_cursor(headers, request[:query])
304
+ Log.debug(:bridge, "stream", method: method, path: request[:path], client_id: stream_client_id(request[:query]))
305
+ serve_sse(io, m[1], last_event_id: cursor, snapshot: snapshot_requested?(request[:query]),
306
+ client_id: stream_client_id(request[:query]))
307
+ break # SSE owns the connection until the client disconnects.
308
+ elsif (m = cancel_match(request[:path])) && method == "POST"
309
+ payload, status, body = handle_cancel(m[1], request[:body])
310
+ write_json(io, status, payload, body)
311
+ elsif (m = answer_match(request[:path])) && method == "POST"
312
+ payload, status, body = handle_answer(m[1], request[:body])
313
+ write_json(io, status, payload, body)
314
+ elsif (m = dismiss_match(request[:path])) && method == "POST"
315
+ payload, status, body = handle_dismiss_question(m[1], request[:body])
316
+ write_json(io, status, payload, body)
317
+ elsif (m = turn_match(request[:path])) && method == "POST"
318
+ payload, status, body = handle_post_turn(m[1], request[:body])
319
+ write_json(io, status, payload, body)
320
+ elsif (m = command_match(request[:path])) && method == "POST"
321
+ payload, status, body = handle_command(m[1], request[:body])
322
+ write_json(io, status, payload, body)
323
+ elsif (m = exit_match(request[:path])) && method == "POST"
324
+ payload, status, body = handle_exit_request(m[1], request[:body])
325
+ write_json(io, status, payload, body)
326
+ elsif (m = recap_match(request[:path])) && method == "POST"
327
+ payload, status, body = handle_recap(m[1])
328
+ write_json(io, status, payload, body)
329
+ elsif (m = state_match(request[:path])) && method == "GET"
330
+ payload, status, body = handle_state(m[1])
331
+ write_json(io, status, payload, body)
332
+ elsif (m = stats_match(request[:path])) && method == "GET"
333
+ payload, status, body = handle_stats(m[1])
334
+ write_json(io, status, payload, body)
335
+ elsif (m = snapshot_match(request[:path])) && method == "GET"
336
+ payload, status, body = handle_snapshot(m[1])
337
+ write_json(io, status, payload, body)
338
+ else
339
+ write_json(io, 404, { "Allow" => "GET, POST, OPTIONS" },
340
+ { error: "not_found", path: request[:path] })
341
+ end
342
+
343
+ Thread.current[:bridge_answering] = false
344
+ log_request(method, request[:path], started)
345
+ break if close_after_request?(headers)
346
+ end
347
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
348
+ nil
349
+ rescue StandardError => e
350
+ # The thread doesn't report (report_on_exception = false): log it.
351
+ Log.exception(:bridge, "connection_failed", e)
352
+ ensure
353
+ Thread.current[:bridge_answering] = false
354
+ begin
355
+ io.close
356
+ rescue StandardError
357
+ nil
358
+ end
359
+ end
360
+
361
+ # Serve an SSE stream. Owns the connection until the client disconnects.
362
+ # The connection thread IS the writer thread: serve! blocks until then.
363
+ def serve_sse(io, session_id, last_event_id:, snapshot: false, client_id: nil)
364
+ unless own_session?(session_id)
365
+ write_json(io, 404, {}, { error: "unknown_session" })
366
+ return
367
+ end
368
+
369
+ writer = SSEWriter.new(
370
+ engine: @engine,
371
+ ring: @ring,
372
+ session_id: @session_id,
373
+ last_event_id: last_event_id,
374
+ epoch: @epoch,
375
+ snapshot_provider: -> { @engine.session_state_snapshot },
376
+ turn_snapshot_provider: -> { self.snapshot },
377
+ # A reconnect (with a cursor) replays; only a fresh join snapshots.
378
+ join_with_snapshot: snapshot && last_event_id.nil?,
379
+ bridge: self,
380
+ heartbeat_interval: @heartbeat_interval
381
+ )
382
+ @mutex.synchronize do
383
+ @open_streams += 1
384
+ @streams_by_client[client_id] += 1 if client_id
385
+ end
386
+ begin
387
+ writer.serve!(io)
388
+ ensure
389
+ @mutex.synchronize do
390
+ @open_streams -= 1
391
+ if client_id
392
+ @streams_by_client[client_id] -= 1
393
+ @streams_by_client.delete(client_id) unless @streams_by_client[client_id].positive?
394
+ end
395
+ end
396
+ note_client_activity
397
+ end
398
+ end
399
+
400
+ # Never takes the event log: the lock order is events, then @mutex.
401
+ def note_client_activity
402
+ @mutex.synchronize { @last_client_activity_at = monotonic_now }
403
+ end
404
+
405
+ def monotonic_now
406
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
407
+ end
408
+
409
+ def cors
410
+ {
411
+ "Access-Control-Allow-Origin" => "*",
412
+ "Access-Control-Allow-Methods" => "GET, POST, OPTIONS",
413
+ "Access-Control-Allow-Headers" => "Content-Type, Last-Event-ID"
414
+ }
415
+ end
416
+
417
+ def stream_match(path)
418
+ %r|\A/session/([^/]+)/stream\z|u.match(path.to_s)
419
+ end
420
+
421
+ def turn_match(path)
422
+ %r|\A/session/([^/]+)/turn\z|u.match(path.to_s)
423
+ end
424
+
425
+ def state_match(path)
426
+ %r|\A/session/([^/]+)/state\z|u.match(path.to_s)
427
+ end
428
+
429
+ def stats_match(path)
430
+ %r|\A/session/([^/]+)/stats\z|u.match(path.to_s)
431
+ end
432
+
433
+ def snapshot_match(path)
434
+ %r|\A/session/([^/]+)/snapshot\z|u.match(path.to_s)
435
+ end
436
+
437
+ def cancel_match(path)
438
+ %r|\A/session/([^/]+)/cancel\z|u.match(path.to_s)
439
+ end
440
+
441
+ def answer_match(path)
442
+ %r|\A/session/([^/]+)/answer\z|u.match(path.to_s)
443
+ end
444
+
445
+ def dismiss_match(path)
446
+ %r|\A/session/([^/]+)/question/dismiss\z|u.match(path.to_s)
447
+ end
448
+
449
+ def command_match(path)
450
+ %r|\A/session/([^/]+)/command\z|u.match(path.to_s)
451
+ end
452
+
453
+ def exit_match(path)
454
+ %r|\A/session/([^/]+)/exit\z|u.match(path.to_s)
455
+ end
456
+
457
+ def recap_match(path)
458
+ %r|\A/session/([^/]+)/recap\z|u.match(path.to_s)
459
+ end
460
+
461
+ # /recap in an attached TUI: the saved recap, and a new one asked for at
462
+ # once (it arrives as :recap_ready). Answers mid-turn too.
463
+ # 200 {enabled:, saved:, request:, min_user_turns:}. Returns [headers, status, body].
464
+ def handle_recap(session_id)
465
+ return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
466
+
467
+ recap = @engine.recap
468
+ return [{}, 200, { enabled: false }] unless recap
469
+
470
+ saved = @engine.saved_recap
471
+ [{}, 200, { enabled: true, saved: saved, request: @engine.request_recap.to_s, min_user_turns: recap.min_user_turns }]
472
+ rescue StandardError => e
473
+ [{}, 500, { error: "bridge_error", detail: e.message }]
474
+ end
475
+
476
+ # Cancel the active turn on this session's engine, if any.
477
+ # Returns [headers, status, body].
478
+ def handle_cancel(session_id, body)
479
+ unless own_session?(session_id)
480
+ return [{}, 404, { error: "unknown_session" }]
481
+ end
482
+
483
+ # Optional reason from JSON body
484
+ reason = :manual
485
+ if body && !body.strip.empty?
486
+ parsed = parse_json(body)
487
+ r = parsed.is_a?(Hash) ? (fetched(parsed, "reason") || fetched(parsed, "cancellation_reason")) : nil
488
+ reason = CANCEL_REASONS.include?(r.to_s.strip) ? r.to_s.strip.to_sym : :manual
489
+ end
490
+
491
+ if @engine.turn_running? && @engine.active_cancel_controller
492
+ ok = @engine.cancel_current_turn!(reason)
493
+ return [{}, 202, { status: "cancel_requested", session_id: @session_id, reason: reason.to_s }] if ok
494
+
495
+ [{}, 409, { error: "cancel_failed", detail: "could not cancel", session_id: @session_id }]
496
+ else
497
+ [{}, 409, { error: "not_running", detail: "no active turn to cancel", session_id: @session_id }]
498
+ end
499
+ rescue StandardError => e
500
+ [{}, 500, { error: "bridge_error", detail: e.message }]
501
+ end
502
+
503
+ # Answer the pending question. A past +deadline+ (see #handle_post_turn):
504
+ # 408 deadline_passed, and the question stays open. An answer takes no
505
+ # event hold, so it is checked right before it is recorded.
506
+ def handle_answer(session_id, body)
507
+ unless own_session?(session_id)
508
+ return [{}, 404, { error: "unknown_session" }]
509
+ end
510
+ parsed = parse_json(body)
511
+ unless parsed.is_a?(Hash)
512
+ return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }]
513
+ end
514
+ qid = fetched(parsed, "id") || fetched(parsed, "question_id")
515
+ selected = fetched(parsed, "selected") || fetched(parsed, "selection")
516
+ freeform = fetched(parsed, "freeform") || fetched(parsed, "other")
517
+ # Support nested answer
518
+ if parsed["answer"].is_a?(Hash)
519
+ ans = parsed["answer"]
520
+ qid ||= fetched(ans, "id")
521
+ selected ||= fetched(ans, "selected")
522
+ freeform ||= fetched(ans, "freeform")
523
+ end
524
+ if qid.to_s.strip.empty?
525
+ return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "id required" }]
526
+ end
527
+ deadline = fetched(parsed, "deadline")
528
+ return BAD_DEADLINE unless deadline_valid?(deadline)
529
+ return deadline_passed("answer") if expired?("answer_expired", deadline, sid: session_id, id: qid)
530
+
531
+ begin
532
+ result = @engine.answer_question(id: qid, selected: selected, freeform: freeform)
533
+ [{}, 200, { status: "answered", session_id: session_id, answer: result }]
534
+ rescue Engine::QuestionNotPending => e
535
+ # Another client answered first, or the question was cancelled.
536
+ [{}, 409, { error: "question_not_pending", detail: e.message }]
537
+ rescue ArgumentError => e
538
+ [{}, 400, { error: "invalid_answer", detail: e.message }]
539
+ rescue StandardError => e
540
+ [{}, 500, { error: "bridge_error", detail: e.message }]
541
+ end
542
+ end
543
+
544
+ # Dismiss the pending question (an empty answer, as in the REPL): the
545
+ # tool returns without an answer and every UI gets :question_cancelled.
546
+ # Only the question the client saw, and only while nobody has answered
547
+ # it: Engine#cancel_question checks both under the question lock. A past
548
+ # +deadline+ (dismissing an approval denies it): 408, as for an answer.
549
+ # Returns [headers, status, body].
550
+ def handle_dismiss_question(session_id, body)
551
+ return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
552
+
553
+ parsed = parse_json(body)
554
+ return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }] unless parsed.is_a?(Hash)
555
+
556
+ qid = fetched(parsed, "id").to_s
557
+ return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "id required" }] if qid.strip.empty?
558
+
559
+ deadline = fetched(parsed, "deadline")
560
+ return BAD_DEADLINE unless deadline_valid?(deadline)
561
+ return deadline_passed("dismissal") if expired?("dismiss_expired", deadline, sid: session_id, id: qid)
562
+
563
+ dismissed = @engine.cancel_question("dismissed", id: qid)
564
+ return [{}, 409, { error: "question_not_pending", detail: "no pending question #{qid}" }] unless dismissed
565
+
566
+ [{}, 200, { status: "dismissed", id: qid, session_id: @session_id }]
567
+ rescue StandardError => e
568
+ [{}, 500, { error: "bridge_error", detail: e.message }]
569
+ end
570
+
571
+ # Queue a session command (/model, /models, !rollback, !cmd, /continue)
572
+ # for the worker loop, which runs it between turns and announces
573
+ # :command_ran (busy while a turn runs). Answers at once: the queueing
574
+ # and its :command_queued are one step of the event log, so the
575
+ # :command_ran always comes after. Only the syntax is checked here. A
576
+ # past +deadline+ (see #handle_post_turn), checked with the event log
577
+ # held: 408 deadline_passed, not run. Every command is checked, the
578
+ # read-only ones (/models) too: its client said it didn't run.
579
+ # Returns [headers, status, body].
580
+ def handle_command(session_id, body)
581
+ return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
582
+ return [{}, 501, { error: "commands_unavailable" }] unless @on_command
583
+
584
+ parsed = parse_json(body)
585
+ return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }] unless parsed.is_a?(Hash)
586
+
587
+ line = fetched(parsed, "line").to_s.strip
588
+ # The Engine's own commands: the built-ins and its plugins' (a card's
589
+ # action is a plugin command line).
590
+ unless command_registry.command?(line)
591
+ known = command_registry.entries.reject(&:local).map { |entry| SessionCommands.display_name(entry) }.sort
592
+ detail = "not a session command: #{line[0, 80]} (known: #{known.join(", ")}; /help lists them)"
593
+ return [{ "Allow" => "POST" }, 400, { error: "unknown_command", detail: detail }]
594
+ end
595
+
596
+ deadline = fetched(parsed, "deadline")
597
+ return BAD_DEADLINE unless deadline_valid?(deadline)
598
+
599
+ command = { command_id: SecureRandom.uuid, client_id: fetched(parsed, "client_id"), line: line }
600
+ queued = @engine.synchronize_events do
601
+ next false if expired?("command_expired", deadline, sid: session_id, client_id: command[:client_id])
602
+
603
+ @on_command.call(command)
604
+ # An anytime command runs now, beside a turn (D8): the UIs show its
605
+ # line here, so the cards it shows come after it.
606
+ anytime = command_registry.lookup(line)&.anytime ? { anytime: true } : {}
607
+ @engine.announce(type: :command_queued, **command, **anytime)
608
+ true
609
+ end
610
+ return deadline_passed("command") unless queued
611
+
612
+ [{}, 202, { status: "accepted", command_id: command[:command_id], session_id: @session_id }]
613
+ rescue StandardError => e
614
+ [{}, 500, { error: "bridge_error", detail: e.message }]
615
+ end
616
+
617
+ # A client asks the worker to exit now (`/exit` in the attached TUI). The
618
+ # worker decides with the event log held, so no POST /turn lands between
619
+ # its check and its answer; it leaves from its loop after this reply.
620
+ # 200 {status: "exiting", discard?: the session is empty and goes}, or 409 {status: "held", reason:} naming what
621
+ # keeps it up. Returns [headers, status, body].
622
+ def handle_exit_request(session_id, body)
623
+ return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
624
+ return [{}, 501, { error: "exit_unavailable" }] unless @on_exit_request
625
+
626
+ parsed = parse_json(body)
627
+ return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }] unless parsed.is_a?(Hash)
628
+
629
+ client_id = fetched(parsed, "client_id")
630
+ delete = fetched(parsed, "delete") == true
631
+ reason = @engine.synchronize_events { @on_exit_request.call(client_id, delete: delete) }
632
+ if reason.nil?
633
+ body = { status: "exiting", session_id: @session_id }
634
+ body[:discard] = @exit_discards.call == true if @exit_discards
635
+ return [{}, 200, body]
636
+ end
637
+
638
+ [{}, 409, { status: "held", reason: reason.to_s, session_id: @session_id }]
639
+ rescue StandardError => e
640
+ [{}, 500, { error: "bridge_error", detail: e.message }]
641
+ end
642
+
643
+ # Create a turn via file IPC (fire-and-forget). Returns [headers, status, body].
644
+ #
645
+ # A +deadline+ (wall-clock epoch seconds; the client shares this machine's
646
+ # clock) is when the client stops waiting (BridgeClient#post_turn): a turn
647
+ # past it waited in the socket while this worker was frozen (a sleeping
648
+ # Mac, SIGSTOP) and its client has already said it was not sent, so it is
649
+ # dropped with 408 deadline_passed. It is checked with the event log held,
650
+ # right before the write, so nothing that holds the log (an exit check)
651
+ # can delay an accepted turn past it. No deadline (an older client): taken.
652
+ def handle_post_turn(session_id, body)
653
+ parsed = parse_json(body)
654
+ unless parsed.is_a?(Hash)
655
+ return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }]
656
+ end
657
+
658
+ sid = fetched(parsed, "session_id")
659
+ prompt = fetched(parsed, "prompt")
660
+ client_id = fetched(parsed, "client_id")
661
+ # --no-interrupt: the turn runs with the raised iteration limit.
662
+ no_interrupt = fetched(parsed, "no_interrupt") == true
663
+ if sid.to_s.strip.empty? || prompt.to_s.strip.empty?
664
+ return [{ "Allow" => "POST" }, 400,
665
+ { error: "missing_fields", detail: "session_id and prompt are required" }]
666
+ end
667
+ images = turn_images(sid, fetched(parsed, "images"))
668
+ return [{}, 400, { error: "bad_images", detail: images }] if images.is_a?(String)
669
+
670
+ deadline = fetched(parsed, "deadline")
671
+ return BAD_DEADLINE unless deadline_valid?(deadline)
672
+
673
+ enqueued_id = SecureRandom.uuid
674
+ enqueued =
675
+ if own_session?(sid)
676
+ # Write and announce with the event log held: the worker can't
677
+ # emit this turn's :turn_started (or merge it mid-turn) before
678
+ # :turn_enqueued, and a failed write announces nothing.
679
+ @engine.synchronize_events do
680
+ next :expired if expired?("turn_expired", deadline, sid: sid, client_id: client_id)
681
+
682
+ enqueue_turn(session_id: sid, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
683
+ no_interrupt: no_interrupt, images: images).tap do |ok|
684
+ next unless ok
685
+
686
+ enqueued_event = { type: :turn_enqueued, enqueued_id: enqueued_id, client_id: client_id, prompt: prompt.to_s }
687
+ enqueued_event[:images] = images unless images.empty?
688
+ @engine.announce(enqueued_event)
689
+ @on_input&.call
690
+ end
691
+ end
692
+ elsif expired?("turn_expired", deadline, sid: sid, client_id: client_id)
693
+ :expired
694
+ else
695
+ enqueue_turn(session_id: sid, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
696
+ no_interrupt: no_interrupt, images: images)
697
+ end
698
+ return deadline_passed("turn") if enqueued == :expired
699
+ return [{}, 500, { error: "enqueue_failed", detail: "could not write turn input" }] unless enqueued
700
+
701
+ [{}, 202, { status: "accepted", enqueued_id: enqueued_id, session_id: sid }]
702
+ rescue StandardError => e
703
+ # SessionManager loads lazily (enqueue_turn).
704
+ if defined?(SessionManager::ImagesUnsupported) && e.is_a?(SessionManager::ImagesUnsupported)
705
+ return [{}, 409, { error: "images_unsupported", detail: e.message }]
706
+ end
707
+
708
+ [{}, 500, { error: "bridge_error", detail: e.message }]
709
+ end
710
+
711
+ # Read-only snapshot surface. AC #4: too-old reconnects re-derive state
712
+ # from here.
713
+ def handle_state(session_id)
714
+ unless own_session?(session_id)
715
+ return [{}, 404, { error: "unknown_session" }]
716
+ end
717
+
718
+ state = @engine.session_state_snapshot
719
+ [{}, 200, { session_id: @session_id, session_state_snapshot: state.merge(event_id: event_id(state[:event_seq])) }]
720
+ end
721
+
722
+ # /stats for an attached client: the metrics, with the window and prompt
723
+ # profile asked from the server when no turn has reported them yet (so,
724
+ # unlike /state, it may wait on one short /props GET).
725
+ def handle_stats(session_id)
726
+ return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
727
+
728
+ [{}, 200, { session_id: @session_id, metrics: @engine.stats_snapshot }]
729
+ end
730
+
731
+ # The snapshot frame's content as one request, for a client that renders
732
+ # the messages elsewhere (the web server strips and formats them): it then
733
+ # streams from the snapshot's event_seq, and the ring replays what came
734
+ # after (or the stream resets). Returns [headers, status, body].
735
+ def handle_snapshot(session_id)
736
+ return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
737
+
738
+ body = @engine.synchronize_events do
739
+ snap = snapshot
740
+ state = @engine.session_state_snapshot.merge(event_seq: snap[:event_seq], event_id: snap[:event_id])
741
+ { snapshot: snap, session_state_snapshot: state }
742
+ end
743
+ [{}, 200, body]
744
+ end
745
+
746
+ # The stream cursor for +seq+ in this worker.
747
+ def event_id(seq)
748
+ EventId.format(seq.to_i, @epoch)
749
+ end
750
+
751
+ # A per-session bridge only ever owns one Engine (for @session_id). The
752
+ # stream and state surfaces must serve that session and nothing else —
753
+ # serving a different id's data (or closing with no response) would be a
754
+ # cross-session leak. POST/turn enqueue stays lenient (fire-and-forget to
755
+ # another worker's input dir) and validates via write_turn_input.
756
+ def own_session?(session_id)
757
+ session_id.to_s == @session_id.to_s
758
+ end
759
+
760
+ # A request's +deadline+ (see #handle_post_turn) is epoch seconds or absent.
761
+ def deadline_valid?(deadline) = deadline.nil? || deadline.is_a?(Numeric)
762
+
763
+ # Whether a request's +deadline+ has passed; logs +event+ with +fields+
764
+ # and how late it was when it has.
765
+ def expired?(event, deadline, **fields)
766
+ return false if deadline.nil?
767
+
768
+ late = Time.now.to_f - deadline
769
+ return false unless late.positive?
770
+
771
+ Log.warn(:bridge, event, **fields, late: late.round(1))
772
+ true
773
+ end
774
+
775
+ # The 408 for a request read after its deadline: +what+ (turn, command,
776
+ # answer, dismissal) was dropped.
777
+ def deadline_passed(what)
778
+ [{}, 408, { error: "deadline_passed", detail: "the #{what} arrived after its client stopped waiting; not run" }]
779
+ end
780
+
781
+ # Write a turn into the target session's input dir, reusing the file IPC
782
+ # the worker polls. Never calls run_turn across the boundary.
783
+ def enqueue_turn(session_id:, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [])
784
+ require_relative "session_manager"
785
+ Samagotchi::SessionManager.write_turn_input(
786
+ session_id, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id, no_interrupt: no_interrupt,
787
+ state_dir: @state_dir, images: images
788
+ )
789
+ rescue LoadError
790
+ # SessionManager not available (e.g. bridge used standalone in a spec).
791
+ false
792
+ end
793
+
794
+ # A turn's images as [{file:, name:}], or a String saying what's wrong.
795
+ # Only refs to files already in that session's images/ pass (a web
796
+ # upload): never a path, so no client can make the worker read a file.
797
+ def turn_images(session_id, raw)
798
+ return [] if raw.nil?
799
+ return "images must be a list" unless raw.is_a?(Array)
800
+ return "at most #{MAX_TURN_IMAGES} images" if raw.size > MAX_TURN_IMAGES
801
+
802
+ session_dir = Session.session_dir(session_id, state_dir: @state_dir || Session.default_state_dir)
803
+ raw.map do |image|
804
+ return "each image must be {file:, name:}" unless image.is_a?(Hash)
805
+
806
+ ref = ImageStore.symbolize(image)
807
+ return "images are refs to uploaded files, not paths" if ref.key?(:path)
808
+ return "unknown image #{ref[:file].to_s[0, 80]}" unless ImageStore.valid_ref?(session_dir, ref)
809
+
810
+ { file: ref[:file].to_s, name: File.basename(ref[:name].to_s)[0, 120] }
811
+ end
812
+ end
813
+
814
+ # ── HTTP plumbing ────────────────────────────────────────────────────────
815
+
816
+ def read_request(io)
817
+ request_line = io.gets
818
+ return nil if request_line.nil? || request_line.empty?
819
+
820
+ method, target, = request_line.split(" ")
821
+ return nil if method.nil? || target.nil?
822
+
823
+ headers = {}
824
+ content_length = 0
825
+ while (line = io.gets)
826
+ break if line == "\r\n" || line == "\n"
827
+
828
+ key, value = line.split(":", 2)
829
+ next unless key
830
+
831
+ headers[key.strip.downcase] = value.to_s.strip
832
+ content_length = value.to_s.to_i if key.strip.downcase == "content-length"
833
+ end
834
+
835
+ # Nothing a client sends is this big (images travel as refs): the body
836
+ # is skipped, not kept, so the client reads a 413 rather than a reset.
837
+ too_large = content_length > MAX_BODY_BYTES
838
+ skip_body(io, content_length) if too_large
839
+ body = content_length > 0 && !too_large ? io.read(content_length) : nil
840
+ path, query = split_target(target)
841
+ { method: method, path: path, query: query, headers: headers, body: body, too_large: too_large }
842
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
843
+ nil
844
+ end
845
+
846
+ def skip_body(io, length)
847
+ left = [length, 32 * MAX_BODY_BYTES].min
848
+ while left.positive?
849
+ chunk = io.read([left, 65_536].min)
850
+ break if chunk.nil? || chunk.empty?
851
+
852
+ left -= chunk.bytesize
853
+ end
854
+ end
855
+
856
+ def split_target(target)
857
+ query = nil
858
+ path = target
859
+ if target.include?("?")
860
+ path, query = target.split("?", 2)
861
+ end
862
+ [URI.decode_www_form_component(path), query]
863
+ rescue StandardError
864
+ [target, nil]
865
+ end
866
+
867
+ # One debug line per answered request (never its body): what #write_json
868
+ # last sent on this connection's thread.
869
+ def log_request(method, path, started)
870
+ return unless Log.level?(:debug)
871
+
872
+ Log.debug(:bridge, "request", method: method, path: path, status: Thread.current[:bridge_status],
873
+ ms: ((monotonic_now - started) * 1000).round)
874
+ end
875
+
876
+ def write_json(io, status, extra_headers, body)
877
+ Thread.current[:bridge_status] = status
878
+ extra_headers ||= {}
879
+ body ||= {}
880
+ data = JSON.generate(body)
881
+ reason = HTTP_REASONS.fetch(status, "OK")
882
+ headers = {
883
+ "Content-Type" => "application/json",
884
+ "Content-Length" => data.bytesize.to_s,
885
+ "Connection" => "close",
886
+ "Cache-Control" => "no-store",
887
+ "Access-Control-Allow-Origin" => "*"
888
+ }.merge(extra_headers)
889
+
890
+ io.write("HTTP/1.1 #{status} #{reason}\r\n")
891
+ headers.each { |k, v| io.write("#{k}: #{v}\r\n") }
892
+ io.write("\r\n")
893
+ io.write(data) unless body.nil? || status == 204
894
+ io.flush
895
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
896
+ nil
897
+ end
898
+
899
+ # Parse a JSON body defensively; returns nil on failure.
900
+ def parse_json(body)
901
+ return nil if body.nil? || body.strip.empty?
902
+
903
+ JSON.parse(body)
904
+ rescue JSON::ParserError
905
+ nil
906
+ end
907
+
908
+ def fetched(hash, key)
909
+ hash[key] || hash[key.to_sym]
910
+ end
911
+
912
+ # Resume cursor for SSE: prefer the browser's native `Last-Event-ID`
913
+ # header (sent automatically on reconnect because we emit `id:` frames),
914
+ # else fall back to the explicit `?from_seq=` query param used by clients
915
+ # that remember their own cursor.
916
+ def reconnect_cursor(headers, query)
917
+ cursor = headers["last-event-id"]
918
+ return cursor if cursor && !cursor.empty?
919
+
920
+ return nil unless query
921
+
922
+ URI.decode_www_form(query).to_h["from_seq"]
923
+ rescue StandardError
924
+ nil
925
+ end
926
+
927
+ # `?snapshot=1`: join with a snapshot frame instead of a replay.
928
+ def snapshot_requested?(query)
929
+ return false unless query
930
+
931
+ URI.decode_www_form(query).to_h["snapshot"].to_s == "1"
932
+ rescue StandardError
933
+ false
934
+ end
935
+
936
+ # `?client_id=`: whose stream it is (see #open_streams_except).
937
+ def stream_client_id(query)
938
+ return nil unless query
939
+
940
+ id = URI.decode_www_form(query).to_h["client_id"].to_s
941
+ id.empty? ? nil : id
942
+ rescue StandardError
943
+ nil
944
+ end
945
+
946
+ def close_after_request?(headers)
947
+ headers["connection"].to_s.downcase == "close"
948
+ end
949
+
950
+ def write_sidecar
951
+ record = {
952
+ "port" => @port,
953
+ "bind" => @bind,
954
+ "session_id" => @session_id,
955
+ "started_at" => Time.now.iso8601(3)
956
+ }
957
+ record["input_format"] = @input_format if @input_format
958
+ path = File.join(session_dir, SIDECAR_FILE)
959
+ FileUtils.mkdir_p(session_dir)
960
+ temp = "#{path}.tmp"
961
+ File.write(temp, JSON.pretty_generate(record) + "\n")
962
+ File.rename(temp, path)
963
+ rescue StandardError => e
964
+ Log.warn(:bridge, "sidecar_write_failed", echo: "Bridge: failed to write #{SIDECAR_FILE}: #{e.class}: #{e.message}", error: e.class.name)
965
+ end
966
+
967
+ # Only while it still names this bridge: a racing worker for the same
968
+ # session may have written its own since.
969
+ def remove_sidecar
970
+ path = File.join(session_dir, SIDECAR_FILE)
971
+ return unless @server && File.file?(path)
972
+
973
+ File.unlink(path) if JSON.parse(File.read(path))["port"].to_i == @port
974
+ rescue StandardError
975
+ nil
976
+ end
977
+
978
+ def session_dir
979
+ Session.session_dir(@session_id, state_dir: @state_dir)
980
+ end
981
+
982
+ HTTP_REASONS = {
983
+ 200 => "OK",
984
+ 202 => "Accepted",
985
+ 204 => "No Content",
986
+ 400 => "Bad Request",
987
+ 404 => "Not Found",
988
+ 409 => "Conflict",
989
+ 500 => "Internal Server Error",
990
+ 501 => "Not Implemented"
991
+ }.freeze
992
+ end
993
+ end