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,1349 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape" # CGI.escapeHTML; Ruby 4.0 ships only cgi/escape
4
+ require "fileutils"
5
+ require "json"
6
+ require "time"
7
+ require "uri"
8
+ require "rack"
9
+ require "rack/request"
10
+
11
+ require_relative "../bridge_client"
12
+ require_relative "../bridge/bounded_queue"
13
+ require_relative "../session"
14
+ require_relative "../session_manager"
15
+ require_relative "../session_commands"
16
+ require_relative "../host_registry"
17
+ require_relative "../model_profile"
18
+ require_relative "../project_scope"
19
+ require_relative "../version"
20
+ require_relative "../output_formatter"
21
+ require_relative "../image_store"
22
+ require_relative "../context_note"
23
+ require_relative "../recap_store"
24
+ require_relative "markdown_renderer"
25
+ require_relative "message_parts"
26
+ require_relative "session_summary"
27
+ require_relative "../log"
28
+
29
+ module Samagotchi
30
+ module Web
31
+ # Rack application that serves the Web UI and JSON API.
32
+ #
33
+ # This is the single-port control plane for Chi Web. It never constructs
34
+ # an Engine directly — it talks to SessionManager over the file IPC layer.
35
+ # Live SSE is proxied from each session's Bridge
36
+ # (the single live client transport); history for any session — including
37
+ # dead ones — is served by GET /api/sessions/:id/output from output/ files.
38
+ class App
39
+ DEFAULT_HOST = BridgeClient::HOST
40
+ BRIDGE_WAIT_TIMEOUT = 10.0
41
+ # An uploaded image may be this big before it is downscaled.
42
+ MAX_IMAGE_UPLOAD_BYTES = 20 * 1024 * 1024
43
+ SESSION_ID_RE = /\A[0-9A-Za-z-]{1,64}\z/
44
+ IMAGE_NAME_RE = /\A[0-9a-f]{16}\.(png|jpe?g|gif|webp)\z/
45
+ IMAGE_TYPES = { "png" => "image/png", "jpg" => "image/jpeg", "jpeg" => "image/jpeg", "gif" => "image/gif",
46
+ "webp" => "image/webp" }.freeze
47
+ # The cancel reasons a client may give, as the Bridge takes them
48
+ # (Bridge::CANCEL_REASONS); anything else is sent and echoed as manual.
49
+ CANCEL_REASONS = %w[manual user ctrl_c].freeze
50
+
51
+ # POST /stop waits this long for the worker to let go of the session.
52
+ STOP_WAIT_SECONDS = 2.0
53
+
54
+ # GET /api/events: a `: ping` this often while idle, a queue this deep
55
+ # per tab (an overflow ends the connection; the reconnect's snapshot
56
+ # is the recovery), and the loop's longest wait before it looks at
57
+ # whether the hub or the server is shutting down.
58
+ EVENTS_HEARTBEAT = 15.0
59
+ EVENTS_QUEUE = 256
60
+ # Seconds GET /api/models waits for the hosts' lists before answering
61
+ # with what it has (the listing goes on and fills the registry's cache).
62
+ MODELS_WAIT_TIMEOUT = 4.0
63
+ EVENTS_POLL = 1.0
64
+
65
+ # A callable the event loops ask whether the server still runs;
66
+ # Server.start points it at WEBrick's status once it has the server.
67
+ attr_writer :server_running
68
+
69
+ # @param bridge_wait_timeout [Float] bounded seconds to wait for a
70
+ # freshly-spawned worker's bridge before answering POST /api/sessions.
71
+ # @param turn_view [Boolean] the page's per-turn view (web.turn_view,
72
+ # the default); false is the classic row of bubbles; ?view=turn|chat
73
+ # overrides it for one page load
74
+ # @param hub [SessionHub, nil] the session projection GET /api/events
75
+ # streams from; without one the route answers 503
76
+ # @param registry [HostRegistry, nil] the hosts GET /api/models lists
77
+ # (built from the config on first use)
78
+ # @param models_wait_timeout [Float] bounded seconds GET /api/models
79
+ # waits for the hosts' lists
80
+ def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
81
+ bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, turn_view: true, hub: nil,
82
+ events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
83
+ registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT)
84
+ @manager = manager || SessionManager
85
+ @registry = registry
86
+ @models_wait_timeout = models_wait_timeout
87
+ @models_mutex = Mutex.new
88
+ @models_thread = nil
89
+ @session_class = session_class || Session
90
+ @state_dir = state_dir
91
+ @public_dir = public_dir || File.expand_path("public", __dir__)
92
+ @bridge_wait_timeout = bridge_wait_timeout
93
+ @markdown_renderer = MarkdownRenderer.new(enabled: markdown)
94
+ @turn_view = turn_view
95
+ @hub = hub
96
+ @events_heartbeat = events_heartbeat
97
+ @events_queue = events_queue
98
+ @server_running = -> { true }
99
+ end
100
+
101
+ def call(env)
102
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
103
+ response = route(env)
104
+ log_request(env, response, started)
105
+ response
106
+ end
107
+
108
+ private
109
+
110
+ def route(env)
111
+ req = Rack::Request.new(env)
112
+ return forbidden unless localhost?(req)
113
+
114
+ case [req.request_method, req.path_info]
115
+ when ["GET", "/"], ["GET", "/index.html"]
116
+ serve_index(req)
117
+ when ["GET", "/api/sessions"]
118
+ handle_list(req)
119
+ when ["POST", "/api/sessions"]
120
+ handle_create(req)
121
+ when ["GET", "/api/info"]
122
+ handle_info
123
+ when ["GET", "/api/models"]
124
+ handle_models
125
+ when ["GET", "/api/events"]
126
+ handle_events(req)
127
+ else
128
+ # Dynamic routes
129
+ if (m = %r{\A/api/sessions/([^/]+)/stream\z}.match(req.path_info)) && req.get?
130
+ return handle_stream(req, m[1])
131
+ end
132
+ if (m = %r{\A/api/sessions/([^/]+)/output\z}.match(req.path_info)) && req.get?
133
+ return handle_output(req, m[1])
134
+ end
135
+ if (m = %r{\A/api/sessions/([^/]+)/cancel\z}.match(req.path_info)) && req.post?
136
+ return handle_cancel(req, m[1])
137
+ end
138
+ if (m = %r{\A/api/sessions/([^/]+)/stop\z}.match(req.path_info)) && req.post?
139
+ return handle_stop(req, m[1])
140
+ end
141
+ if (m = %r{\A/api/sessions/([^/]+)/turn\z}.match(req.path_info)) && req.post?
142
+ return handle_turn(req, m[1])
143
+ end
144
+ if (m = %r{\A/api/sessions/([^/]+)/answer\z}.match(req.path_info)) && req.post?
145
+ return handle_question_answer(req, m[1])
146
+ end
147
+ if (m = %r{\A/api/sessions/([^/]+)/question/dismiss\z}.match(req.path_info)) && req.post?
148
+ return handle_question_dismiss(req, m[1])
149
+ end
150
+ if (m = %r{\A/api/sessions/([^/]+)/command\z}.match(req.path_info)) && req.post?
151
+ return handle_command(req, m[1])
152
+ end
153
+ if (m = %r{\A/api/sessions/([^/]+)/images\z}.match(req.path_info)) && req.post?
154
+ return handle_image_upload(req, m[1])
155
+ end
156
+ if (m = %r{\A/api/sessions/([^/]+)/images/([^/]+)\z}.match(req.path_info)) && req.get?
157
+ return handle_image(req, m[1], m[2])
158
+ end
159
+ if (m = %r{\A/api/sessions/([^/]+)\z}.match(req.path_info)) && req.get?
160
+ return handle_show(req, m[1])
161
+ end
162
+ if (m = %r{\A/api/sessions/([^/]+)\z}.match(req.path_info)) && req.delete?
163
+ return handle_delete(req, m[1])
164
+ end
165
+ if req.path_info.start_with?("/assets/") || req.path_info.start_with?("/public/")
166
+ return serve_static(req)
167
+ end
168
+ # Fallback static file serve from public_dir (e.g. /style.css)
169
+ maybe_static = serve_static(req)
170
+ return maybe_static if maybe_static[0] != 404
171
+
172
+ not_found(path: req.path_info)
173
+ end
174
+ rescue StandardError => e
175
+ Log.exception(:web, "request_failed", e, method: env["REQUEST_METHOD"], path: env["PATH_INFO"])
176
+ error_response(500, "internal_error", e.message)
177
+ end
178
+
179
+ # API requests at debug level (not the page and its assets): method,
180
+ # path, status, time. Never a body or the query; only a tail=1 read
181
+ # (the page's end-of-turn re-read) is marked, to tell it from a full one.
182
+ def log_request(env, response, started)
183
+ path = env["PATH_INFO"].to_s
184
+ return unless path.start_with?("/api/") && Log.level?(:debug)
185
+
186
+ tail = env["QUERY_STRING"].to_s.split("&").include?("tail=1") ? { tail: true } : {}
187
+ Log.debug(:web, "request", sid: path[%r{\A/api/sessions/([^/]+)}, 1], method: env["REQUEST_METHOD"], path: path,
188
+ status: response[0], **tail,
189
+ ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
190
+ end
191
+
192
+ def localhost?(req)
193
+ # Only 127.0.0.1 / ::1 / localhost are allowed. The socket is bound to
194
+ # 127.0.0.1, but an explicit Host check prevents DNS-rebind tricks.
195
+ host = req.host.to_s.downcase.split(":").first
196
+ %w[127.0.0.1 ::1 localhost].include?(host)
197
+ end
198
+
199
+ def forbidden
200
+ error_response(403, "forbidden", "only 127.0.0.1 is allowed")
201
+ end
202
+
203
+ # The page's scope: ?dir=<folder> lists that folder's project (every
204
+ # session when the folder is in no repo). A dir that isn't an existing
205
+ # absolute folder answers 400 before anything else runs.
206
+ def handle_list(req)
207
+ dir, error = scope_dir(req.params["dir"])
208
+ return error if error
209
+
210
+ scope = {}
211
+ root = dir && ProjectScope.root_for(dir)
212
+ scope[:project_root] = root if root
213
+ # Lazy retention sweep (once per 24h)
214
+ if @manager.respond_to?(:retention_sweep_if_due)
215
+ begin
216
+ @manager.retention_sweep_if_due(state_dir: @state_dir)
217
+ rescue StandardError
218
+ nil
219
+ end
220
+ end
221
+ sort = sanitize_sort(req.params["sort"])
222
+ order = sanitize_order(req.params["order"])
223
+ limit = sanitize_limit(req.params["limit"])
224
+ offset = sanitize_offset(req.params["offset"])
225
+ return list_from_hub(root, sort: sort, order: order, limit: limit, offset: offset) if @hub
226
+
227
+ sessions = if @state_dir
228
+ @manager.list_sessions(state_dir: @state_dir, sort: sort, order: order, limit: limit, offset: offset,
229
+ **scope)
230
+ else
231
+ @manager.list_sessions(sort: sort, order: order, limit: limit, offset: offset, **scope)
232
+ end
233
+ roots = {}
234
+ payload = sessions.map do |s|
235
+ owner = session_owner(s.id)
236
+ session_to_json(s, status: displayed_status(s, owner: owner), owner: owner, root_cache: roots)
237
+ end
238
+ # Expose total via header for pagination (total unordered count)
239
+ headers = { "Content-Type" => "application/json; charset=utf-8", "Cache-Control" => "no-store", "Access-Control-Allow-Origin" => "*" }
240
+ # Compute total without limit/offset for header
241
+ if limit || offset.positive?
242
+ total = if @state_dir
243
+ @manager.list_sessions(state_dir: @state_dir, sort: sort, order: order, **scope).size
244
+ else
245
+ @manager.list_sessions(sort: sort, order: order, **scope).size
246
+ end
247
+ headers["X-Total-Count"] = total.to_s
248
+ end
249
+ body = JSON.generate(payload)
250
+ headers["Content-Length"] = body.bytesize.to_s
251
+ [200, headers, [body]]
252
+ end
253
+
254
+ # The list from the hub's projection (the page's fallback and the
255
+ # first paint): the same sort, paging and total as from the files.
256
+ def list_from_hub(root, sort:, order:, limit:, offset:)
257
+ all = @hub.snapshot(project_root: root, sort: sort, order: order)
258
+ page = all.drop(offset)
259
+ page = page.first(limit) if limit
260
+ headers = { "Content-Type" => "application/json; charset=utf-8", "Cache-Control" => "no-store", "Access-Control-Allow-Origin" => "*" }
261
+ headers["X-Total-Count"] = all.size.to_s if limit || offset.positive?
262
+ body = JSON.generate(page)
263
+ headers["Content-Length"] = body.bytesize.to_s
264
+ [200, headers, [body]]
265
+ end
266
+
267
+ # A scope folder from the page: [dir, nil] (dir nil when none was
268
+ # given), or [nil, a 400 response] when it isn't an existing absolute
269
+ # folder (a bookmarked worktree deleted since, a typo).
270
+ def scope_dir(value)
271
+ return [nil, nil] if value.nil? || value.to_s.strip.empty?
272
+
273
+ dir = value.to_s
274
+ unless dir.start_with?("/") && File.directory?(dir)
275
+ return [nil, error_response(400, "invalid_dir", "not a folder: #{dir}")]
276
+ end
277
+
278
+ [File.expand_path(dir), nil]
279
+ end
280
+
281
+ # What a second `chi web` probes before starting its own server: this
282
+ # is chi web, and it knows ?dir (features).
283
+ def handle_info
284
+ json_response(200, { app: "chi-web", version: Samagotchi::VERSION, pid: Process.pid, cwd: Dir.pwd,
285
+ features: ["dir"] })
286
+ end
287
+
288
+ # The models a new session can start on, spelled as chi spells them
289
+ # elsewhere: bare for the default host (what default.model holds), and
290
+ # host:model for another host. The default is the configured model, or
291
+ # what spawn_session gives a session today. The hosts' lists come from
292
+ # the registry's cache (60 s; 10 min for a remote host); the route waits
293
+ # a bounded time for a fresh listing and answers with what it has, a
294
+ # host that failed or the wait running out noted in +warning+, never a
295
+ # failure: the default alone is enough for the page.
296
+ def handle_models
297
+ default = begin
298
+ ModelProfile.required_model_name
299
+ rescue StandardError
300
+ nil
301
+ end
302
+ models = []
303
+ warnings = []
304
+ begin
305
+ registry = host_registry
306
+ results = await_model_lists(registry)
307
+ if results.nil?
308
+ warnings << "the hosts are still listing their models"
309
+ else
310
+ default_host = registry.default_entry&.name
311
+ ordered = results.keys.sort_by { |name| [name == default_host ? 0 : 1, name] }
312
+ ordered.each do |host|
313
+ data = results[host]
314
+ if data[:error]
315
+ warnings << "#{host}: #{data[:error]}"
316
+ next
317
+ end
318
+ Array(data[:models]).each do |info|
319
+ id = info.id.to_s
320
+ next if id.strip.empty? || id.end_with?(":batch")
321
+
322
+ models << { name: host == default_host ? id : "#{host}:#{id}", host: host, id: id }
323
+ end
324
+ end
325
+ end
326
+ rescue StandardError => e
327
+ warnings << e.message
328
+ end
329
+ if default && models.none? { |m| m[:name].casecmp?(default) }
330
+ models.unshift({ name: default, host: nil, id: default })
331
+ end
332
+ payload = { default: default, models: models }
333
+ payload[:warning] = warnings.join("; ") unless warnings.empty?
334
+ json_response(200, payload)
335
+ end
336
+
337
+ def host_registry
338
+ @models_mutex.synchronize { @registry ||= HostRegistry.new }
339
+ end
340
+
341
+ # The hosts' model lists (cached ones as they are), or the last complete
342
+ # listing (nil when there is none) when this one is not done within the
343
+ # wait. One listing runs at a time; a request that finds one running
344
+ # waits on it. A listing that raised raises here.
345
+ def await_model_lists(registry)
346
+ thread = @models_mutex.synchronize do
347
+ unless @models_thread&.alive?
348
+ @models_thread = Thread.new { registry.list_all_models(force: false) }
349
+ @models_thread.report_on_exception = false
350
+ end
351
+ @models_thread
352
+ end
353
+ thread.join(@models_wait_timeout) ? thread.value : registry.cached_results
354
+ end
355
+
356
+ def sanitize_sort(val)
357
+ %w[created_at updated_at].include?(val.to_s) ? val.to_s : "updated_at"
358
+ end
359
+
360
+ def sanitize_order(val)
361
+ %w[asc desc].include?(val.to_s) ? val.to_s : "desc"
362
+ end
363
+
364
+ def sanitize_limit(val)
365
+ return nil if val.nil? || val.to_s.strip.empty?
366
+ n = val.to_i
367
+ return nil if n <= 0
368
+ [n, 1000].min
369
+ end
370
+
371
+ def sanitize_offset(val)
372
+ return 0 if val.nil? || val.to_s.strip.empty?
373
+ n = val.to_i
374
+ n.positive? ? n : 0
375
+ end
376
+
377
+ def handle_create(req)
378
+ body = parse_json(request_body(req))
379
+ unless body.is_a?(Hash)
380
+ return error_response(400, "invalid_json", "invalid JSON body")
381
+ end
382
+ prompt = body["prompt"] || body[:prompt]
383
+ # idle: a session with no first turn, for a first message with
384
+ # images (the page uploads them into it, then sends the turn).
385
+ idle = body["idle"] == true
386
+ if prompt.to_s.strip.empty? && !idle
387
+ return error_response(400, "missing_fields", "prompt is required")
388
+ end
389
+ # dir: the folder the chat starts in (the page's scope); without it,
390
+ # the server's own cwd.
391
+ dir, error = scope_dir(body["dir"])
392
+ return error if error
393
+
394
+ folder = dir ? { working_directory: dir } : {}
395
+ # model: the model the session starts on (the start page's picker,
396
+ # spelled as GET /api/models lists it); blank means the default.
397
+ model = (body["model"] || body["model_name"]).to_s.strip
398
+ folder[:model_name] = model unless model.empty?
399
+ begin
400
+ session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
401
+ rescue ArgumentError => e
402
+ return error_response(400, "invalid_model", e.message) if e.message.match?(/SAMAGOTCHI_DEFAULT_MODEL/)
403
+ raise
404
+ end
405
+ # The worker's Bridge is the single live transport: wait (bounded) for
406
+ # it so the client can attach without client-side polling.
407
+ port = await_bridge_port(session.id)
408
+ # The projection holds it before the page hears back (no tick wait).
409
+ @hub&.touch(session.id)
410
+ json_response(201, session_to_json(session).merge(bridge_port: port))
411
+ end
412
+
413
+ def handle_show(req, id)
414
+ session = @session_class.load(id, state_dir: default_state_dir)
415
+ # Read-only preview: selecting a session never spawns a worker.
416
+ # The worker is woken only on POST /turn (handle_turn) via
417
+ # SessionManager.resume_session + write_turn_input. This avoids
418
+ # replaying last_prompt and spamming workers on preview scrub.
419
+ # last_event_seq is nil when no live bridge, so the frontend stays
420
+ # silent until the first Send.
421
+ #
422
+ # A live worker's snapshot is the truth (the session file lags its
423
+ # Engine): the messages, the turn in progress and the prompts queued
424
+ # behind it, all at one event_seq the client streams on from.
425
+ live = bridge_get_json(id, "snapshot")
426
+ turn_snapshot = live && live["snapshot"]
427
+ # ?cards=1: the page's re-read when a card arrives, for its rendered
428
+ # body (the stream carries the card's plain text only).
429
+ if req.params["cards"] == "1"
430
+ return json_response(200, { cards: cards_for_display(turn_snapshot) })
431
+ end
432
+ snapshot = live && live["session_state_snapshot"]
433
+ last_event_seq = snapshot ? snapshot["event_seq"] : bridge_event_seq(id)
434
+ current_turn = turn_snapshot && turn_snapshot["current_turn"]
435
+ pending = if turn_snapshot
436
+ current_turn && current_turn["pending_question"]
437
+ elsif session.respond_to?(:pending_question)
438
+ session.pending_question
439
+ end
440
+ owner = session_owner(id)
441
+ # A /model in the worker changes it before the file catches up.
442
+ session.model_name = snapshot["model_name"] if snapshot && !snapshot["model_name"].to_s.empty?
443
+ session_json = session_to_json(session, status: displayed_status(session, snapshot, owner: owner), owner: owner)
444
+ # What the worker's server said it served for that model (after a turn).
445
+ if snapshot
446
+ session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"])
447
+ end
448
+ raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
449
+ timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"])
450
+ # ?tail=1: the page's re-read at the end of a turn wants the final
451
+ # answer's rendered markdown, the status and the timing, not the
452
+ # whole history again (nor a render of every earlier answer).
453
+ if req.params["tail"] == "1"
454
+ return json_response(200, {
455
+ tail: true,
456
+ session: session_json,
457
+ messages: last_assistant_for_display(raw_messages),
458
+ markdown_warning: @markdown_renderer.warning,
459
+ timing: timing
460
+ })
461
+ end
462
+
463
+ json_response(200, {
464
+ session: session_json,
465
+ history: read_history(id),
466
+ messages: messages_for_display(raw_messages, parts: req.params["parts"] == "1"),
467
+ current_turn: current_turn,
468
+ queued: turn_snapshot ? Array(turn_snapshot["queued"]) : [],
469
+ recap: turn_snapshot && turn_snapshot["recap"],
470
+ saved_recap: saved_recap_for(id, session, turn_snapshot),
471
+ continue_offer: turn_snapshot && turn_snapshot["continue_offer"],
472
+ guardrail_warning: turn_snapshot && turn_snapshot["guardrail_warning"],
473
+ plugin_warning: turn_snapshot && turn_snapshot["plugin_warning"],
474
+ # Plugins' slow setup still running (chi.init): the page's init row.
475
+ init_tasks: turn_snapshot ? Array(turn_snapshot["init_tasks"]) : [],
476
+ cards: cards_for_display(turn_snapshot),
477
+ # The composer's / autocomplete; the built-ins until a worker
478
+ # names its plugins' too.
479
+ commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
480
+ markdown_warning: @markdown_renderer.warning,
481
+ pending_question: pending,
482
+ last_event_seq: last_event_seq,
483
+ # The stream cursor `<seq>-<epoch>`: a later worker resets it
484
+ # instead of taking the seq as its own. Only the snapshot has it.
485
+ last_event_id: turn_snapshot && turn_snapshot["event_id"],
486
+ timing: timing
487
+ })
488
+ rescue ArgumentError => e
489
+ error_response(404, "not_found", e.message)
490
+ end
491
+
492
+ # The worker's last cards and between-turns notices (Bridge
493
+ # snapshot[:cards]), a card's body as body_html: rendered markdown
494
+ # when the renderer is on, else the escaped text in a <pre>.
495
+ # @return [Array<Hash>] [] without a live worker
496
+ def cards_for_display(turn_snapshot)
497
+ Array(turn_snapshot && turn_snapshot["cards"]).filter_map do |card|
498
+ next unless card.is_a?(Hash)
499
+ next card unless card["type"].to_s == "card"
500
+
501
+ card.merge("body_html" => card_body_html(card["body"].to_s))
502
+ end
503
+ end
504
+
505
+ def card_body_html(body)
506
+ return "" if body.strip.empty?
507
+
508
+ html = @markdown_renderer.render(body) if @markdown_renderer.available?
509
+ html || "<pre>#{CGI.escapeHTML(body)}</pre>"
510
+ end
511
+
512
+ def handle_question_answer(req, id)
513
+ session = @session_class.load(id, state_dir: default_state_dir) rescue nil
514
+ return error_response(404, "not_found", "Session not found: #{id}") unless session
515
+
516
+ body = parse_json(request_body(req))
517
+ unless body.is_a?(Hash)
518
+ return error_response(400, "invalid_json", "invalid JSON body")
519
+ end
520
+ qid = body["id"] || body[:id] || body["question_id"] || body[:question_id]
521
+ selected = body["selected"] || body[:selected] || body["selection"] || body[:selection]
522
+ freeform = body["freeform"] || body[:freeform] || body["other"] || body[:other]
523
+ # Allow payload nested under answer
524
+ if body["answer"].is_a?(Hash)
525
+ ans = body["answer"]
526
+ qid ||= ans["id"] || ans[:id]
527
+ selected ||= ans["selected"] || ans[:selected]
528
+ freeform ||= ans["freeform"] || ans[:freeform]
529
+ end
530
+ if qid.to_s.strip.empty?
531
+ return error_response(400, "missing_fields", "id is required")
532
+ end
533
+ # Try live Engine via Bridge first (in-process answer without file IPC)
534
+ client = bridge_client(id)
535
+ if client
536
+ begin
537
+ reply = client.answer(id: qid, selected: selected, freeform: freeform)
538
+ return json_response(200, { status: "answered", session_id: id, id: qid }) if reply.ok?
539
+ # Read after its deadline and dropped (see #worker_timeout).
540
+ return worker_timeout("the answer was not sent") if reply.status == 408
541
+
542
+ # Pass the bridge's verdict through: 409 = another client answered
543
+ # first (or the question was cancelled), 400 = invalid selection.
544
+ if [400, 409].include?(reply.status)
545
+ detail = reply.json&.dig("detail") || "answer rejected"
546
+ return error_response(reply.status, reply.status == 409 ? "question_not_pending" : "invalid_answer", detail)
547
+ end
548
+ rescue Errno::ETIMEDOUT
549
+ return worker_timeout("the answer was not sent")
550
+ rescue StandardError
551
+ nil
552
+ end
553
+ end
554
+ error_response(503, "not_live", "no live bridge for session #{id}")
555
+ rescue ArgumentError => e
556
+ error_response(404, "not_found", e.message)
557
+ end
558
+
559
+ # Leave the pending question unanswered (the card's Dismiss). A question
560
+ # only exists while its worker waits on it, so this needs a live bridge.
561
+ def handle_question_dismiss(req, id)
562
+ body = parse_json(request_body(req))
563
+ return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
564
+
565
+ qid = body["id"].to_s
566
+ return error_response(400, "missing_fields", "id is required") if qid.strip.empty?
567
+
568
+ client = bridge_client(id)
569
+ return error_response(503, "not_live", "no live bridge for session #{id}") unless client
570
+
571
+ reply = client.dismiss_question(id: qid)
572
+ case reply.status
573
+ when 200 then json_response(200, { status: "dismissed", session_id: id, id: qid })
574
+ when 409 then error_response(409, "question_not_pending", reply.json&.dig("detail") || "question not pending")
575
+ when 408 then worker_timeout("the question was not dismissed")
576
+ when 404
577
+ error_response(501, "not_supported", BridgeClient.stale_worker_message(id, cant: "dismiss questions"))
578
+ else error_response(503, "not_live", "no live bridge for session #{id}")
579
+ end
580
+ rescue Errno::ETIMEDOUT
581
+ worker_timeout("the question was not dismissed")
582
+ rescue StandardError
583
+ error_response(503, "not_live", "no live bridge for session #{id}")
584
+ end
585
+
586
+ # A session command typed in the composer (/model, /models, !rollback,
587
+ # !cmd, /continue): the worker runs it (woken as for a turn) and every
588
+ # UI gets its :command_ran. Needs the worker's Bridge.
589
+ def handle_command(req, id)
590
+ body = parse_json(request_body(req))
591
+ return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
592
+
593
+ line = body["line"].to_s.strip
594
+ return error_response(400, "missing_fields", "line is required") if line.empty?
595
+
596
+ @manager.resume_session(id, state_dir: @state_dir) if @manager.respond_to?(:resume_session)
597
+ client = live_bridge_client(id)
598
+ return error_response(503, "not_live", "no live bridge for session #{id}") unless client
599
+
600
+ reply = client.post_command(line: line, client_id: body["client_id"])
601
+ case reply.status
602
+ when 202 then json_response(202, reply.json || { status: "accepted" })
603
+ when 400 then error_response(400, reply.json&.dig("error") || "unknown_command", reply.json&.dig("detail") || "not a session command")
604
+ when 408 then worker_timeout("the command was not run")
605
+ when 404
606
+ error_response(501, "not_supported", BridgeClient.stale_worker_message(id, cant: "run commands"))
607
+ else error_response(503, "not_live", "no live bridge for session #{id}")
608
+ end
609
+ rescue SessionManager::OwnedByTUI => e
610
+ error_response(409, "owned_by_tui", e.message)
611
+ rescue ArgumentError => e
612
+ error_response(404, "not_found", e.message)
613
+ rescue Errno::ETIMEDOUT
614
+ worker_timeout("the command was not run")
615
+ rescue SystemCallError, IOError
616
+ error_response(503, "not_live", "no live bridge for session #{id}")
617
+ end
618
+
619
+ # The worker didn't take a request in time: its read timed out, or the
620
+ # Bridge read it after its deadline (408 deadline_passed) and dropped
621
+ # it. Either way +what+ didn't happen, and a frozen worker that wakes
622
+ # won't do it (BridgeClient::DEADLINE_SHARE).
623
+ def worker_timeout(what)
624
+ error_response(504, "worker_timeout", "the session's worker did not answer, so #{what}")
625
+ end
626
+
627
+ def handle_output(req, id)
628
+ # Query ?since= may be an ISO8601 time or numeric mtime; we treat it as Time parse.
629
+ since_param = req.params["since"]
630
+ since_time = parse_since(since_param)
631
+ session = @session_class.load(id, state_dir: default_state_dir)
632
+ raw = @manager.read_responses(id, since_time: since_time, state_dir: @state_dir)
633
+ # Strip wire tokens server-side so the client never sees <|tool_call> etc.
634
+ rendered = raw.map { |chunk| OutputFormatter.strip(chunk) }.reject(&:empty?)
635
+ json_response(200, { session_id: session.id, output: rendered })
636
+ rescue ArgumentError => e
637
+ error_response(404, "not_found", e.message)
638
+ end
639
+
640
+ def handle_turn(req, id)
641
+ body = parse_json(request_body(req))
642
+ unless body.is_a?(Hash)
643
+ return error_response(400, "invalid_json", "invalid JSON body")
644
+ end
645
+ prompt = body["prompt"] || body[:prompt]
646
+ client_id = body["client_id"] || body[:client_id]
647
+ if prompt.to_s.strip.empty?
648
+ return error_response(400, "missing_fields", "prompt is required")
649
+ end
650
+ images = turn_images(id, body["images"])
651
+ return error_response(400, "bad_images", images) if images.is_a?(String)
652
+
653
+ result = SessionManager.deliver_turn(id, prompt: prompt.to_s, client_id: client_id, images: images, state_dir: @state_dir,
654
+ manager: @manager, bridge: -> { live_bridge_client(id) })
655
+ case result[:status]
656
+ when :accepted then json_response(202, result[:ack])
657
+ when :refused then json_response(result[:code], result[:ack])
658
+ when :timeout then error_response(504, "worker_timeout", result[:ack]["detail"])
659
+ else error_response(500, "enqueue_failed", "could not write turn input")
660
+ end
661
+ rescue SessionManager::OwnedByTUI => e
662
+ error_response(409, "owned_by_tui", e.message)
663
+ rescue SessionManager::ImagesUnsupported => e
664
+ error_response(409, "images_unsupported", e.message)
665
+ rescue ArgumentError => e
666
+ error_response(404, "not_found", e.message)
667
+ end
668
+
669
+ # A turn's images as [{file:, name:}] (refs to uploads already in the
670
+ # session's images/), or a String saying what's wrong. Never a path.
671
+ def turn_images(id, raw)
672
+ return [] if raw.nil?
673
+ return "images must be a list" unless raw.is_a?(Array)
674
+
675
+ session_dir = image_session_dir(id)
676
+ return "unknown session" unless session_dir
677
+
678
+ raw.map do |image|
679
+ return "each image must be {file:, name:}" unless image.is_a?(Hash)
680
+ return "images are refs to uploaded files, not paths" if image.key?("path") || image.key?(:path)
681
+ return "unknown image" unless ImageStore.valid_ref?(session_dir, image)
682
+
683
+ { file: (image["file"] || image[:file]).to_s, name: File.basename((image["name"] || image[:name]).to_s)[0, 120] }
684
+ end
685
+ end
686
+
687
+ # The session's folder, or nil for an id that isn't one.
688
+ def image_session_dir(id)
689
+ return nil unless SESSION_ID_RE.match?(id.to_s)
690
+
691
+ @session_class.load(id, state_dir: default_state_dir)
692
+ Session.session_dir(id, state_dir: default_state_dir)
693
+ rescue ArgumentError
694
+ nil
695
+ end
696
+
697
+ # POST /api/sessions/:id/images: the raw image as the body (a paste or
698
+ # a drop), ?name= for its file name. Stored like any other image
699
+ # (converted, downscaled); answers the ref the turn then names.
700
+ def handle_image_upload(req, id)
701
+ session_dir = image_session_dir(id)
702
+ return error_response(404, "not_found", "unknown session") unless session_dir
703
+
704
+ length = req.content_length.to_i
705
+ if length > MAX_IMAGE_UPLOAD_BYTES
706
+ return error_response(413, "too_large", "an image may be up to #{MAX_IMAGE_UPLOAD_BYTES / 1024 / 1024} MB")
707
+ end
708
+
709
+ bytes = request_body(req, MAX_IMAGE_UPLOAD_BYTES + 1).to_s.b
710
+ if bytes.bytesize > MAX_IMAGE_UPLOAD_BYTES
711
+ return error_response(413, "too_large", "an image may be up to #{MAX_IMAGE_UPLOAD_BYTES / 1024 / 1024} MB")
712
+ end
713
+
714
+ name = File.basename(req.params["name"].to_s)[0, 120]
715
+ ref = ImageStore.ingest(session_dir, bytes: bytes, name: name.empty? ? "image" : name, source: "user")
716
+ json_response(201, ref)
717
+ rescue ImageStore::Error => e
718
+ error_response(422, "bad_image", e.message)
719
+ end
720
+
721
+ # GET /api/sessions/:id/images/<hash>.<ext>: a stored image (only
722
+ # raster types; never svg), for the page's thumbnails.
723
+ def handle_image(_req, id, name)
724
+ session_dir = image_session_dir(id)
725
+ return not_found(path: "images/#{name}") unless session_dir && IMAGE_NAME_RE.match?(name.to_s)
726
+
727
+ path = File.join(session_dir, ImageStore::DIR, name)
728
+ return not_found(path: "images/#{name}") unless File.file?(path) && !File.symlink?(path)
729
+
730
+ body = File.binread(path)
731
+ [200, { "Content-Type" => IMAGE_TYPES.fetch(File.extname(name).delete(".")), "Content-Length" => body.bytesize.to_s,
732
+ "X-Content-Type-Options" => "nosniff", "Cache-Control" => "private, max-age=86400" }, [body]]
733
+ end
734
+
735
+ # The session's Bridge, waiting briefly for one a resume just spawned.
736
+ # @return [BridgeClient, nil]
737
+ def live_bridge_client(id)
738
+ port = bridge_sidecar_port(id) || await_bridge_port(id, timeout: [@bridge_wait_timeout.to_f, 5.0].min)
739
+ port && BridgeClient.new(session_id: id, port: port, host: DEFAULT_HOST)
740
+ end
741
+
742
+ # The process holding the session: {"pid", "kind" => "worker"|"tui"}, or
743
+ # nil. A worker always runs a Bridge; a TUI (plain `chi`) doesn't share.
744
+ def session_owner(id)
745
+ return nil unless @manager.respond_to?(:session_owner)
746
+
747
+ @manager.session_owner(id, state_dir: @state_dir)
748
+ rescue StandardError
749
+ nil
750
+ end
751
+
752
+ def handle_cancel(req, id)
753
+ # Validate session exists
754
+ begin
755
+ @session_class.load(id, state_dir: default_state_dir)
756
+ rescue ArgumentError => e
757
+ return error_response(404, "not_found", e.message)
758
+ end
759
+
760
+ body = request_body(req)
761
+ reason = "user"
762
+ unless body.nil? || body.strip.empty?
763
+ parsed = parse_json(body)
764
+ if parsed.is_a?(Hash)
765
+ r = parsed["reason"] || parsed[:reason] || parsed["cancellation_reason"]
766
+ r = r.to_s.strip
767
+ reason = if r.empty? then "user"
768
+ elsif CANCEL_REASONS.include?(r) then r
769
+ else "manual"
770
+ end
771
+ end
772
+ end
773
+
774
+ # Try direct bridge cancel first (in-process, low latency)
775
+ client = bridge_client(id)
776
+ if client
777
+ begin
778
+ reply = client.cancel(reason: reason)
779
+ if reply.status == 202
780
+ return json_response(202, { status: "cancel_requested", session_id: id, reason: reason, via: "bridge" })
781
+ end
782
+ # The bridge answers 409 when there is no active turn to cancel.
783
+ if reply.status == 409
784
+ return json_response(409, { error: "not_running", detail: "no active turn to cancel", session_id: id })
785
+ end
786
+ rescue StandardError
787
+ nil
788
+ end
789
+ end
790
+
791
+ error_response(503, "not_live", "no live bridge for session #{id}")
792
+ rescue ArgumentError => e
793
+ error_response(404, "not_found", e.message)
794
+ end
795
+
796
+ def handle_stop(_req, id)
797
+ # Bounded, so a Rack thread isn't held long; a worker still exiting
798
+ # after it just means an immediate resume finds it (rare).
799
+ @manager.stop_session(id, state_dir: @state_dir, wait: STOP_WAIT_SECONDS)
800
+ @hub&.touch(id)
801
+ json_response(200, { status: "stopped", session_id: id })
802
+ rescue SessionManager::OwnedByTUI => e
803
+ error_response(409, "owned_by_tui", e.message)
804
+ rescue ArgumentError => e
805
+ error_response(404, "not_found", e.message)
806
+ end
807
+
808
+ # DELETE /api/sessions/:id: the session for good. A live worker is
809
+ # stopped first (the page's confirm says so); a chi REPL's session is
810
+ # refused.
811
+ def handle_delete(_req, id)
812
+ result = @manager.delete_session(id, state_dir: @state_dir, stop: true, wait: STOP_WAIT_SECONDS)
813
+ @hub&.touch(result[:id])
814
+ json_response(200, { status: "deleted", session_id: result[:id], stopped: result[:stopped] })
815
+ rescue SessionManager::OwnedByTUI
816
+ error_response(409, "owned_by_tui", "session #{id} is open in a chi REPL; close it there first")
817
+ rescue SessionManager::DeleteRefused => e
818
+ error_response(409, e.reason.to_s, "#{e.message}; try again in a moment")
819
+ rescue ArgumentError => e
820
+ error_response(404, "not_found", e.message)
821
+ end
822
+
823
+ def handle_stream(req, id)
824
+ # Validate session exists
825
+ begin
826
+ @session_class.load(id, state_dir: default_state_dir)
827
+ rescue ArgumentError
828
+ return error_response(404, "not_found", "Session not found: #{id}")
829
+ end
830
+
831
+ # Single live transport: proxy to the session's Bridge. When no live
832
+ # worker exists there is nothing to stream — history is available via
833
+ # GET /api/sessions/:id/output.
834
+ bridge_port = bridge_sidecar_port(id)
835
+ # A resume just spawned the worker: its bridge may still be binding.
836
+ # Wait briefly for it so the first SSE connect lands on a live bridge
837
+ # instead of an instantly-closed empty 200 / 503 during startup.
838
+ if bridge_port.nil? && @bridge_wait_timeout.to_i.positive?
839
+ bridge_port = await_bridge_port(id, timeout: [@bridge_wait_timeout.to_f, 2.0].min)
840
+ end
841
+ return error_response(503, "not_live", "no live bridge for session #{id}") unless bridge_port
842
+
843
+ headers = {
844
+ "Content-Type" => "text/event-stream",
845
+ "Cache-Control" => "no-cache",
846
+ "Connection" => "keep-alive",
847
+ "X-Accel-Buffering" => "no",
848
+ "Access-Control-Allow-Origin" => "*"
849
+ }
850
+
851
+ # Handlers that buffer enumerable bodies before responding (rackup's
852
+ # WEBrick does `body.each` to completion) hang forever on an unbounded
853
+ # SSE body — the browser never even receives the status line. When the
854
+ # handler supports hijacking, stream frames straight to the socket
855
+ # instead of returning an enumerable.
856
+ if req.env["rack.hijack?"]
857
+ hijack = sse_hijack(id, req, bridge_port)
858
+ return [200, headers.merge("rack.hijack" => hijack), []]
859
+ end
860
+
861
+ query = req.query_string.to_s.empty? ? "" : "?#{req.query_string}"
862
+ [200, headers, ProxyStreamBody.new(host: DEFAULT_HOST, port: bridge_port, session_id: id, query: query, headers: req.env,
863
+ server_running: @server_running)]
864
+ end
865
+
866
+ # GET /api/events: the session list as one SSE stream per tab. The
867
+ # first frame is the hub's snapshot (scoped by ?dir= as the list is),
868
+ # then `session` for an upsert and `session_gone` for a removal, with
869
+ # a `: ping` while idle. Frame ids are the hub's seq, for the log's
870
+ # sake: there is no replay, a reconnect starts with a fresh snapshot.
871
+ def handle_events(req)
872
+ return error_response(503, "no_hub", "this chi web has no session hub") unless @hub
873
+
874
+ dir, error = scope_dir(req.params["dir"])
875
+ return error if error
876
+
877
+ root = dir && ProjectScope.root_for(dir)
878
+ headers = {
879
+ "Content-Type" => "text/event-stream",
880
+ "Cache-Control" => "no-cache",
881
+ "Connection" => "keep-alive",
882
+ "X-Accel-Buffering" => "no",
883
+ "Access-Control-Allow-Origin" => "*"
884
+ }
885
+ body = EventsBody.new(hub: @hub, project_root: root, heartbeat: @events_heartbeat, capacity: @events_queue,
886
+ server_running: @server_running)
887
+ # As handle_stream: hijack when the handler offers it (rackup's
888
+ # WEBrick buffers enumerable bodies), else an enumerable body.
889
+ if req.env["rack.hijack?"]
890
+ hijack = lambda do |io|
891
+ body.each do |chunk|
892
+ io.write(chunk)
893
+ io.flush
894
+ end
895
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
896
+ nil # the tab went away
897
+ end
898
+ return [200, headers.merge("rack.hijack" => hijack), []]
899
+ end
900
+
901
+ [200, headers, body]
902
+ end
903
+
904
+ # Rack hijack lambda (env["rack.hijack?"] truthy): pipes the bridge's SSE
905
+ # frames directly to the client socket until it disconnects. The handler
906
+ # (WEBrick via rackup) has already sent the status line + headers, so
907
+ # only body bytes go to the socket here.
908
+ def sse_hijack(id, req, port)
909
+ lambda do |io|
910
+ query = req.query_string.to_s.empty? ? "" : "?#{req.query_string}"
911
+ body = ProxyStreamBody.new(host: DEFAULT_HOST, port: port, session_id: id, query: query, headers: req.env,
912
+ server_running: @server_running)
913
+ body.each { |chunk| io.write(chunk) }
914
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
915
+ nil # client went away — end the stream quietly
916
+ rescue StandardError => e
917
+ Log.warn(:web, "proxy_failed", sid: id, port: port, error: e.class.name, msg: e.message)
918
+ nil
919
+ end
920
+ end
921
+
922
+ # Bounded wait for a freshly-spawned worker's bridge sidecar so the
923
+ # response hands the client a live transport without client-side polling.
924
+ # Returns the bridge port, or nil when it is not up by the deadline (the
925
+ # session still exists; EventSource auto-reconnect covers late binders).
926
+ def await_bridge_port(session_id, timeout: @bridge_wait_timeout)
927
+ return nil if timeout.nil? || timeout <= 0
928
+
929
+ BridgeClient.poll(timeout, interval: BridgeClient::SPAWN_POLL_INTERVAL) { bridge_sidecar_port(session_id) }
930
+ rescue StandardError
931
+ nil
932
+ end
933
+
934
+ # Port of the session's live Bridge (stale sidecars are removed), or nil.
935
+ def bridge_sidecar_port(session_id)
936
+ dir = @session_class.session_dir(session_id, state_dir: default_state_dir)
937
+ BridgeClient.sidecar_port(dir, host: DEFAULT_HOST)
938
+ rescue StandardError
939
+ nil
940
+ end
941
+
942
+ # @return [BridgeClient, nil] a client for the session's live Bridge
943
+ def bridge_client(session_id)
944
+ port = bridge_sidecar_port(session_id)
945
+ port && BridgeClient.new(session_id: session_id, port: port, host: DEFAULT_HOST)
946
+ end
947
+
948
+ # Read the live Engine state over the bridge (raw GET /session/:id/state).
949
+ # @return [Hash, nil] parsed JSON body, or nil when no live bridge / timeout.
950
+ def bridge_get_json(session_id, path)
951
+ bridge_client(session_id)&.get_json(path)
952
+ end
953
+
954
+ # Monotonic SSE cursor for a live bridge session, else nil.
955
+ def bridge_event_seq(session_id)
956
+ bridge_client(session_id)&.event_seq
957
+ end
958
+
959
+ def serve_index(req)
960
+ path = File.join(@public_dir, "index.html")
961
+ if File.file?(path)
962
+ body = File.read(path).sub("<body>", "<body #{index_data_attributes(req)}>")
963
+ [200, {
964
+ "Content-Type" => "text/html; charset=utf-8",
965
+ "Content-Length" => body.bytesize.to_s,
966
+ "Cache-Control" => "no-store, no-cache, must-revalidate, max-age=0",
967
+ "Pragma" => "no-cache",
968
+ "Expires" => "0"
969
+ }, [body]]
970
+ else
971
+ [200, { "Content-Type" => "text/html" }, ["<h1>Chi Web</h1><p>Public dir missing: #{@public_dir}</p>"]]
972
+ end
973
+ end
974
+
975
+ def serve_static(req)
976
+ # Serve files under public_dir for /assets/* and bare paths
977
+ rel = req.path_info.sub(%r{\A/(assets|public)/}, "")
978
+ rel = req.path_info.sub(%r{\A/}, "") if rel == req.path_info
979
+ # Prevent directory traversal
980
+ rel = rel.split("/").reject { |p| p == ".." || p.empty? }.join("/")
981
+ full = File.join(@public_dir, rel)
982
+ if File.file?(full) && full.start_with?(@public_dir)
983
+ body = File.binread(full)
984
+ ctype = mime_type(full)
985
+ [200, { "Content-Type" => ctype, "Content-Length" => body.bytesize.to_s, "Cache-Control" => "public, max-age=3600" }, [body]]
986
+ else
987
+ not_found(path: req.path_info)
988
+ end
989
+ end
990
+
991
+ def mime_type(path)
992
+ case File.extname(path).downcase
993
+ when ".html" then "text/html; charset=utf-8"
994
+ when ".js" then "application/javascript; charset=utf-8"
995
+ when ".css" then "text/css; charset=utf-8"
996
+ when ".json" then "application/json; charset=utf-8"
997
+ when ".svg" then "image/svg+xml"
998
+ else "application/octet-stream"
999
+ end
1000
+ end
1001
+
1002
+ # status is turn state (idle/running): SessionSummary.displayed_status,
1003
+ # unless the manager can't tell who owns a session (then the file's
1004
+ # word stands).
1005
+ def displayed_status(session, snapshot = nil, owner: session_owner(session.id))
1006
+ unless @manager.respond_to?(:session_owner)
1007
+ return snapshot["status"] if snapshot.is_a?(Hash) && snapshot["status"]
1008
+
1009
+ return session.status
1010
+ end
1011
+
1012
+ SessionSummary.displayed_status(session, snapshot, owner: owner)
1013
+ end
1014
+
1015
+ # @param owner [Hash, nil] #session_owner; its kind is shown as `owner`
1016
+ def session_to_json(s, status: s.status, owner: nil, root_cache: nil)
1017
+ SessionSummary.build(s, status: status, owner: owner, root_cache: root_cache,
1018
+ session_dir: @session_class.session_dir(s.id, state_dir: default_state_dir))
1019
+ end
1020
+
1021
+ def timing_payload(session_id, live_metrics: nil)
1022
+ persisted = read_analytics(session_id)
1023
+ source = if live_metrics.is_a?(Hash)
1024
+ persisted.merge(live_metrics).merge(
1025
+ "started_at" => persisted["started_at"] || live_metrics["started_at"],
1026
+ "turn_records" => merge_timing_records(persisted["turn_records"], live_metrics["turn_records"]),
1027
+ "tool_records" => merge_timing_records(persisted["tool_records"], live_metrics["tool_records"])
1028
+ )
1029
+ else
1030
+ persisted
1031
+ end
1032
+ started_at = source["started_at"]
1033
+ last_activity_at = source["last_activity_at"]
1034
+ {
1035
+ started_at: started_at,
1036
+ last_activity_at: last_activity_at,
1037
+ session_duration_ms: session_duration_ms(started_at, last_activity_at, active: live_metrics.is_a?(Hash)),
1038
+ turn_records: Array(source["turn_records"]),
1039
+ tool_records: Array(source["tool_records"]),
1040
+ active_turn: source["active_turn"],
1041
+ active_tools: Array(source["active_tools"])
1042
+ }
1043
+ end
1044
+
1045
+ def read_analytics(session_id)
1046
+ path = File.join(@session_class.session_dir(session_id, state_dir: default_state_dir), "analytics.json")
1047
+ return {} unless File.file?(path)
1048
+
1049
+ data = JSON.parse(File.read(path))
1050
+ data.is_a?(Hash) ? data : {}
1051
+ rescue JSON::ParserError, SystemCallError
1052
+ {}
1053
+ end
1054
+
1055
+ def merge_timing_records(persisted, live)
1056
+ (Array(persisted) + Array(live)).each_with_object({}) do |record, records|
1057
+ next unless record.is_a?(Hash)
1058
+
1059
+ id = record["id"] || record[:id]
1060
+ records[id] = record if id
1061
+ end.values
1062
+ end
1063
+
1064
+ def session_duration_ms(started_at, last_activity_at, active:)
1065
+ started = Time.iso8601(started_at.to_s)
1066
+ finished = active ? Time.now : Time.iso8601(last_activity_at.to_s)
1067
+ [((finished - started) * 1000).round, 0].max
1068
+ rescue ArgumentError
1069
+ nil
1070
+ end
1071
+
1072
+ # The recap saved with the session and how many user turns came since:
1073
+ # a live worker's (it counts against its Engine's messages), else
1074
+ # recap.json against the session file. A stopped session gets its
1075
+ # recap without a worker being woken.
1076
+ # @return [Hash, nil] {text:, turns_since:}
1077
+ def saved_recap_for(id, session, turn_snapshot)
1078
+ if turn_snapshot
1079
+ saved = turn_snapshot["saved_recap"]
1080
+ return saved && { text: saved["text"], turns_since: saved["turns_since"].to_i }
1081
+ end
1082
+
1083
+ saved = RecapStore.read(@session_class.session_dir(id, state_dir: default_state_dir))
1084
+ return nil unless saved
1085
+
1086
+ messages = Array(session.messages)
1087
+ since = messages.drop(saved[:covered].to_i).count { |m| (m[:role] || m["role"]).to_s == "user" }
1088
+ { text: saved[:text], turns_since: since }
1089
+ rescue StandardError
1090
+ nil
1091
+ end
1092
+
1093
+ def read_history(id)
1094
+ raw = @manager.read_responses(id, since_time: nil, state_dir: @state_dir)
1095
+ raw.map { |chunk| OutputFormatter.strip(chunk) }.reject(&:empty?)
1096
+ rescue StandardError
1097
+ []
1098
+ end
1099
+
1100
+ # @param msgs [Array<Hash>] a session's messages; symbol keys from disk,
1101
+ # string keys from a Bridge snapshot
1102
+ # @param parts [Boolean] the turn view's reload (?parts=1): each
1103
+ # assistant message also carries what it did (MessageParts: thinking,
1104
+ # tool calls with params and output), and a message with nothing to
1105
+ # show but that (a text-less step) is kept with empty content
1106
+ def messages_for_display(msgs, parts: false)
1107
+ filtered = []
1108
+ list = Array(msgs)
1109
+ list.each_with_index do |m, index|
1110
+ role = (m[:role] || m["role"]).to_s
1111
+ content = (m[:content] || m["content"]).to_s
1112
+ if Samagotchi::ContextNote.note?(m)
1113
+ filtered << { role: "note", content: Samagotchi::ContextNote.text_of(m), label: Samagotchi::ContextNote.label_of(m) }
1114
+ next
1115
+ end
1116
+ next if role == "system"
1117
+ next if role == "tool_response"
1118
+
1119
+ stripped = Samagotchi::OutputFormatter.strip_markup(content)
1120
+ did = parts && %w[model assistant].include?(role) ? message_parts(list, index) : nil
1121
+ next if stripped.empty? && did.nil?
1122
+
1123
+ norm_role = role == "model" ? "assistant" : role
1124
+ # normalize assistant vs model, keep user as is
1125
+ norm_role = "assistant" if norm_role == "assistant" || norm_role == "model"
1126
+ norm_role = "user" if norm_role == "user"
1127
+ next unless %w[user assistant].include?(norm_role)
1128
+
1129
+ message = { role: norm_role, content: stripped }
1130
+ images = m[:images] || m["images"]
1131
+ message[:images] = Array(images).map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } if norm_role == "user" && images.is_a?(Array) && !images.empty?
1132
+ message[:html] = @markdown_renderer.render(stripped) if norm_role == "assistant" && !stripped.empty? && @markdown_renderer.available?
1133
+ message[:parts] = did if did
1134
+ filtered << message
1135
+ end
1136
+ filtered
1137
+ rescue StandardError
1138
+ []
1139
+ end
1140
+
1141
+ # The parts of the assistant message at +index+, with the tool_response
1142
+ # messages right after it.
1143
+ def message_parts(list, index)
1144
+ responses = list.drop(index + 1).take_while { |r| (r[:role] || r["role"]).to_s == "tool_response" }
1145
+ MessageParts.for_message(list[index], responses)
1146
+ end
1147
+
1148
+ # The last message messages_for_display shows as an answer, as a list of
1149
+ # at most one: walked backwards so only that one is rendered.
1150
+ def last_assistant_for_display(msgs)
1151
+ Array(msgs).reverse_each do |m|
1152
+ shown = messages_for_display([m]).first
1153
+ return [shown] if shown && shown[:role] == "assistant"
1154
+ end
1155
+ []
1156
+ end
1157
+
1158
+ def parse_json(str)
1159
+ return nil if str.nil? || str.strip.empty?
1160
+
1161
+ JSON.parse(str)
1162
+ rescue JSON::ParserError
1163
+ nil
1164
+ end
1165
+
1166
+ def parse_since(val)
1167
+ return nil if val.nil? || val.to_s.strip.empty?
1168
+
1169
+ # Try ISO8601, then float timestamp
1170
+ Time.iso8601(val.to_s)
1171
+ rescue ArgumentError
1172
+ begin
1173
+ Time.at(Float(val.to_s))
1174
+ rescue StandardError
1175
+ nil
1176
+ end
1177
+ end
1178
+
1179
+ def default_state_dir
1180
+ @state_dir || Session.default_state_dir
1181
+ end
1182
+
1183
+ # The sessions folder as the page shows it: ~ for the home folder.
1184
+ def sessions_dir_label
1185
+ home_label(File.expand_path(default_state_dir))
1186
+ end
1187
+
1188
+ def home_label(dir)
1189
+ home = Dir.home
1190
+ return "~" if dir == home
1191
+
1192
+ dir.start_with?("#{home}/") ? "~#{dir.delete_prefix(home)}" : dir
1193
+ end
1194
+
1195
+ # <body> data for the page: where sessions are stored, where an
1196
+ # all-view new chat starts (the server's cwd), and, when ?dir names a
1197
+ # folder in a repo, that project's name and root. A bad dir adds
1198
+ # nothing here: the list call answers 400 and the page says so.
1199
+ def index_data_attributes(req)
1200
+ attrs = { "sessions-dir" => sessions_dir_label, "server-dir" => home_label(Dir.pwd) }
1201
+ attrs["turn-view"] = "1" if turn_view?(req.params["view"])
1202
+ dir, = scope_dir(req.params["dir"])
1203
+ root = dir && ProjectScope.root_for(dir)
1204
+ if root
1205
+ attrs["project-name"] = File.basename(root)
1206
+ attrs["project-dir"] = home_label(root)
1207
+ elsif dir.nil?
1208
+ # The all view's way back: the project view it came from
1209
+ # (?from=), else the project chi web runs in.
1210
+ back, = scope_dir(req.params["from"])
1211
+ back ||= Dir.pwd
1212
+ if (back_root = ProjectScope.root_for(back))
1213
+ attrs["back-name"] = File.basename(back_root)
1214
+ attrs["back-dir"] = back
1215
+ end
1216
+ end
1217
+ attrs.map { |key, value| %(data-#{key}="#{Rack::Utils.escape_html(value)}") }.join(" ")
1218
+ end
1219
+
1220
+ # The turn view for this page load: ?view=turn or ?view=chat wins,
1221
+ # anything else leaves the config's choice.
1222
+ def turn_view?(param)
1223
+ case param
1224
+ when "turn" then true
1225
+ when "chat" then false
1226
+ else @turn_view
1227
+ end
1228
+ end
1229
+
1230
+ def json_response(status, payload)
1231
+ body = JSON.generate(payload)
1232
+ [status, { "Content-Type" => "application/json; charset=utf-8", "Content-Length" => body.bytesize.to_s, "Cache-Control" => "no-store", "Access-Control-Allow-Origin" => "*" }, [body]]
1233
+ end
1234
+
1235
+ # The request body, "" when there is none: WEBrick (through rackup)
1236
+ # raises LengthRequired on reading a POST that has neither a
1237
+ # Content-Length nor a chunked body (plain `curl -X POST`).
1238
+ def request_body(req, limit = nil)
1239
+ return "" if req.get_header("CONTENT_LENGTH").nil? && req.get_header("HTTP_TRANSFER_ENCODING").nil?
1240
+
1241
+ req.body.read(*limit)
1242
+ end
1243
+
1244
+ def error_response(status, code, detail)
1245
+ json_response(status, { error: code, detail: detail })
1246
+ end
1247
+
1248
+
1249
+ def not_found(path:)
1250
+ error_response(404, "not_found", "not found: #{path}")
1251
+ end
1252
+
1253
+ # One tab's GET /api/events: a bounded queue behind a lambda sink,
1254
+ # subscribed and snapshotted as one step under the hub's lock, then
1255
+ # drained onto the socket. The loop ends when the hub stops or the
1256
+ # server leaves :Running (rackup's WEBrick joins every request thread
1257
+ # before returning, so a loop that waited on the queue alone would
1258
+ # hang Ctrl-C while a tab is open), and when the queue overflowed:
1259
+ # the reconnect's snapshot is the recovery.
1260
+ class EventsBody
1261
+ # Subscribes now: the snapshot is the list as of the request, and
1262
+ # what changes between here and the socket queues up behind it.
1263
+ def initialize(hub:, project_root:, heartbeat:, capacity:, server_running:)
1264
+ @hub = hub
1265
+ @project_root = project_root
1266
+ @heartbeat = heartbeat
1267
+ @server_running = server_running
1268
+ @queue = Bridge::BoundedQueue.new(capacity: capacity)
1269
+ @handle, @snapshot = @hub.subscribe(->(event) { @queue.push(event) }, snapshot: true, project_root: project_root)
1270
+ end
1271
+
1272
+ def each
1273
+ yield frame(nil, "snapshot", sessions: @snapshot)
1274
+ last_write = monotonic
1275
+ loop do
1276
+ event = @queue.pop([@heartbeat, EVENTS_POLL].min)
1277
+ if event
1278
+ break if @queue.overflow_dropped?
1279
+
1280
+ next unless in_scope?(event)
1281
+
1282
+ yield frame(event.seq, event.type, event.data)
1283
+ last_write = monotonic
1284
+ else
1285
+ # What was queued before the stop still goes out.
1286
+ break if @hub.stopped? || !@server_running.call
1287
+
1288
+ if monotonic - last_write >= @heartbeat
1289
+ yield ": ping\r\n\r\n"
1290
+ last_write = monotonic
1291
+ end
1292
+ end
1293
+ end
1294
+ ensure
1295
+ close
1296
+ end
1297
+
1298
+ # Rack calls it when the body is done with, served or not.
1299
+ def close
1300
+ @handle.unsubscribe
1301
+ end
1302
+
1303
+ private
1304
+
1305
+ # A session outside ?dir's project is not this tab's (its removal
1306
+ # still goes out: harmless).
1307
+ def in_scope?(event)
1308
+ return true if @project_root.nil? || event.type != "session"
1309
+
1310
+ event.data[:session][:project_root] == @project_root
1311
+ end
1312
+
1313
+ def frame(id, type, data)
1314
+ lines = []
1315
+ lines << "id: #{id}" if id
1316
+ lines << "event: #{type}"
1317
+ JSON.generate(data).each_line { |line| lines << "data: #{line.chomp}" }
1318
+ lines.join("\r\n") + "\r\n\r\n"
1319
+ end
1320
+
1321
+ def monotonic
1322
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
1323
+ end
1324
+ end
1325
+
1326
+ # Proxy body that streams from the per-session Bridge TCP server. It
1327
+ # ends when the Bridge closes the stream or, as EventsBody, when the
1328
+ # server leaves :Running: rackup's WEBrick joins every request thread
1329
+ # before returning, and a read blocked on a quiet worker would hang
1330
+ # Ctrl-C of chi web. The bytes go through unchanged.
1331
+ class ProxyStreamBody
1332
+ def initialize(host:, port:, session_id:, query:, headers:, server_running: -> { true })
1333
+ @client = BridgeClient.new(session_id: session_id, port: port, host: host)
1334
+ @query = query
1335
+ @headers = headers
1336
+ @server_running = server_running
1337
+ end
1338
+
1339
+ def each(&block)
1340
+ # Forward the browser's auto-reconnect cursor: the bridge prefers the
1341
+ # Last-Event-ID header over ?from_seq, and the reconnect URL carries a
1342
+ # stale initial cursor — without this the bridge would replay content
1343
+ # already delivered (duplicate bubbles).
1344
+ @client.stream(query: @query, last_event_id: @headers["HTTP_LAST_EVENT_ID"], running: @server_running, &block)
1345
+ end
1346
+ end
1347
+ end
1348
+ end
1349
+ end