samagotchi 0.102.0 → 0.104.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -1
  3. data/bin/chi +60 -191
  4. data/docs/architecture.md +67 -1
  5. data/docs/cli.md +190 -9
  6. data/docs/configuration.md +7 -1
  7. data/docs/guardrails.md +4 -2
  8. data/docs/internals/context-telemetry.md +6 -2
  9. data/docs/internals/engine.md +5 -2
  10. data/docs/internals/llm-context-forget.md +11 -1
  11. data/docs/internals/participants.md +137 -0
  12. data/docs/internals/remote-sessions.md +429 -0
  13. data/docs/internals/working-set.md +95 -11
  14. data/docs/plugins.md +33 -3
  15. data/docs/sessions.md +16 -7
  16. data/docs/sub-agent.md +16 -0
  17. data/docs/testing.md +68 -4
  18. data/lib/samagotchi/answer_command.rb +27 -25
  19. data/lib/samagotchi/bridge.rb +31 -7
  20. data/lib/samagotchi/bridge_client/event_stream.rb +86 -12
  21. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  22. data/lib/samagotchi/bridge_client.rb +26 -39
  23. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  25. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  27. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  28. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  30. data/lib/samagotchi/chi_invocation.rb +40 -0
  31. data/lib/samagotchi/cli/command.rb +3 -2
  32. data/lib/samagotchi/cli/flags.rb +5 -0
  33. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  34. data/lib/samagotchi/context_command.rb +80 -73
  35. data/lib/samagotchi/context_status.rb +15 -2
  36. data/lib/samagotchi/engine.rb +28 -14
  37. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  38. data/lib/samagotchi/idle_recap.rb +8 -2
  39. data/lib/samagotchi/idle_scheduler.rb +34 -9
  40. data/lib/samagotchi/kernel_loop.rb +21 -2
  41. data/lib/samagotchi/launch_mode.rb +26 -0
  42. data/lib/samagotchi/llm/chat_loop.rb +8 -1
  43. data/lib/samagotchi/llm_context_forget.rb +13 -6
  44. data/lib/samagotchi/note_command.rb +8 -7
  45. data/lib/samagotchi/owner_lock.rb +65 -5
  46. data/lib/samagotchi/parent_report.rb +55 -43
  47. data/lib/samagotchi/participants.rb +122 -0
  48. data/lib/samagotchi/remote_attach_command.rb +119 -0
  49. data/lib/samagotchi/remote_command.rb +170 -0
  50. data/lib/samagotchi/remotes.rb +130 -0
  51. data/lib/samagotchi/run_options.rb +245 -0
  52. data/lib/samagotchi/send_command/busy.rb +58 -0
  53. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  54. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  55. data/lib/samagotchi/send_command.rb +173 -108
  56. data/lib/samagotchi/session.rb +38 -9
  57. data/lib/samagotchi/session_access/contract.rb +167 -0
  58. data/lib/samagotchi/session_access/dispatch.rb +152 -0
  59. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  60. data/lib/samagotchi/session_access/local.rb +400 -0
  61. data/lib/samagotchi/session_access/remote.rb +734 -0
  62. data/lib/samagotchi/session_access/web_bridge_client.rb +236 -0
  63. data/lib/samagotchi/session_access.rb +223 -0
  64. data/lib/samagotchi/session_archive_command.rb +9 -5
  65. data/lib/samagotchi/session_commands.rb +20 -15
  66. data/lib/samagotchi/session_continue.rb +1 -1
  67. data/lib/samagotchi/session_delete_command.rb +8 -4
  68. data/lib/samagotchi/session_inbox.rb +18 -9
  69. data/lib/samagotchi/session_manager.rb +27 -6
  70. data/lib/samagotchi/session_setup.rb +11 -6
  71. data/lib/samagotchi/sessions_command.rb +36 -14
  72. data/lib/samagotchi/share_command.rb +174 -0
  73. data/lib/samagotchi/terminal_ui/attach_launcher.rb +66 -17
  74. data/lib/samagotchi/terminal_ui/attach_link.rb +114 -0
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +155 -87
  76. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  77. data/lib/samagotchi/terminal_ui/local_link.rb +135 -0
  78. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  79. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  80. data/lib/samagotchi/terminal_ui/version_lines.rb +28 -3
  81. data/lib/samagotchi/terminal_ui.rb +6 -3
  82. data/lib/samagotchi/testing/waiting.rb +92 -9
  83. data/lib/samagotchi/title_command.rb +9 -11
  84. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +530 -126
  87. data/lib/samagotchi/web/grants.rb +302 -0
  88. data/lib/samagotchi/web/participant_access.rb +286 -0
  89. data/lib/samagotchi/web/principal.rb +29 -0
  90. data/lib/samagotchi/web/public/data.js +3 -2
  91. data/lib/samagotchi/web/public/index.html +20 -4
  92. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  93. data/lib/samagotchi/web/public/stage_view.js +3 -2
  94. data/lib/samagotchi/web/public/stream_handlers.js +3 -1
  95. data/lib/samagotchi/web/public/working_set.js +159 -28
  96. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  97. data/lib/samagotchi/web/public/working_set_view.js +91 -33
  98. data/lib/samagotchi/web/server.rb +3 -1
  99. data/lib/samagotchi/web/session_access_routes.rb +459 -0
  100. data/lib/samagotchi/worker.rb +26 -5
  101. data/lib/samagotchi/worker_sidecar.rb +12 -5
  102. data/lib/samagotchi/working_set.rb +88 -14
  103. data/lib/samagotchi/working_set_command.rb +133 -30
  104. data/lib/samagotchi/working_set_pin.rb +34 -0
  105. data/lib/samagotchi/working_set_steer.rb +191 -27
  106. metadata +28 -1
@@ -42,11 +42,19 @@ require_relative "../context_note"
42
42
  require_relative "../context_sources"
43
43
  require_relative "../context_providers"
44
44
  require_relative "../recap_store"
45
+ require_relative "../toolkit"
46
+ require_relative "../turn_flow"
47
+ require_relative "../session_chain"
48
+ require_relative "../parent_answers_command"
49
+ require_relative "../guardrails/parent_policy"
45
50
  require_relative "markdown_renderer"
46
51
  require_relative "capabilities"
47
52
  require_relative "editor"
48
53
  require_relative "../message_parts"
49
54
  require_relative "session_summary"
55
+ require_relative "session_access_routes"
56
+ require_relative "principal"
57
+ require_relative "participant_access"
50
58
  require_relative "../log"
51
59
  require_relative "../paths"
52
60
  require_relative "../redacted_inspect"
@@ -63,6 +71,8 @@ module Samagotchi
63
71
  class App
64
72
  # @lan holds the LAN access token.
65
73
  include RedactedInspect
74
+ include SessionAccessRoutes
75
+ include ParticipantAccess
66
76
 
67
77
  DEFAULT_HOST = BridgeClient::HOST
68
78
  BRIDGE_WAIT_TIMEOUT = 10.0
@@ -76,7 +86,11 @@ module Samagotchi
76
86
  IMAGE_TYPES = { "png" => "image/png", "jpg" => "image/jpeg", "jpeg" => "image/jpeg", "gif" => "image/gif",
77
87
  "webp" => "image/webp" }.freeze
78
88
 
79
- # POST /stop waits this long for the worker to let go of the session.
89
+ # POST /stop waits this long for the worker to let go of the session
90
+ # when the body names no wait (the page's Stop: it shows the stop at
91
+ # once, and a Rack thread isn't held for it). A CLI through --remote
92
+ # sends its own, SessionManager::STOP_WAIT at most, so chi sessions
93
+ # stop waits as long as here.
80
94
  STOP_WAIT_SECONDS = 2.0
81
95
 
82
96
  # GET /api/events: a `: ping` this often while idle, a queue this deep
@@ -113,17 +127,22 @@ module Samagotchi
113
127
  # (nil: built from config.yml's hosts:, again when they change)
114
128
  # @param models_wait_timeout [Float] bounded seconds GET /api/models
115
129
  # waits for the hosts' lists
130
+ # @param wait_poll_interval [Float] how often POST
131
+ # /api/sessions/:id/wait looks at the session (ReplyWait's poll)
116
132
  # @param lan [Hash, nil] LAN mode (web.host: lan): { ip:, token: },
117
133
  # the address the server also listens on and its access token (a
118
134
  # String, or a Web::Token::Source that follows the file); nil:
119
135
  # loopback only, no token
136
+ # @param grants [Grants, nil] the participants' grants (default: the
137
+ # store next to +state_dir+, Grants.path)
120
138
  def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
121
139
  bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, view: Config::BY_KEY["web.view"].default, hub: nil,
122
140
  annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
123
141
  editor: Config::BY_KEY["web.editor"].default,
124
142
  events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
125
143
  registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil,
126
- working_set: Config::BY_KEY["web.working_set"].default)
144
+ working_set: Config::BY_KEY["web.working_set"].default, wait_poll_interval: ReplyWait::POLL_INTERVAL,
145
+ grants: nil)
127
146
  @manager = manager || SessionManager
128
147
  @registry = registry
129
148
  @registry_given = !registry.nil?
@@ -146,6 +165,8 @@ module Samagotchi
146
165
  @lan = lan
147
166
  @refused_mutex = Mutex.new
148
167
  @refused_logged = {}
168
+ @wait_poll_interval = wait_poll_interval
169
+ init_participant_access(grants, state_dir)
149
170
  end
150
171
 
151
172
  def call(env)
@@ -158,39 +179,63 @@ module Samagotchi
158
179
  private
159
180
 
160
181
  # The routes, first match wins: a handler takes the request and the
161
- # path's captures (the session id, an image's name). A :session route
162
- # runs in #session_route: an ArgumentError is its unknown session.
182
+ # path's captures (the session id, an image's name). The fourth column
183
+ # is who may use the route (ROUTE_PERMISSIONS, checked against the
184
+ # request's Principal): :owner, or :participant for one a participant
185
+ # may use too, its handler answering them as theirs (the participant
186
+ # page, the trimmed info, the filtered show; docs/internals/
187
+ # participants.md). A :session route runs in #session_route: an
188
+ # ArgumentError is its unknown session.
189
+ ROUTE_PERMISSIONS = %i[owner participant].freeze
163
190
  ROUTES = [
164
- ["GET", %r{\A/(?:index\.html)?\z}, :serve_index],
165
- ["GET", %r{\A/api/sessions\z}, :handle_list],
166
- ["POST", %r{\A/api/sessions\z}, :handle_create],
167
- ["GET", %r{\A/api/info\z}, :handle_info],
168
- ["GET", %r{\A/api/models\z}, :handle_models],
169
- ["GET", %r{\A/api/history\z}, :handle_history],
170
- ["GET", %r{\A/api/events\z}, :handle_events],
171
- ["POST", %r{\A/api/render\z}, :handle_render],
172
- ["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream],
173
- ["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output, :session],
174
- ["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel, :session],
175
- ["POST", %r{\A/api/sessions/([^/]+)/tasks/([^/]+)/stop\z}, :handle_task_stop, :session],
176
- ["POST", %r{\A/api/sessions/([^/]+)/turn/([^/]+)/dequeue\z}, :handle_dequeue_turn, :session],
177
- ["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop, :session],
178
- ["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart, :session],
179
- ["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive, :session],
180
- ["POST", %r{\A/api/sessions/([^/]+)/unarchive\z}, :handle_unarchive, :session],
181
- ["POST", %r{\A/api/sessions/([^/]+)/turn\z}, :handle_turn, :session],
182
- ["POST", %r{\A/api/sessions/([^/]+)/answer\z}, :handle_question_answer],
183
- ["POST", %r{\A/api/sessions/([^/]+)/question/dismiss\z}, :handle_question_dismiss],
184
- ["POST", %r{\A/api/sessions/([^/]+)/command\z}, :handle_command, :session],
185
- ["POST", %r{\A/api/sessions/([^/]+)/images\z}, :handle_image_upload],
186
- ["GET", %r{\A/api/sessions/([^/]+)/images/([^/]+)\z}, :handle_image],
187
- ["GET", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_list, :session],
188
- ["GET", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_show, :session],
189
- ["POST", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_add, :session],
190
- ["DELETE", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_delete, :session],
191
- ["GET", %r{\A/api/sessions/([^/]+)/working_set\z}, :handle_working_set, :session],
192
- ["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show, :session],
193
- ["DELETE", %r{\A/api/sessions/([^/]+)\z}, :handle_delete, :session]
191
+ ["GET", %r{\A/(?:index\.html)?\z}, :serve_index, :participant],
192
+ ["GET", %r{\A/api/sessions\z}, :handle_list, :owner],
193
+ ["POST", %r{\A/api/sessions\z}, :handle_create, :owner],
194
+ ["GET", %r{\A/api/sessions/resolve\z}, :handle_resolve, :owner],
195
+ ["GET", %r{\A/api/sessions/summaries\z}, :handle_summaries, :owner],
196
+ ["GET", %r{\A/api/info\z}, :handle_info, :participant],
197
+ ["GET", %r{\A/api/models\z}, :handle_models, :owner],
198
+ ["GET", %r{\A/api/history\z}, :handle_history, :owner],
199
+ ["GET", %r{\A/api/events\z}, :handle_events, :owner],
200
+ ["POST", %r{\A/api/render\z}, :handle_render, :owner],
201
+ ["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream, :owner],
202
+ ["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output, :owner, :session],
203
+ ["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel, :owner, :session],
204
+ ["POST", %r{\A/api/sessions/([^/]+)/tasks/([^/]+)/stop\z}, :handle_task_stop, :owner, :session],
205
+ ["POST", %r{\A/api/sessions/([^/]+)/turn/([^/]+)/dequeue\z}, :handle_dequeue_turn, :owner, :session],
206
+ ["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop, :owner, :session],
207
+ ["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart, :owner, :session],
208
+ ["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive, :owner, :session],
209
+ ["POST", %r{\A/api/sessions/([^/]+)/unarchive\z}, :handle_unarchive, :owner, :session],
210
+ ["POST", %r{\A/api/sessions/([^/]+)/turn\z}, :handle_turn, :owner, :session],
211
+ ["POST", %r{\A/api/sessions/([^/]+)/wait/prepare\z}, :handle_wait_prepare, :owner, :session],
212
+ ["POST", %r{\A/api/sessions/([^/]+)/wait\z}, :handle_wait, :owner, :session],
213
+ ["POST", %r{\A/api/sessions/([^/]+)/notes\z}, :handle_notes, :owner, :session],
214
+ ["POST", %r{\A/api/sessions/([^/]+)/wake\z}, :handle_wake, :owner, :session],
215
+ ["GET", %r{\A/api/sessions/([^/]+)/worker\z}, :handle_worker, :owner, :session],
216
+ ["POST", %r{\A/api/sessions/([^/]+)/exit\z}, :handle_exit, :owner, :session],
217
+ ["POST", %r{\A/api/sessions/([^/]+)/recap\z}, :handle_recap, :owner, :session],
218
+ ["GET", %r{\A/api/sessions/([^/]+)/stats\z}, :handle_stats, :owner, :session],
219
+ ["GET", %r{\A/api/sessions/([^/]+)/children/counts\z}, :handle_children_counts, :owner, :session],
220
+ ["GET", %r{\A/api/sessions/([^/]+)/turns/([^/]+)/records\z}, :handle_turn_records, :owner, :session],
221
+ ["POST", %r{\A/api/sessions/([^/]+)/answer\z}, :handle_question_answer, :owner],
222
+ ["POST", %r{\A/api/sessions/([^/]+)/question/dismiss\z}, :handle_question_dismiss, :owner],
223
+ ["POST", %r{\A/api/sessions/([^/]+)/command\z}, :handle_command, :owner, :session],
224
+ ["POST", %r{\A/api/sessions/([^/]+)/images\z}, :handle_image_upload, :owner],
225
+ ["GET", %r{\A/api/sessions/([^/]+)/images/([^/]+)\z}, :handle_image, :owner],
226
+ ["GET", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_list, :owner, :session],
227
+ ["GET", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_show, :owner, :session],
228
+ ["POST", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_add, :owner, :session],
229
+ ["POST", %r{\A/api/sessions/([^/]+)/context/([^/]+)/push\z}, :handle_context_push, :owner, :session],
230
+ ["DELETE", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_delete, :owner, :session],
231
+ ["GET", %r{\A/api/sessions/([^/]+)/working_set\z}, :handle_working_set, :owner, :session],
232
+ ["GET", %r{\A/api/sessions/([^/]+)/participants\z}, :handle_participants_list, :owner, :session],
233
+ ["POST", %r{\A/api/sessions/([^/]+)/participants\z}, :handle_participants_invite, :owner, :session],
234
+ ["DELETE", %r{\A/api/sessions/([^/]+)/participants\z}, :handle_participants_revoke, :owner, :session],
235
+ ["GET", %r{\A/api/participants\z}, :handle_participants_list, :owner],
236
+ ["DELETE", %r{\A/api/participants\z}, :handle_participants_revoke, :owner],
237
+ ["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show, :participant, :session],
238
+ ["DELETE", %r{\A/api/sessions/([^/]+)\z}, :handle_delete, :owner, :session]
194
239
  ].freeze
195
240
 
196
241
  def route(env)
@@ -198,16 +243,22 @@ module Samagotchi
198
243
  return forbidden unless local_host_header?(env)
199
244
  return cross_origin if cross_site?(env)
200
245
 
201
- if @lan && !LOOPBACK_PEERS.include?(env["REMOTE_ADDR"].to_s)
202
- denied = token_gate(req)
203
- return denied if denied
204
- end
246
+ principal, denied = token_gate(req)
247
+ return denied if denied
205
248
 
206
- ROUTES.each do |verb, pattern, handler, kind|
249
+ # The principal the handlers read (#principal): never the body's.
250
+ env[PRINCIPAL_ENV] = principal
251
+ participant_seen(req, principal) unless principal.owner?
252
+ ROUTES.each do |verb, pattern, handler, permission, kind|
207
253
  next unless req.request_method == verb && (m = pattern.match(req.path_info))
254
+ # Before the permission: a participant learns nothing of another session.
255
+ return unknown_session(m.captures.first) if other_session?(principal, req.path_info, m.captures)
256
+ return not_permitted_for(req, principal) unless principal.permits?(permission)
257
+
208
258
  # Every /api/sessions/:id route: an id that isn't one (Session.valid_id?,
209
259
  # "..", a %2F) is an unknown session before any handler makes a path.
210
- if req.path_info.start_with?("/api/sessions/") && !Session.valid_id?(m.captures.first)
260
+ # (/api/sessions/resolve and /summaries take no id.)
261
+ if req.path_info.start_with?("/api/sessions/") && !m.captures.empty? && !Session.valid_id?(m.captures.first)
211
262
  return error_response(404, "not_found", "Session not found: #{m.captures.first.to_s[0, 80]}")
212
263
  end
213
264
 
@@ -293,25 +344,57 @@ module Samagotchi
293
344
  TOKEN_COOKIE_ATTRIBUTES = "HttpOnly; SameSite=Lax; Path=/; Max-Age=34560000"
294
345
  REFUSED_LOG_INTERVAL = 60.0
295
346
 
347
+ # Where #route keeps the request's Principal for its handlers.
348
+ PRINCIPAL_ENV = "samagotchi.principal"
349
+ PAGE_PATHS = ["/", "/index.html"].freeze
350
+
351
+ # The request's Principal, as #route resolved it.
352
+ def principal(req) = req.env.fetch(PRINCIPAL_ENV)
353
+
296
354
  # LAN mode, a peer that isn't this machine (the peer is the socket's
297
355
  # address, REMOTE_ADDR: never X-Forwarded-For or req.ip, which trust
298
356
  # headers a phone could send): every route needs the access token, as
299
357
  # the chi_token cookie or an Authorization: Bearer header. The page
300
358
  # with ?token= trades it for the cookie and a 303 to itself without
301
359
  # it, so the token doesn't stay in the phone's history.
302
- # @return [Array, nil] the response that refuses it, or nil to go on
360
+ # @return [Array(Principal, nil), Array(nil, Array)] the request's
361
+ # principal (#principal_for), or the response that refuses it
303
362
  def token_gate(req)
363
+ principal = principal_for(req)
364
+ return [nil, grant_ended_response(req) || unauthorized(req)] if principal.nil?
365
+ # The cookie gets the token that was presented: the owner's, or the
366
+ # participant's grant's.
367
+ return [nil, token_redirect(req, req.env[GRANT_TOKEN_ENV] || current_token)] if token_trade?(req)
368
+
369
+ [principal, nil]
370
+ end
371
+
372
+ # Who the request is: the owner for a peer the gate doesn't ask (no
373
+ # LAN mode, or this machine's loopback) and for the access token (the
374
+ # page's ?token= on a trade, else the Bearer header or the cookie);
375
+ # a participant for a live grant's token (Grants, looked up only when
376
+ # no owner token was given); nil for anyone else.
377
+ # @return [Principal, nil]
378
+ def principal_for(req)
379
+ return Principal::OWNER unless token_required?(req)
380
+
304
381
  token = current_token
305
- if req.get? && ["/", "/index.html"].include?(req.path_info) && req.params.key?("token")
306
- return token_redirect(req, token) if token_match?(req.params["token"], token)
307
- else
308
- auth = req.get_header("HTTP_AUTHORIZATION").to_s[/\ABearer +(\S+)\z/, 1]
309
- return nil if token_match?(auth, token) || token_match?(req.cookies[TOKEN_COOKIE], token)
310
- end
382
+ given = token_trade?(req) ? [req.params["token"]] : [bearer_token(req), req.cookies[TOKEN_COOKIE]]
383
+ return Principal::OWNER if given.any? { |value| token_match?(value, token) }
311
384
 
312
- unauthorized(req)
385
+ participant_principal(req, given.select { |value| Grants.token?(value) })
313
386
  end
314
387
 
388
+ def token_required?(req) = @lan && !LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s)
389
+
390
+ # The page opened with ?token= (a gated request): its token is traded
391
+ # for the cookie, whatever else the request carries.
392
+ def token_trade?(req)
393
+ token_required?(req) && req.get? && PAGE_PATHS.include?(req.path_info) && req.params.key?("token")
394
+ end
395
+
396
+ def bearer_token(req) = req.get_header("HTTP_AUTHORIZATION").to_s[/\ABearer +(\S+)\z/, 1]
397
+
315
398
  def current_token
316
399
  token = @lan[:token]
317
400
  token.respond_to?(:current) ? token.current : token
@@ -398,6 +481,11 @@ module Samagotchi
398
481
  error_response(403, "cross_origin", "requests from other websites are refused")
399
482
  end
400
483
 
484
+ # A route the request's principal may not use (Principal#permits?).
485
+ def not_permitted
486
+ error_response(403, "not_permitted", "only the session's owner can do that")
487
+ end
488
+
401
489
  def forbidden
402
490
  names = @lan ? "127.0.0.1, [::1], localhost and #{@lan[:ip]}" : "127.0.0.1, [::1] and localhost"
403
491
  error_response(403, "forbidden", "only the host names #{names} are answered")
@@ -450,12 +538,21 @@ module Samagotchi
450
538
  # What a second `chi web` probes before starting its own server: this
451
539
  # is chi web, and it knows ?dir (features). installed: the newest chi
452
540
  # on this machine (nil until the hub has looked); newest_workers: the
453
- # workers it starts run that one, not this chi web's version.
454
- def handle_info(_req)
541
+ # workers it starts run that one, not this chi web's version;
542
+ # session_commands: the routes chi --remote needs
543
+ # (SessionAccessRoutes::FEATURE); remote_attach: the ones a remote
544
+ # attached TUI needs (ATTACH_FEATURE). lan: its LAN address, nil when it
545
+ # listens on loopback only. A participant gets app, version and
546
+ # features only (#participant_info).
547
+ def handle_info(req)
548
+ return participant_info unless principal(req).owner?
549
+
455
550
  json_response(200, { app: "chi-web", version: Samagotchi::VERSION, installed: @hub&.installed, pid: Process.pid,
456
- cwd: Dir.pwd, features: %w[dir newest_workers], lan: @lan && @lan[:ip] })
551
+ cwd: Dir.pwd, features: INFO_FEATURES, lan: @lan && @lan[:ip] })
457
552
  end
458
553
 
554
+ INFO_FEATURES = ["dir", "newest_workers", SessionAccessRoutes::FEATURE, SessionAccessRoutes::ATTACH_FEATURE].freeze
555
+
459
556
  # The models a new session can start on, spelled as chi spells them
460
557
  # elsewhere: bare for the default host (what default.model holds), and
461
558
  # host:model for another host. The default is the configured model, or
@@ -679,17 +776,32 @@ module Samagotchi
679
776
  return error_response(400, "invalid_settings", e.message)
680
777
  end
681
778
 
779
+ # toolkit, tools, no_tools: its toolkit (chi send --new --toolkit,
780
+ # --tool, --no-tool: a name, tool names added and left out);
781
+ # parent_answers: what a parent agent may answer on its questions
782
+ # (hard, standard or soft; a looser one than config.yml's is capped,
783
+ # and the 201's parent_answers_warning says so).
784
+ toolkit, error = request_toolkit(body)
785
+ return error if error
786
+
787
+ parent_answers, error = request_parent_answers(body["parent_answers"])
788
+ return error if error
789
+
790
+ model = (body["model"] || body["model_name"]).to_s.strip
791
+ refused = create_refusal(body, idle ? body["preview"] : prompt, values: [parent_answers], model: model)
792
+ return refused if refused
793
+
682
794
  folder = dir ? { working_directory: dir } : {}
683
795
  # model: the model the session starts on (the start page's picker,
684
796
  # spelled as GET /api/models lists it); blank means the default.
685
- model = (body["model"] || body["model_name"]).to_s.strip
686
797
  folder[:model_name] = model unless model.empty?
687
798
  # preview: an idle session's first message, sent as its first turn
688
799
  # next; the list and the notifications name the session by it
689
800
  # until that turn is saved.
690
801
  preview = idle ? body["preview"].to_s.strip : ""
691
802
  folder[:preview] = preview unless preview.empty?
692
- setup = SessionSetup.new(llm_context: llm_context, thinking: thinking, settings: settings)
803
+ setup = SessionSetup.new(llm_context: llm_context, thinking: thinking, settings: settings, toolkit: toolkit,
804
+ parent_answers: parent_answers)
693
805
  folder[:setup] = setup unless setup.empty?
694
806
  begin
695
807
  session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
@@ -703,7 +815,8 @@ module Samagotchi
703
815
  # The projection holds it before the page hears back (no tick wait).
704
816
  @hub&.touch(session.id)
705
817
  record_history(body, prompt) if !idle && PromptHistory.shell_line?(prompt.to_s.strip)
706
- json_response(201, session_to_json(session).merge(bridge_port: port))
818
+ json_response(201, session_to_json(session).merge(bridge_port: port, model_warning: session.model_warning,
819
+ parent_answers_warning: Guardrails::ParentPolicy.capped_note(parent_answers)))
707
820
  end
708
821
 
709
822
  # POST /api/sessions with continues: <id, prefix or last:id>: the next
@@ -724,14 +837,17 @@ module Samagotchi
724
837
  unless given.is_a?(String) && !given.strip.empty?
725
838
  return error_response(400, "invalid_continues", "continues is a session id")
726
839
  end
727
- if (fields = %w[dir llm_context thinking settings].select { |key| body.key?(key) }).any?
840
+ if (fields = CONTINUE_TAKES.select { |key| body.key?(key) }).any?
728
841
  return error_response(400, "invalid_continues",
729
- "continues takes the previous session's folder, llm_context, thinking and settings; " \
730
- "leave out #{fields.join(", ")}")
842
+ "continues takes the previous session's folder, llm_context, thinking, toolkit, " \
843
+ "settings and parent_answers; leave out #{fields.join(", ")}")
731
844
  end
732
845
 
733
846
  preview = idle ? body["preview"].to_s.strip : ""
734
847
  model = (body["model"] || body["model_name"]).to_s.strip
848
+ refused = continue_refusal(given, body, idle ? body["preview"] : prompt, model: model)
849
+ return refused if refused
850
+
735
851
  session, error = continue_from(given, prompt: idle ? nil : prompt.to_s, preview: preview.empty? ? nil : preview,
736
852
  model: model.empty? ? nil : model)
737
853
  return error if error
@@ -746,22 +862,123 @@ module Samagotchi
746
862
  opener: session.opener))
747
863
  end
748
864
 
865
+ # chi send's client ids: its lines are checked as chi send checks them
866
+ # (#parent_gate).
867
+ CLI_SEND_IDS = [ClientId::CLI_SEND, ClientId::CLI_ANSWER].freeze
868
+
869
+ # What a continue takes from the previous link: refused beside it.
870
+ CONTINUE_TAKES = %w[dir llm_context thinking settings toolkit tools no_tools parent_answers].freeze
871
+
872
+ # The previous link's parent_answers and the model the new one runs,
873
+ # for #create_refusal; nil when the previous link isn't there
874
+ # (continue_from says so).
875
+ def continue_refusal(given, body, line, model:)
876
+ return nil unless body["check_vision"] == true || CLI_SEND_IDS.include?(body["client_id"])
877
+
878
+ previous = Session.load(SessionChain.resolve(given, state_dir: default_state_dir), state_dir: default_state_dir)
879
+ create_refusal(body, line, values: [previous.parent_answers], model: model, previous: previous)
880
+ rescue ArgumentError, ModelProfile::MissingModel
881
+ nil
882
+ end
883
+
884
+ # A create refused before anything spawns, as chi send --new refuses
885
+ # it (SendCommand::ParentGate, the vision check): its first line
886
+ # (+line+, the prompt or an idle one's preview) when it is a parent
887
+ # agent's /continue yes or a /parent-answers that loosens +values+
888
+ # (#parent_gate), and with check_vision: true (its first message
889
+ # brings images) a model known not to take them (422 no_vision).
890
+ # @param previous [Session, nil] a continue's previous link: its model
891
+ # decides when +model+ is blank
892
+ # @return [Array, nil] the response, or nil to go on
893
+ def create_refusal(body, line, values:, model:, previous: nil)
894
+ refused = parent_gate(line, client_id: body["client_id"], values: values)
895
+ return refused if refused
896
+ return nil unless body["check_vision"] == true
897
+
898
+ name, typed = if previous
899
+ SendCommand.continued_model(previous, model)
900
+ else
901
+ [model.empty? ? nil : model, nil]
902
+ end
903
+ vision_refused(name, typed)
904
+ end
905
+
906
+ # A line from chi send (client_id cli:send, or cli:answer for a parent
907
+ # agent's /continue yes) refused here as chi send refuses it on its own
908
+ # machine (SendCommand::ParentGate), held to this server's config.yml:
909
+ # a parent's /continue yes that guardrails.parent_answers (tightened by
910
+ # +values+, the targets' own parent_answers) refuses, 403
911
+ # parent_continue_refused; a /parent-answers line that loosens one of
912
+ # +values+, 403 parent_answers_refused (loosening is the user's at a
913
+ # UI). The page's lines (web:…) and others aren't checked here; the
914
+ # worker checks every line again.
915
+ # @return [Array, nil] the response, or nil to go on
916
+ def parent_gate(line, client_id:, values:)
917
+ line = line.to_s
918
+ return nil unless CLI_SEND_IDS.include?(client_id)
919
+
920
+ if client_id == ClientId::CLI_ANSWER && TurnFlow.continue_yes?(line) &&
921
+ (refusal = SendCommand::ParentGate.continue_refusal_for(values))
922
+ return error_response(403, "parent_continue_refused", refusal)
923
+ end
924
+ return nil unless SendCommand::ParentGate.loosens_any?(line, values)
925
+
926
+ error_response(403, "parent_answers_refused", ParentAnswersCommand::REFUSED)
927
+ end
928
+
929
+ # 422 no_vision in chi send's words when the model (+name+, nil: the
930
+ # default) is known not to take images; nil when it does or is
931
+ # unknown (the worker decides when the turn runs).
932
+ def vision_refused(name, typed)
933
+ refusal = SendCommand.vision_refusal(name, SendCommand.vision_answer(name, typed: typed))
934
+ refusal && error_response(422, "no_vision", refusal)
935
+ end
936
+
937
+ # The create request's toolkit (a name), tools and no_tools (names, a
938
+ # comma list each too), as chi send --new's --toolkit, --tool and
939
+ # --no-tool: [toolkit, nil] (nil for none), or [nil, a 400 response].
940
+ def request_toolkit(body)
941
+ name = body["toolkit"]
942
+ lists = %w[tools no_tools].to_h { |key| [key, body[key]] }
943
+ unless (name.nil? || name.is_a?(String)) &&
944
+ lists.values.all? { |list| list.nil? || (list.is_a?(Array) && list.all?(String)) }
945
+ return [nil, error_response(400, "invalid_toolkit", "toolkit is a name; tools and no_tools are lists of names")]
946
+ end
947
+
948
+ [Toolkit.from_flags(name: name, add: lists["tools"] || [], remove: lists["no_tools"] || []), nil]
949
+ end
950
+
951
+ # The create request's parent_answers (hard, standard or soft;
952
+ # "default" or none: config.yml's): [level, nil] or [nil, a 400].
953
+ def request_parent_answers(raw)
954
+ return [nil, nil] if raw.nil?
955
+
956
+ level = raw.is_a?(String) ? Guardrails::ParentPolicy.parse_level(raw) : :invalid
957
+ return [nil, nil] if level == :unset
958
+ unless Guardrails::ParentPolicy::ORDER.include?(level)
959
+ return [nil, error_response(400, "invalid_parent_answers",
960
+ "parent_answers takes #{Guardrails::ParentPolicy::ORDER.join(", ")}")]
961
+ end
962
+
963
+ [level, nil]
964
+ end
965
+
749
966
  # SessionManager.continue_session, its refusals as responses: [session,
750
967
  # nil] or [nil, the response]. Only its own errors: what fails after
751
968
  # the new link started is not a refusal of it.
752
969
  def continue_from(given, prompt:, preview:, model:)
753
970
  [@manager.continue_session(given, prompt: prompt, preview: preview, model: model, state_dir: @state_dir), nil]
754
971
  rescue SessionContinue::Refused => e
755
- extra = e.reason == :continued ? { next_id: e.ids.first } : { ids: e.ids }
756
- [nil, json_response(409, { error: e.reason.to_s, detail: e.message, **extra })]
972
+ extra = e.reason == :continued ? { next_id: e.ids.first } : {}
973
+ [nil, refusal_response(409, e.reason.to_s, e, session_id: e.session_id, reason: e.reason.to_s, ids: e.ids, **extra)]
757
974
  rescue SessionManager::ArchiveRefused => e
758
- [nil, error_response(409, e.reason == :scratch ? "scratch" : "busy", e.message)]
759
- rescue SessionManager::OwnedByTUI
760
- [nil, owned_by_repl(given)]
975
+ [nil, archive_refused(e)]
976
+ rescue SessionManager::OwnedByTUI => e
977
+ [nil, owned_by_repl(given, e)]
761
978
  rescue ModelProfile::MissingModel => e
762
979
  [nil, error_response(400, "invalid_model", e.message)]
763
980
  rescue Session::AmbiguousId => e
764
- [nil, error_response(400, "ambiguous_id", e.message)]
981
+ [nil, refusal_response(400, "ambiguous_id", e, ids: e.ids)]
765
982
  rescue ArgumentError => e
766
983
  [nil, error_response(404, "not_found", e.message)]
767
984
  end
@@ -800,10 +1017,13 @@ module Samagotchi
800
1017
  end
801
1018
 
802
1019
  def handle_show(req, id)
1020
+ return participant_show(id) unless principal(req).owner?
1021
+
803
1022
  # The page's light re-reads: never the whole conversation.
804
1023
  return handle_cards_read(id) if req.params["cards"] == "1"
805
1024
  return handle_tail_read(req, id) if req.params["tail"] == "1"
806
1025
  return handle_timing_read(id) if req.params["timing"] == "1"
1026
+ return handle_brief_read(id) if req.params["brief"] == "1"
807
1027
 
808
1028
  session = @session_class.load(id, state_dir: default_state_dir)
809
1029
  # Read-only preview: selecting a session never spawns a worker.
@@ -1111,17 +1331,38 @@ module Samagotchi
1111
1331
  qid = body["id"]
1112
1332
  return error_response(400, "missing_fields", "id is required") if qid.to_s.strip.empty?
1113
1333
 
1114
- request = ->(client) { client.answer(id: qid, selected: body["selected"], freeform: body["freeform"]) }
1334
+ # client_id: only chi answer's (a parent agent's answer, which the
1335
+ # worker holds to its guardrails.parent_answers) is passed on; any
1336
+ # other is dropped, so a forward can only tighten (an answer never
1337
+ # passes for the user's own by it).
1338
+ client_id = body["client_id"] == ClientId::CLI_ANSWER ? ClientId::CLI_ANSWER : nil
1339
+ request = once_more_on_late do |client|
1340
+ client.answer(id: qid, selected: body["selected"], freeform: body["freeform"], client_id: client_id)
1341
+ end
1115
1342
  relay(id, bridge_client(id), request, what: "the answer was not sent", cant: "answer questions") do |reply|
1116
1343
  case reply.status
1117
1344
  when 200 then json_response(200, { status: "answered", session_id: id, id: qid })
1118
1345
  # Another client answered first (or the question was cancelled).
1119
1346
  when 409 then error_response(409, "question_not_pending", reply_detail(reply, "question not pending"))
1120
1347
  when 400 then error_response(400, "invalid_answer", reply_detail(reply, "answer rejected"))
1348
+ # The worker's guardrails.parent_answers refused a parent's option.
1349
+ when 403 then json_response(403, { error: "parent_answer_refused", reason: reply.json&.dig("reason"),
1350
+ detail: reply_detail(reply, "the worker refused that option") })
1121
1351
  end
1122
1352
  end
1123
1353
  end
1124
1354
 
1355
+ # A request to the worker sent once more when the worker dropped it as
1356
+ # past its deadline (408), as chi answer does on this machine
1357
+ # (SessionAccess::Local#answer); a second 408 is #relay's 504
1358
+ # worker_timeout. The question stays open either way.
1359
+ def once_more_on_late
1360
+ lambda do |client|
1361
+ reply = yield client
1362
+ reply.status == 408 ? yield(client) : reply
1363
+ end
1364
+ end
1365
+
1125
1366
  # Leave the pending question unanswered (the card's Dismiss). A question
1126
1367
  # only exists while its worker waits on it, so this needs a live bridge.
1127
1368
  def handle_question_dismiss(req, id)
@@ -1131,11 +1372,14 @@ module Samagotchi
1131
1372
  qid = body["id"].to_s
1132
1373
  return error_response(400, "missing_fields", "id is required") if qid.strip.empty?
1133
1374
 
1134
- request = ->(client) { client.dismiss_question(id: qid) }
1375
+ request = once_more_on_late { |client| client.dismiss_question(id: qid) }
1135
1376
  relay(id, bridge_client(id), request, what: "the question was not dismissed", cant: "dismiss questions") do |reply|
1136
1377
  case reply.status
1137
1378
  when 200 then json_response(200, { status: "dismissed", session_id: id, id: qid })
1138
- when 409 then error_response(409, "question_not_pending", reply_detail(reply, "question not pending"))
1379
+ when 409
1380
+ # The step-limit question can't be left unanswered.
1381
+ code = reply.json&.dig("error") == "not_dismissable" ? "not_dismissable" : "question_not_pending"
1382
+ error_response(409, code, reply_detail(reply, "question not pending"))
1139
1383
  end
1140
1384
  end
1141
1385
  end
@@ -1159,15 +1403,23 @@ module Samagotchi
1159
1403
  record_history(body, line) if PromptHistory.shell_line?(line)
1160
1404
  json_response(202, reply.json || { status: "accepted" })
1161
1405
  when 400
1162
- # One of this chi's own commands the worker doesn't know: it runs an older chi.
1406
+ # One of this chi's own commands the worker doesn't know: it runs an
1407
+ # older chi. command: which one, so a remote TUI names it as its
1408
+ # Bridge's own 400 unknown_command would (WebBridgeClient).
1163
1409
  name = reply.json&.dig("error") == "unknown_command" && SessionCommands.builtin_name(line)
1164
- next error_response(501, "not_supported", BridgeClient.stale_worker_message(id, cant: "run #{name}")) if name
1410
+ next older_worker_command(id, name) if name
1165
1411
 
1166
1412
  error_response(400, reply.json&.dig("error") || "unknown_command", reply_detail(reply, "not a session command"))
1167
1413
  end
1168
1414
  end
1169
1415
  rescue SessionManager::OwnedByTUI => e
1170
- error_response(409, "owned_by_tui", e.message)
1416
+ owned_by_tui(e)
1417
+ end
1418
+
1419
+ # 501 not_supported for a builtin +name+ ("/title") the worker lacks.
1420
+ def older_worker_command(id, name)
1421
+ words = BridgeClient.stale_worker_message(id, cant: "run #{name}")
1422
+ json_response(501, { error: "not_supported", detail: words, message: words, command: name })
1171
1423
  end
1172
1424
 
1173
1425
  # Hand a request to the session's live Bridge (+client+, nil without
@@ -1205,8 +1457,22 @@ module Samagotchi
1205
1457
 
1206
1458
  # 409 owned_by_tui in the words a refused archive, delete or continue
1207
1459
  # shows (stop, restart, a turn and a command show OwnedByTUI's own).
1208
- def owned_by_repl(id)
1209
- error_response(409, "owned_by_tui", SessionManager::OwnedByTUI.new(id).message)
1460
+ def owned_by_repl(id, error = nil)
1461
+ named = SessionManager::OwnedByTUI.new(id)
1462
+ error ||= named
1463
+ refusal_response(409, "owned_by_tui", error, detail: named.message, session_id: error.session_id)
1464
+ end
1465
+
1466
+ # 409 owned_by_tui in OwnedByTUI's own words (stop, restart, a turn, a
1467
+ # command).
1468
+ def owned_by_tui(error) = refusal_response(409, "owned_by_tui", error, session_id: error.session_id)
1469
+
1470
+ # 409 scratch or busy (every other reason: a turn, a queued prompt, an
1471
+ # open step-limit question, in it or a delegate, +busy_id+), with the
1472
+ # reason for a remote client.
1473
+ def archive_refused(error)
1474
+ refusal_response(409, error.reason == :scratch ? "scratch" : "busy", error, session_id: error.session_id,
1475
+ reason: error.reason.to_s, busy_id: error.busy_id)
1210
1476
  end
1211
1477
 
1212
1478
  def not_live(id)
@@ -1266,19 +1532,44 @@ module Samagotchi
1266
1532
  return error_response(400, refused[:error], refused[:detail])
1267
1533
  end
1268
1534
 
1535
+ refused = turn_refusal(id, body, prompt.to_s, images)
1536
+ return refused if refused
1537
+
1538
+ # no_interrupt: an attached TUI's --no-interrupt (chi --remote --attach).
1539
+ # author: the principal's (nil for the owner), never the body's.
1269
1540
  result = SessionManager.deliver_turn(id, prompt: prompt.to_s, client_id: client_id, images: images,
1270
- delivery: delivery, state_dir: @state_dir, manager: @manager,
1541
+ delivery: delivery, no_interrupt: body["no_interrupt"] == true,
1542
+ author: principal(req).author,
1543
+ state_dir: @state_dir, manager: @manager,
1271
1544
  bridge: -> { live_bridge_client(id) })
1272
1545
  case result.status
1273
1546
  when :accepted
1274
1547
  record_history(body, prompt)
1275
- json_response(202, result.ack)
1548
+ # via: bridge or file (the worker was gone: its input file), which
1549
+ # names the turn a remote chi send --wait follows (WaitTarget).
1550
+ json_response(202, result.ack.merge("via" => result.via.to_s))
1276
1551
  when :refused then json_response(result.code, result.ack)
1277
1552
  when :timeout then error_response(504, "worker_timeout", result.detail)
1278
1553
  else error_response(500, "enqueue_failed", "could not write turn input")
1279
1554
  end
1280
1555
  rescue SessionManager::OwnedByTUI => e
1281
- error_response(409, "owned_by_tui", e.message)
1556
+ owned_by_tui(e)
1557
+ end
1558
+
1559
+ # A turn refused before it goes in, as chi send refuses it on its own
1560
+ # machine: a chi send line #parent_gate refuses (the session's own
1561
+ # parent_answers), and with check_vision: true images to a model known
1562
+ # not to take them (422 no_vision). The page sends neither.
1563
+ # @return [Array, nil] the response, or nil to go on
1564
+ def turn_refusal(id, body, prompt, images)
1565
+ vision = body["check_vision"] == true && !images.empty?
1566
+ return nil unless vision || CLI_SEND_IDS.include?(body["client_id"])
1567
+
1568
+ session = @session_class.load(id, state_dir: default_state_dir)
1569
+ refused = parent_gate(prompt, client_id: body["client_id"], values: [session.parent_answers])
1570
+ return refused if refused
1571
+
1572
+ vision ? vision_refused(session.model_name, session.model_typed) : nil
1282
1573
  end
1283
1574
 
1284
1575
  # A turn's images as [{file:, name:}] (refs to uploads already in the
@@ -1420,14 +1711,27 @@ module Samagotchi
1420
1711
  end
1421
1712
  end
1422
1713
 
1423
- def handle_stop(_req, id)
1714
+ # POST /api/sessions/:id/stop {wait?}: 200 {status: "stopped",
1715
+ # session_id, released}: released, whether the worker let go of the
1716
+ # session within the wait (STOP_WAIT_SECONDS, or the body's seconds up
1717
+ # to SessionManager::STOP_WAIT: chi sessions stop's two wordings); 409
1718
+ # owned_by_tui.
1719
+ def handle_stop(req, id)
1424
1720
  # Bounded, so a Rack thread isn't held long; a worker still exiting
1425
1721
  # after it just means an immediate resume finds it (rare).
1426
- @manager.stop_session(id, state_dir: @state_dir, wait: STOP_WAIT_SECONDS)
1722
+ released = @manager.stop_session(id, state_dir: @state_dir, wait: stop_wait(req))
1427
1723
  @hub&.touch(id)
1428
- json_response(200, { status: "stopped", session_id: id })
1724
+ json_response(200, { status: "stopped", session_id: id, released: released == true })
1429
1725
  rescue SessionManager::OwnedByTUI => e
1430
- error_response(409, "owned_by_tui", e.message)
1726
+ owned_by_tui(e)
1727
+ end
1728
+
1729
+ # The body's wait (a number of seconds), capped at
1730
+ # SessionManager::STOP_WAIT; STOP_WAIT_SECONDS without one.
1731
+ def stop_wait(req)
1732
+ body = parse_json(request_body(req, 4096))
1733
+ wait = body["wait"] if body.is_a?(Hash)
1734
+ wait.is_a?(Numeric) && wait.positive? ? [wait, SessionManager::STOP_WAIT].min : STOP_WAIT_SECONDS
1431
1735
  end
1432
1736
 
1433
1737
  # POST /api/sessions/:id/restart: hand the session to a new worker on
@@ -1443,9 +1747,9 @@ module Samagotchi
1443
1747
  version: result.version })
1444
1748
  rescue SessionManager::RestartRefused => e
1445
1749
  error = %i[not_running unsupported failed].include?(e.reason) ? e.reason.to_s : "held"
1446
- json_response(409, { error: error, reason: e.reason.to_s, detail: e.message, session_id: id })
1750
+ refusal_response(409, error, e, reason: e.reason.to_s, session_id: id, version: e.version)
1447
1751
  rescue SessionManager::OwnedByTUI => e
1448
- error_response(409, "owned_by_tui", e.message)
1752
+ owned_by_tui(e)
1449
1753
  end
1450
1754
 
1451
1755
  # POST /api/sessions/:id/archive: hide it (and its delegates) from the
@@ -1457,10 +1761,10 @@ module Samagotchi
1457
1761
  (result[:archived] + result[:discarded]).each { |sid| @hub&.touch(sid) }
1458
1762
  json_response(200, { status: "archived", session_id: result[:id], archived: result[:archived],
1459
1763
  stopped: result[:stopped], discarded: result[:discarded] })
1460
- rescue SessionManager::OwnedByTUI
1461
- owned_by_repl(id)
1764
+ rescue SessionManager::OwnedByTUI => e
1765
+ owned_by_repl(id, e)
1462
1766
  rescue SessionManager::ArchiveRefused => e
1463
- error_response(409, e.reason == :scratch ? "scratch" : "busy", e.message)
1767
+ archive_refused(e)
1464
1768
  end
1465
1769
 
1466
1770
  # POST /api/sessions/:id/unarchive: back to the lists, delegates too.
@@ -1470,17 +1774,21 @@ module Samagotchi
1470
1774
  json_response(200, { status: "unarchived", session_id: result[:id], unarchived: result[:unarchived] })
1471
1775
  end
1472
1776
 
1473
- # DELETE /api/sessions/:id: the session for good. A live worker is
1474
- # stopped first (the page's confirm says so); a chi REPL's session is
1475
- # refused.
1476
- def handle_delete(_req, id)
1477
- result = @manager.delete_session(id, state_dir: @state_dir, stop: true, wait: STOP_WAIT_SECONDS)
1777
+ # DELETE /api/sessions/:id?force=true: the session for good. With
1778
+ # force a live worker is stopped first (the page sends it: its confirm
1779
+ # says so); without it a live worker's session is refused, 409
1780
+ # worker_running, as chi sessions delete without --force. A chi REPL's
1781
+ # session is always refused.
1782
+ def handle_delete(req, id)
1783
+ result = @manager.delete_session(id, state_dir: @state_dir, stop: truthy_param?(req.params["force"]),
1784
+ wait: STOP_WAIT_SECONDS)
1478
1785
  @hub&.touch(result[:id])
1479
1786
  json_response(200, { status: "deleted", session_id: result[:id], stopped: result[:stopped] })
1480
- rescue SessionManager::OwnedByTUI
1481
- owned_by_repl(id)
1787
+ rescue SessionManager::OwnedByTUI => e
1788
+ owned_by_repl(id, e)
1482
1789
  rescue SessionManager::DeleteRefused => e
1483
- error_response(409, e.reason.to_s, "#{e.message}; try again in a moment")
1790
+ detail = e.reason == :still_stopping ? "#{e.message}; try again in a moment" : e.message
1791
+ refusal_response(409, e.reason.to_s, e, detail: detail, session_id: e.session_id, reason: e.reason.to_s)
1484
1792
  end
1485
1793
 
1486
1794
  # ── The working set (web.working_set) ───────────────────────────────
@@ -1510,7 +1818,8 @@ module Samagotchi
1510
1818
  llm_context = SessionSetting::LLM_CONTEXT.snapshot_value(live && live["session_state_snapshot"]) do
1511
1819
  SessionSetting::LLM_CONTEXT.saved_summary(session, registry: host_registry)
1512
1820
  end
1513
- snapshot = WorkingSet.build(messages, cwd: session.working_directory, strategy: working_set_strategy(llm_context))
1821
+ snapshot = WorkingSet.build(messages, cwd: session.working_directory, strategy: working_set_strategy(llm_context),
1822
+ pins: session.working_set_pins)
1514
1823
  json_response(200, snapshot.to_h.merge(running: running))
1515
1824
  end
1516
1825
 
@@ -1536,29 +1845,44 @@ module Samagotchi
1536
1845
  # provider (ContextProviders): never a command.
1537
1846
 
1538
1847
  # GET /api/sessions/:id/context: the sources this session sees, muted
1539
- # ones marked, with what the chips show.
1540
- def handle_context_list(_req, id)
1848
+ # ones marked, with what the chips show, never a command. ?full=1
1849
+ # (chi context ls from another machine, SessionAccess::Remote): each
1850
+ # source as it was added too, its command included (#context_row), and
1851
+ # a project's source a session's own of its name shadows, marked
1852
+ # shadowed.
1853
+ def handle_context_list(req, id)
1541
1854
  session = @session_class.load(id, state_dir: default_state_dir)
1542
1855
  own = ContextSources.session_location(id, state_dir: default_state_dir)
1543
1856
  subs = own.subscriptions
1544
- rows = attached_context(session).map { |attached| context_row(attached, own, subs) }
1857
+ full = truthy_param?(req.params["full"])
1858
+ rows = attached_context(session, shadowed: full).map { |attached| context_row(attached, own, subs, full: full) }
1545
1859
  json_response(200, { session_id: id, sources: rows, can_add_url: ContextProviders.any? })
1546
1860
  end
1547
1861
 
1548
- # GET /api/sessions/:id/context/:name: one source with its text.
1549
- def handle_context_show(_req, id, name)
1862
+ # GET /api/sessions/:id/context/:name: one source with its text
1863
+ # (?full=1: as the list's).
1864
+ def handle_context_show(req, id, name)
1550
1865
  session = @session_class.load(id, state_dir: default_state_dir)
1551
1866
  attached = find_context(session, name) or return error_response(404, "not_found", "no source #{name[0, 40]}")
1552
1867
  own = ContextSources.session_location(id, state_dir: default_state_dir)
1553
- json_response(200, context_row(attached, own, own.subscriptions).merge(text: attached.snapshot.text))
1554
- end
1555
-
1556
- # POST /api/sessions/:id/context {url, why}: the source an installed
1557
- # bundle's provider makes of +url+, attached to this session.
1868
+ row = context_row(attached, own, own.subscriptions, full: truthy_param?(req.params["full"]))
1869
+ json_response(200, row.merge(text: attached.snapshot.text))
1870
+ end
1871
+
1872
+ # POST /api/sessions/:id/context: a source attached to this session.
1873
+ # {url, why, hint}: the one an installed bundle's provider makes of
1874
+ # +url+ (the page's only kind). As chi context add: {name, push: true,
1875
+ # why, hint}, a source chi context push fills, or {name, cmd, every,
1876
+ # why, hint}, a command chi runs every so often. added_by: "cli" or
1877
+ # "agent" (chi context's), else "web". 201 {status: "attached", name};
1878
+ # 400 invalid_json or invalid_source (a bad name or every), 422
1879
+ # not_a_url or no_provider, 409 exists.
1558
1880
  def handle_context_add(req, id)
1559
1881
  session = @session_class.load(id, state_dir: default_state_dir)
1560
1882
  body = parse_json(request_body(req))
1561
- return error_response(400, "invalid_json", "give {url, why}") unless body.is_a?(Hash) && body["url"].is_a?(String)
1883
+ return error_response(400, "invalid_json", CONTEXT_ADD_BODIES) unless body.is_a?(Hash)
1884
+ return context_add_named(id, body) if body.key?("name")
1885
+ return error_response(400, "invalid_json", CONTEXT_ADD_BODIES) unless body["url"].is_a?(String)
1562
1886
 
1563
1887
  url = body["url"].strip
1564
1888
  return error_response(422, "not_a_url", "give an http(s) URL") unless url.match?(%r{\Ahttps?://\S+\z})
@@ -1567,7 +1891,7 @@ module Samagotchi
1567
1891
  return error_response(422, "no_provider", "no installed bundle resolves #{url[0, 200]}") unless resolved
1568
1892
 
1569
1893
  own = ContextSources.session_location(session.id, state_dir: default_state_dir)
1570
- own.add(web_source(resolved, body["why"], own.scope))
1894
+ own.add(web_source(resolved, body, own.scope))
1571
1895
  json_response(201, { status: "attached", name: resolved.name })
1572
1896
  rescue ContextProviders::Invalid => e
1573
1897
  error_response(422, "no_provider", e.message)
@@ -1575,16 +1899,49 @@ module Samagotchi
1575
1899
  error_response(409, "exists", e.message)
1576
1900
  end
1577
1901
 
1578
- def web_source(resolved, why, scope)
1579
- why = ContextSources.one_line(why.is_a?(String) ? why : nil, ContextSources::LINE_MAX_CHARS) || resolved.why
1580
- ContextSources::Source.new(name: resolved.name, cmd: resolved.cmd, every_seconds: resolved.every_seconds, why: why,
1581
- hint: resolved.hint, scope: scope, added_by: "web", created_at: Time.now.utc.iso8601,
1902
+ CONTEXT_ADD_BODIES = "give {url, why}, {name, push: true, why, hint} or {name, cmd, every, why, hint}"
1903
+
1904
+ # A --push or --cmd source (chi context add NAME), checked as chi
1905
+ # context checks it, attached through SessionAccess::Local.
1906
+ def context_add_named(id, body)
1907
+ name, cmd, push, every = body.values_at("name", "cmd", "push", "every")
1908
+ unless name.is_a?(String) && ((push == true) ^ cmd.is_a?(String)) && (every.nil? || every.is_a?(Integer))
1909
+ return error_response(400, "invalid_json", CONTEXT_ADD_BODIES)
1910
+ end
1911
+ return error_response(400, "invalid_source", "every is for a cmd source") if push && every
1912
+ return error_response(400, "invalid_source", "cmd is empty") if cmd && cmd.strip.empty?
1913
+
1914
+ source = begin
1915
+ ContextSources.check_name!(name)
1916
+ ContextSources::Source.new(name: name, cmd: cmd, every_seconds: ContextSources.check_every!(every),
1917
+ why: context_line(body["why"]), hint: context_line(body["hint"]), scope: nil,
1918
+ added_by: context_added_by(body), created_at: Time.now.utc.iso8601)
1919
+ rescue ContextSources::Invalid => e
1920
+ return error_response(400, "invalid_source", e.message)
1921
+ end
1922
+ session_access.context_add(context_target(id), source)
1923
+ json_response(201, { status: "attached", name: name })
1924
+ end
1925
+
1926
+ def context_line(value) = ContextSources.one_line(value.is_a?(String) ? value : nil, ContextSources::LINE_MAX_CHARS)
1927
+ def context_added_by(body) = %w[cli agent].include?(body["added_by"]) ? body["added_by"] : "web"
1928
+
1929
+ def web_source(resolved, body, scope)
1930
+ ContextSources::Source.new(name: resolved.name, cmd: resolved.cmd, every_seconds: resolved.every_seconds,
1931
+ why: context_line(body["why"]) || resolved.why,
1932
+ hint: context_line(body["hint"]) || resolved.hint, scope: scope,
1933
+ added_by: context_added_by(body), created_at: Time.now.utc.iso8601,
1582
1934
  provider: resolved.bundle)
1583
1935
  end
1584
1936
 
1585
1937
  # DELETE /api/sessions/:id/context/:name: the session's own source is
1586
- # detached; a project's is muted for this session only.
1587
- def handle_context_delete(_req, id, name)
1938
+ # detached; a project's is muted for this session only (the page's
1939
+ # ×). With ?own=1 (chi context rm) only the session's own is
1940
+ # detached: a project's is 409 project_source, as chi context rm
1941
+ # refuses it.
1942
+ def handle_context_delete(req, id, name)
1943
+ return context_remove_own(id, name) if truthy_param?(req.params["own"])
1944
+
1588
1945
  session = @session_class.load(id, state_dir: default_state_dir)
1589
1946
  attached = find_context(session, name) or return error_response(404, "not_found", "no source #{name[0, 40]}")
1590
1947
  if attached.location.session?
@@ -1596,8 +1953,20 @@ module Samagotchi
1596
1953
  end
1597
1954
  end
1598
1955
 
1599
- def attached_context(session)
1600
- ContextSources.attached(session.id, project_root: session.project_root, state_dir: default_state_dir)
1956
+ def context_remove_own(id, name)
1957
+ case session_access.context_remove(context_target(id), name)
1958
+ when :removed then json_response(200, { status: "detached", name: name })
1959
+ when :project
1960
+ error_response(409, "project_source", ContextCommand.project_source_message(name))
1961
+ else error_response(404, "not_found", "no source #{name[0, 40]}")
1962
+ end
1963
+ rescue ContextSources::Invalid
1964
+ error_response(404, "not_found", "no source #{name[0, 40]}")
1965
+ end
1966
+
1967
+ def attached_context(session, shadowed: false)
1968
+ ContextSources.attached(session.id, project_root: session.project_root, state_dir: default_state_dir,
1969
+ shadowed: shadowed)
1601
1970
  end
1602
1971
 
1603
1972
  # @return [ContextSources::Attached, nil]
@@ -1608,13 +1977,20 @@ module Samagotchi
1608
1977
  nil
1609
1978
  end
1610
1979
 
1611
- def context_row(attached, own, subs)
1980
+ # A source's row: what the page's chips show. +full+ (chi context ls
1981
+ # and show from another machine, SessionAccess::Remote): also the
1982
+ # source as it was added (source: Source#to_h, its command too) and
1983
+ # the snapshot (Snapshot#to_h without its text), and shadowed.
1984
+ def context_row(attached, own, subs, full: false)
1612
1985
  source = attached.source
1613
1986
  snapshot = attached.snapshot
1614
- { name: source.name, scope: source.scope, kind: source.push? ? "push" : "cmd", every_seconds: source.every_seconds,
1615
- why: source.why, hint: snapshot.hint || source.hint, summary: snapshot.summary, error: snapshot.error,
1616
- fetched_at: snapshot.fetched_at, has_text: snapshot.text?, muted: own.muted?(source.name),
1617
- unread: snapshot.text? && subs[source.name]&.read != snapshot.revision }
1987
+ row = { name: source.name, scope: source.scope, kind: source.push? ? "push" : "cmd", every_seconds: source.every_seconds,
1988
+ why: source.why, hint: snapshot.hint || source.hint, summary: snapshot.summary, error: snapshot.error,
1989
+ fetched_at: snapshot.fetched_at, has_text: snapshot.text?, muted: own.muted?(source.name),
1990
+ unread: snapshot.text? && subs[source.name]&.read != snapshot.revision }
1991
+ return row unless full
1992
+
1993
+ row.merge(source: source.to_h, snapshot: snapshot.to_h.except("text", :text), shadowed: attached.shadowed)
1618
1994
  end
1619
1995
 
1620
1996
  def handle_stream(req, id)
@@ -1654,9 +2030,25 @@ module Samagotchi
1654
2030
  return [200, headers.merge("rack.hijack" => hijack), []]
1655
2031
  end
1656
2032
 
1657
- query = stream_query(req)
1658
- [200, headers, ProxyStreamBody.new(host: DEFAULT_HOST, port: bridge_port, session_id: id, query: query, headers: req.env,
1659
- server_running: @server_running)]
2033
+ [200, headers, proxy_stream_body(id, req, bridge_port)]
2034
+ end
2035
+
2036
+ # The one ProxyStreamBody both of #handle_stream's transports send:
2037
+ # the session's Bridge stream, through the request principal's frame
2038
+ # filter (#stream_filter).
2039
+ def proxy_stream_body(id, req, port)
2040
+ ProxyStreamBody.new(host: DEFAULT_HOST, port: port, session_id: id, query: stream_query(req), headers: req.env,
2041
+ server_running: stream_running(principal(req)), filter: stream_filter(principal(req)))
2042
+ end
2043
+
2044
+ # What a principal's stream goes through on its way from the Bridge:
2045
+ # nil, the raw bytes unchanged, for the owner (the only principal
2046
+ # today). A filter is anything with #call(chunk) -> String (nil or
2047
+ # "" sends nothing); see ProxyStreamBody.
2048
+ def stream_filter(principal)
2049
+ return nil if principal.owner?
2050
+
2051
+ raise ArgumentError, "no stream filter for a #{principal.kind} principal"
1660
2052
  end
1661
2053
 
1662
2054
  # GET /api/events: the session list as one SSE stream per tab. The
@@ -1716,10 +2108,10 @@ module Samagotchi
1716
2108
  # (WEBrick via rackup) has already sent the status line + headers, so
1717
2109
  # only body bytes go to the socket here.
1718
2110
  def sse_hijack(id, req, port)
2111
+ # Made before the status line goes out: a principal with no filter
2112
+ # is refused (#stream_filter), never streamed.
2113
+ body = proxy_stream_body(id, req, port)
1719
2114
  lambda do |io|
1720
- query = stream_query(req)
1721
- body = ProxyStreamBody.new(host: DEFAULT_HOST, port: port, session_id: id, query: query, headers: req.env,
1722
- server_running: @server_running)
1723
2115
  body.each { |chunk| io.write(chunk) }
1724
2116
  rescue Errno::EPIPE, Errno::ECONNRESET, IOError
1725
2117
  nil # client went away — end the stream quietly
@@ -1777,6 +2169,8 @@ module Samagotchi
1777
2169
  end
1778
2170
 
1779
2171
  def serve_index(req)
2172
+ return participant_page(principal(req)) unless principal(req).owner?
2173
+
1780
2174
  path = File.join(@public_dir, "index.html")
1781
2175
  if File.file?(path)
1782
2176
  body = File.read(path).sub("<body>", "<body #{index_data_attributes(req)}>")
@@ -2323,15 +2717,20 @@ module Samagotchi
2323
2717
  # ends when the Bridge closes the stream or, as EventsBody, when the
2324
2718
  # server leaves :Running: rackup's WEBrick joins every request thread
2325
2719
  # before returning, and a read blocked on a quiet worker would hang
2326
- # Ctrl-C of chi web. The bytes go through unchanged.
2720
+ # Ctrl-C of chi web. Without a +filter+ the bytes go through unchanged
2721
+ # (the owner's stream); with one, each chunk read from the Bridge goes
2722
+ # through it, and what it returns is sent.
2327
2723
  class ProxyStreamBody
2328
2724
  # @param headers [Hash] the request's Rack env; only its Last-Event-ID
2329
2725
  # is kept (the env carries the token cookie, never held or inspected)
2330
- def initialize(host:, port:, session_id:, query:, headers:, server_running: -> { true })
2726
+ # @param filter [#call, nil] chunk -> String (nil or "": nothing sent);
2727
+ # it sees the Bridge's reads as they come, not whole frames
2728
+ def initialize(host:, port:, session_id:, query:, headers:, server_running: -> { true }, filter: nil)
2331
2729
  @client = BridgeClient.new(session_id: session_id, port: port, host: host)
2332
2730
  @query = query
2333
2731
  @last_event_id = headers["HTTP_LAST_EVENT_ID"]
2334
2732
  @server_running = server_running
2733
+ @filter = filter
2335
2734
  end
2336
2735
 
2337
2736
  def each(&)
@@ -2339,7 +2738,12 @@ module Samagotchi
2339
2738
  # Last-Event-ID header over ?from_seq, and the reconnect URL carries a
2340
2739
  # stale initial cursor — without this the bridge would replay content
2341
2740
  # already delivered (duplicate bubbles).
2342
- @client.stream(query: @query, last_event_id: @last_event_id, running: @server_running, &)
2741
+ return @client.stream(query: @query, last_event_id: @last_event_id, running: @server_running, &) unless @filter
2742
+
2743
+ @client.stream(query: @query, last_event_id: @last_event_id, running: @server_running) do |chunk|
2744
+ out = @filter.call(chunk)
2745
+ yield(out) unless out.nil? || out.empty?
2746
+ end
2343
2747
  end
2344
2748
  end
2345
2749
  end