samagotchi 0.92.0 → 0.94.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -1
  3. data/README.md +1 -1
  4. data/bin/chi +4 -4
  5. data/docs/architecture.md +4 -4
  6. data/docs/cli.md +1 -1
  7. data/docs/guardrails.md +4 -5
  8. data/docs/hooks.md +1 -1
  9. data/docs/internals/bundles.md +1 -1
  10. data/docs/internals/engine.md +6 -0
  11. data/docs/memory.md +26 -1
  12. data/docs/plugins.md +37 -0
  13. data/docs/releasing.md +2 -2
  14. data/docs/sessions.md +1 -1
  15. data/docs/testing.md +11 -2
  16. data/lib/samagotchi/bootstrap/probe.rb +2 -2
  17. data/lib/samagotchi/bootstrap_command.rb +1 -0
  18. data/lib/samagotchi/bundle_command.rb +1 -0
  19. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  20. data/lib/samagotchi/bundles/model-notes/manifest.yml +11 -0
  21. data/lib/samagotchi/bundles/model-notes/model_notes_deepseek.md +7 -0
  22. data/lib/samagotchi/bundles/model-notes/notes/README.md +37 -0
  23. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  24. data/lib/samagotchi/child_reports.rb +12 -14
  25. data/lib/samagotchi/client_id.rb +18 -0
  26. data/lib/samagotchi/config/restart_watch.rb +112 -0
  27. data/lib/samagotchi/config.rb +18 -59
  28. data/lib/samagotchi/engine.rb +4 -14
  29. data/lib/samagotchi/guardrails/data_readers.rb +34 -30
  30. data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
  31. data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
  32. data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
  33. data/lib/samagotchi/hook_question.rb +21 -0
  34. data/lib/samagotchi/hook_routes.rb +6 -6
  35. data/lib/samagotchi/hooks/registry.rb +5 -5
  36. data/lib/samagotchi/host_registry.rb +1 -1
  37. data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
  38. data/lib/samagotchi/memory_bundle/profile.rb +22 -4
  39. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -1
  40. data/lib/samagotchi/parent_answers_command.rb +6 -6
  41. data/lib/samagotchi/plugin/context.rb +6 -5
  42. data/lib/samagotchi/plugin/http_transport.rb +14 -3
  43. data/lib/samagotchi/plugin/sessions.rb +2 -3
  44. data/lib/samagotchi/question_desk.rb +12 -9
  45. data/lib/samagotchi/reply_wait/poll.rb +267 -0
  46. data/lib/samagotchi/reply_wait.rb +47 -190
  47. data/lib/samagotchi/send_command/parent_gate.rb +83 -0
  48. data/lib/samagotchi/send_command/wait_target.rb +55 -0
  49. data/lib/samagotchi/send_command.rb +31 -100
  50. data/lib/samagotchi/session.rb +10 -9
  51. data/lib/samagotchi/session_inbox.rb +31 -8
  52. data/lib/samagotchi/session_manager.rb +48 -15
  53. data/lib/samagotchi/session_metrics.rb +22 -3
  54. data/lib/samagotchi/sessions_command.rb +4 -4
  55. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  56. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  57. data/lib/samagotchi/testing/bundle.rb +1 -1
  58. data/lib/samagotchi/testing/host.rb +2 -8
  59. data/lib/samagotchi/testing/recorder.rb +6 -9
  60. data/lib/samagotchi/testing/sandbox.rb +26 -0
  61. data/lib/samagotchi/tools/delegate.rb +10 -10
  62. data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
  63. data/lib/samagotchi/tools/delegate_relay.rb +31 -16
  64. data/lib/samagotchi/tools/delegate_wait.rb +4 -4
  65. data/lib/samagotchi/update_command.rb +1 -1
  66. data/lib/samagotchi/version.rb +1 -1
  67. data/lib/samagotchi/web/app.rb +46 -63
  68. data/lib/samagotchi/web/public/app.js +137 -638
  69. data/lib/samagotchi/web/public/dock_reserve.js +103 -0
  70. data/lib/samagotchi/web/public/index.html +4 -4
  71. data/lib/samagotchi/web/public/send_turn.js +173 -0
  72. data/lib/samagotchi/web/public/session_actions.js +203 -0
  73. data/lib/samagotchi/web/public/start_page.js +236 -0
  74. data/lib/samagotchi/web/public/toast_place.js +1 -1
  75. metadata +14 -1
@@ -85,7 +85,7 @@ module Samagotchi
85
85
 
86
86
  fields = Relay.card(child, question, relay_id: relay_id, more: more)
87
87
  answer = relay.open_question(fields, watch: watch(child_id, qid, state_dir))
88
- settle(answer, fields, client: client, desk: desk, relay_id: relay_id, qid: qid)
88
+ settle(answer, fields, label: label(question), client: client, desk: desk, relay_id: relay_id, qid: qid)
89
89
  ensure
90
90
  # A relay that failed before it settled closes, so the desk (which
91
91
  # prunes only settled ones) keeps none open; a settled one stays.
@@ -108,12 +108,11 @@ module Samagotchi
108
108
  end
109
109
 
110
110
  # Record the parent's answer, tell the child, and say what came of it.
111
- def settle(answer, fields, client:, desk:, relay_id:, qid:)
112
- what = fields[:kind] == HOOK_KIND ? hook_what(fields) : what_text(fields[:approval])
113
- hook = fields[:kind] == HOOK_KIND
111
+ # @param label [String] the outcome line's start (#label)
112
+ def settle(answer, fields, label:, client:, desk:, relay_id:, qid:)
114
113
  if answer.is_a?(Hash) && answer[:error] == "cancelled"
115
114
  desk.close(relay_id)
116
- return closed(answer[:reason].to_s, what, client: client, relay_id: relay_id, qid: qid, hook: hook)
115
+ return closed(answer[:reason].to_s, label, client: client, relay_id: relay_id, qid: qid)
117
116
  end
118
117
 
119
118
  dismissed = !answer.is_a?(QuestionAnswer)
@@ -121,18 +120,18 @@ module Samagotchi
121
120
  desk.record(relay_id, selected_indices: dismissed ? [] : answer.selected_indices,
122
121
  freeform: dismissed ? nil : answer.freeform, dismissed: dismissed, by: by)
123
122
  response = post(client, "answered", relay_id, qid)
124
- Outcome.new(line: line(what, delivered(response, answer, fields, dismissed), hook: hook), stopped: false)
123
+ Outcome.new(line: line(label, delivered(response, answer, fields, dismissed)), stopped: false)
125
124
  end
126
125
 
127
- def closed(reason, what, client:, relay_id:, qid:, hook: false)
126
+ def closed(reason, label, client:, relay_id:, qid:)
128
127
  case reason
129
- when "answered_on_child" then Outcome.new(line: line(what, "answered on the child", hook: hook), stopped: false)
128
+ when "answered_on_child" then Outcome.new(line: line(label, "answered on the child"), stopped: false)
130
129
  when "child_gone"
131
- Outcome.new(line: line(what, "not delivered (the child's worker is gone)", hook: hook), stopped: false)
130
+ Outcome.new(line: line(label, "not delivered (the child's worker is gone)"), stopped: false)
132
131
  else
133
132
  # A Stop: the child's question stays open there.
134
133
  post(client, "closed", relay_id, qid, reason: "stopped")
135
- Outcome.new(line: line(what, "still open (your turn was stopped)", hook: hook), stopped: true)
134
+ Outcome.new(line: line(label, "still open (your turn was stopped)"), stopped: true)
136
135
  end
137
136
  end
138
137
 
@@ -172,14 +171,30 @@ module Samagotchi
172
171
  why.empty? ? "denied" : "denied (#{why.inspect})"
173
172
  end
174
173
 
175
- def line(what, outcome, hook: false)
176
- "#{hook ? "hook question" : "approval"} relayed to your user: #{what} → #{outcome}"
174
+ # One relayed question's outcome line: #label → +outcome+.
175
+ def line(label, outcome) = "#{label} → #{outcome}"
176
+
177
+ # What an outcome line starts with: "hook question relayed to your
178
+ # user: Cost cap", "approval relayed to your user: execute: git push".
179
+ # @param question [PendingQuestion] the child's
180
+ def label(question)
181
+ "#{question.kind.to_s == HOOK_KIND ? "hook question" : "approval"} relayed to your user: #{what(question)}"
182
+ end
183
+
184
+ # What a relayed question is about, as its card has it (Relay.card): a
185
+ # hook's header as the asking hook gave it (kept down a chain: a relay
186
+ # of a relay), else its hook; an approval's call (#what_text).
187
+ # @param question [PendingQuestion] the child's
188
+ def what(question)
189
+ return what_text(symbolized(question.approval)) unless question.kind.to_s == HOOK_KIND
190
+
191
+ inner = symbolized(question.relay, nil)
192
+ header = (inner ? inner[:header] : question.header).to_s
193
+ header.empty? ? question.hook.to_s : header
177
194
  end
178
195
 
179
- # A hook card's header as the child asked it (Cost cap), or its hook.
180
- def hook_what(fields)
181
- header = fields.dig(:relay, :header).to_s
182
- header.empty? ? fields[:hook].to_s : header
196
+ def symbolized(value, none = {})
197
+ value.is_a?(Hash) ? Relay.deep_symbolize(value) : none
183
198
  end
184
199
 
185
200
  # execute: git push origin main
@@ -69,7 +69,7 @@ module Samagotchi
69
69
  cursor = DelegateCursors.get(peers.session_id, child_id, state_dir: sd)
70
70
  report_claim = cursor.report_claim
71
71
  if report_claim
72
- cursor = DelegateCursor.from_json_hash(report_claim["cursor"]).with(report_claim: report_claim)
72
+ cursor = report_claim.cursor.with(report_claim: report_claim)
73
73
  end
74
74
 
75
75
  baseline = cursor.baseline
@@ -88,10 +88,10 @@ module Samagotchi
88
88
  outcomes.concat(relayed_outcomes.delete(key) || [])
89
89
  movement = advance(peers.session_id, child_id, wait, state_dir: sd)
90
90
  if movement&.already_reported
91
- file = movement.cursor.report_claim&.fetch("file", nil) || wait.file
91
+ file = movement.cursor.report_claim&.file || wait.file
92
92
  return already_reported_result(child_id, file, state_dir: sd)
93
93
  end
94
- return already_reported_result(child_id, report_claim["file"], state_dir: sd) if report_claim && wait.status == :timeout
94
+ return already_reported_result(child_id, report_claim.file, state_dir: sd) if report_claim && wait.status == :timeout
95
95
 
96
96
  text = with_outcomes(finish(wait, child_id, timeout: timeout, reports: reports), outcomes)
97
97
  return RefText.wrap(text, reply_path(child_id, wait, state_dir: sd))
@@ -179,7 +179,7 @@ module Samagotchi
179
179
 
180
180
  already_reported = false
181
181
  cursor = DelegateCursors.update(parent_id, child_id, state_dir: state_dir) do |current|
182
- if (current.report_claim && current.report_claim["file"] == wait.file) ||
182
+ if (current.report_claim && current.report_claim.file == wait.file) ||
183
183
  (wait.status == :done && current.reply_file == wait.file)
184
184
  already_reported = true
185
185
  next current
@@ -203,7 +203,7 @@ module Samagotchi
203
203
  row = Row.new(component: update.name, from: update.from, to: update.to)
204
204
  case update.status
205
205
  when :would_update, :updated
206
- notes = [adds(update.adds), kept(update.kept, update.name), replaced(update.replaced)].compact
206
+ notes = [adds(update.adds), kept(update.kept, update.name), replaced(update.replaced), *update.kept_own].compact
207
207
  done(row, :update, notes.empty? ? nil : notes.join("; "))
208
208
  when :up_to_date then up_to_date(row)
209
209
  when :skipped then skipped(row, update.note)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.92.0"
4
+ VERSION = "0.94.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -152,7 +152,8 @@ module Samagotchi
152
152
  private
153
153
 
154
154
  # The routes, first match wins: a handler takes the request and the
155
- # path's captures (the session id, an image's name).
155
+ # path's captures (the session id, an image's name). A :session route
156
+ # runs in #session_route: an ArgumentError is its unknown session.
156
157
  ROUTES = [
157
158
  ["GET", %r{\A/(?:index\.html)?\z}, :serve_index],
158
159
  ["GET", %r{\A/api/sessions\z}, :handle_list],
@@ -163,26 +164,26 @@ module Samagotchi
163
164
  ["GET", %r{\A/api/events\z}, :handle_events],
164
165
  ["POST", %r{\A/api/render\z}, :handle_render],
165
166
  ["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream],
166
- ["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output],
167
- ["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel],
168
- ["POST", %r{\A/api/sessions/([^/]+)/tasks/([^/]+)/stop\z}, :handle_task_stop],
169
- ["POST", %r{\A/api/sessions/([^/]+)/turn/([^/]+)/dequeue\z}, :handle_dequeue_turn],
170
- ["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop],
171
- ["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart],
172
- ["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive],
173
- ["POST", %r{\A/api/sessions/([^/]+)/unarchive\z}, :handle_unarchive],
174
- ["POST", %r{\A/api/sessions/([^/]+)/turn\z}, :handle_turn],
167
+ ["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output, :session],
168
+ ["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel, :session],
169
+ ["POST", %r{\A/api/sessions/([^/]+)/tasks/([^/]+)/stop\z}, :handle_task_stop, :session],
170
+ ["POST", %r{\A/api/sessions/([^/]+)/turn/([^/]+)/dequeue\z}, :handle_dequeue_turn, :session],
171
+ ["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop, :session],
172
+ ["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart, :session],
173
+ ["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive, :session],
174
+ ["POST", %r{\A/api/sessions/([^/]+)/unarchive\z}, :handle_unarchive, :session],
175
+ ["POST", %r{\A/api/sessions/([^/]+)/turn\z}, :handle_turn, :session],
175
176
  ["POST", %r{\A/api/sessions/([^/]+)/answer\z}, :handle_question_answer],
176
177
  ["POST", %r{\A/api/sessions/([^/]+)/question/dismiss\z}, :handle_question_dismiss],
177
- ["POST", %r{\A/api/sessions/([^/]+)/command\z}, :handle_command],
178
+ ["POST", %r{\A/api/sessions/([^/]+)/command\z}, :handle_command, :session],
178
179
  ["POST", %r{\A/api/sessions/([^/]+)/images\z}, :handle_image_upload],
179
180
  ["GET", %r{\A/api/sessions/([^/]+)/images/([^/]+)\z}, :handle_image],
180
- ["GET", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_list],
181
- ["GET", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_show],
182
- ["POST", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_add],
183
- ["DELETE", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_delete],
184
- ["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show],
185
- ["DELETE", %r{\A/api/sessions/([^/]+)\z}, :handle_delete]
181
+ ["GET", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_list, :session],
182
+ ["GET", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_show, :session],
183
+ ["POST", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_add, :session],
184
+ ["DELETE", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_delete, :session],
185
+ ["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show, :session],
186
+ ["DELETE", %r{\A/api/sessions/([^/]+)\z}, :handle_delete, :session]
186
187
  ].freeze
187
188
 
188
189
  def route(env)
@@ -195,7 +196,7 @@ module Samagotchi
195
196
  return denied if denied
196
197
  end
197
198
 
198
- ROUTES.each do |verb, pattern, handler|
199
+ ROUTES.each do |verb, pattern, handler, kind|
199
200
  next unless req.request_method == verb && (m = pattern.match(req.path_info))
200
201
  # Every /api/sessions/:id route: an id that isn't one (Session.valid_id?,
201
202
  # "..", a %2F) is an unknown session before any handler makes a path.
@@ -203,6 +204,8 @@ module Samagotchi
203
204
  return error_response(404, "not_found", "Session not found: #{m.captures.first.to_s[0, 80]}")
204
205
  end
205
206
 
207
+ return session_route { send(handler, req, *m.captures) } if kind == :session
208
+
206
209
  return send(handler, req, *m.captures)
207
210
  end
208
211
  # Anything else is a file under public_dir (/assets/*, /public/* or
@@ -213,6 +216,16 @@ module Samagotchi
213
216
  error_response(500, "internal_error", e.message)
214
217
  end
215
218
 
219
+ # A :session route's handler: the ArgumentError its session lookup
220
+ # raises (Session.load, the manager's find) is a 404 not_found with
221
+ # its message. Only these routes: elsewhere an ArgumentError is a bug,
222
+ # #route's 500. A handler's own rescues run first.
223
+ def session_route
224
+ yield
225
+ rescue ArgumentError => e
226
+ error_response(404, "not_found", e.message)
227
+ end
228
+
216
229
  # API requests at debug level (not the page and its assets): method,
217
230
  # path, status, time. Never a body or the query; only a tail=1 read
218
231
  # (the page's end-of-turn re-read) and a timing=1 one (its fallback
@@ -737,7 +750,7 @@ module Samagotchi
737
750
  rescue SessionManager::ArchiveRefused => e
738
751
  [nil, error_response(409, e.reason == :scratch ? "scratch" : "busy", e.message)]
739
752
  rescue SessionManager::OwnedByTUI
740
- [nil, error_response(409, "owned_by_tui", "session #{given} is open in a chi REPL; close it there first")]
753
+ [nil, owned_by_repl(given)]
741
754
  rescue ModelProfile::MissingModel => e
742
755
  [nil, error_response(400, "invalid_model", e.message)]
743
756
  rescue Session::AmbiguousId => e
@@ -839,8 +852,6 @@ module Samagotchi
839
852
  last_event_id: turn_snapshot && turn_snapshot["event_id"],
840
853
  timing: timing
841
854
  })
842
- rescue ArgumentError => e
843
- error_response(404, "not_found", e.message)
844
855
  end
845
856
 
846
857
  # A list in the worker's turn +snapshot+ (queued prompts, commands,
@@ -948,8 +959,6 @@ module Samagotchi
948
959
  **viewer_capabilities(req),
949
960
  timing: timing
950
961
  })
951
- rescue ArgumentError => e
952
- error_response(404, "not_found", e.message)
953
962
  end
954
963
 
955
964
  # The folder the live tail's answer's file refs must stay under: the
@@ -1132,8 +1141,6 @@ module Samagotchi
1132
1141
  end
1133
1142
  rescue SessionManager::OwnedByTUI => e
1134
1143
  error_response(409, "owned_by_tui", e.message)
1135
- rescue ArgumentError => e
1136
- error_response(404, "not_found", e.message)
1137
1144
  end
1138
1145
 
1139
1146
  # Hand a request to the session's live Bridge (+client+, nil without
@@ -1169,6 +1176,12 @@ module Samagotchi
1169
1176
  not_live(id)
1170
1177
  end
1171
1178
 
1179
+ # 409 owned_by_tui in the words a refused archive, delete or continue
1180
+ # shows (stop, restart, a turn and a command show OwnedByTUI's own).
1181
+ def owned_by_repl(id)
1182
+ error_response(409, "owned_by_tui", SessionManager::OwnedByTUI.new(id).message)
1183
+ end
1184
+
1172
1185
  def not_live(id)
1173
1186
  error_response(503, "not_live", "no live bridge for session #{id}")
1174
1187
  end
@@ -1204,8 +1217,6 @@ module Samagotchi
1204
1217
  # Strip wire tokens server-side so the client never sees <|tool_call> etc.
1205
1218
  rendered = raw.map { |chunk| OutputFormatter.strip(chunk) }.reject(&:empty?)
1206
1219
  json_response(200, { session_id: session.id, output: rendered })
1207
- rescue ArgumentError => e
1208
- error_response(404, "not_found", e.message)
1209
1220
  end
1210
1221
 
1211
1222
  def handle_turn(req, id)
@@ -1231,18 +1242,16 @@ module Samagotchi
1231
1242
  result = SessionManager.deliver_turn(id, prompt: prompt.to_s, client_id: client_id, images: images,
1232
1243
  delivery: delivery, state_dir: @state_dir, manager: @manager,
1233
1244
  bridge: -> { live_bridge_client(id) })
1234
- case result[:status]
1245
+ case result.status
1235
1246
  when :accepted
1236
1247
  record_history(body, prompt)
1237
- json_response(202, result[:ack])
1238
- when :refused then json_response(result[:code], result[:ack])
1239
- when :timeout then error_response(504, "worker_timeout", result[:ack]["detail"])
1248
+ json_response(202, result.ack)
1249
+ when :refused then json_response(result.code, result.ack)
1250
+ when :timeout then error_response(504, "worker_timeout", result.detail)
1240
1251
  else error_response(500, "enqueue_failed", "could not write turn input")
1241
1252
  end
1242
1253
  rescue SessionManager::OwnedByTUI => e
1243
1254
  error_response(409, "owned_by_tui", e.message)
1244
- rescue ArgumentError => e
1245
- error_response(404, "not_found", e.message)
1246
1255
  end
1247
1256
 
1248
1257
  # A turn's images as [{file:, name:}] (refs to uploads already in the
@@ -1323,8 +1332,7 @@ module Samagotchi
1323
1332
  def handle_cancel(req, id)
1324
1333
  @session_class.load(id, state_dir: default_state_dir)
1325
1334
 
1326
- body = request_body(req)
1327
- parsed = body.nil? || body.strip.empty? ? nil : parse_json(body)
1335
+ parsed = parse_json(request_body(req))
1328
1336
  reason = (parsed["reason"] if parsed.is_a?(Hash)).to_s.strip
1329
1337
  reason = "user" if reason.empty?
1330
1338
 
@@ -1337,8 +1345,6 @@ module Samagotchi
1337
1345
  when 409 then json_response(409, { error: "not_running", detail: "no active turn to cancel", session_id: id })
1338
1346
  end
1339
1347
  end
1340
- rescue ArgumentError => e
1341
- error_response(404, "not_found", e.message)
1342
1348
  end
1343
1349
 
1344
1350
  # A task id as TaskRuntime.generate_task_id makes it.
@@ -1361,8 +1367,6 @@ module Samagotchi
1361
1367
  when 409 then json_response(409, { error: "not_running", status: body["status"], task_id: task_id, session_id: id })
1362
1368
  end
1363
1369
  end
1364
- rescue ArgumentError => e
1365
- error_response(404, "not_found", e.message)
1366
1370
  end
1367
1371
 
1368
1372
  # POST /api/sessions/:id/turn/:enqueued_id/dequeue {client_id?}: the ×
@@ -1374,8 +1378,7 @@ module Samagotchi
1374
1378
  # 404 unknown_enqueued_id pass through; the rest as every relay.
1375
1379
  def handle_dequeue_turn(req, id, enqueued_id)
1376
1380
  @session_class.load(id, state_dir: default_state_dir)
1377
- body = request_body(req)
1378
- parsed = body.nil? || body.strip.empty? ? nil : parse_json(body)
1381
+ parsed = parse_json(request_body(req))
1379
1382
  client_id = (parsed["client_id"] if parsed.is_a?(Hash))
1380
1383
 
1381
1384
  request = ->(client) { client.dequeue(enqueued_id: enqueued_id, client_id: client_id) }
@@ -1388,8 +1391,6 @@ module Samagotchi
1388
1391
  json_response(404, { error: "unknown_enqueued_id", enqueued_id: enqueued_id, session_id: id }) if body["error"] == "unknown_enqueued_id"
1389
1392
  end
1390
1393
  end
1391
- rescue ArgumentError => e
1392
- error_response(404, "not_found", e.message)
1393
1394
  end
1394
1395
 
1395
1396
  def handle_stop(_req, id)
@@ -1400,8 +1401,6 @@ module Samagotchi
1400
1401
  json_response(200, { status: "stopped", session_id: id })
1401
1402
  rescue SessionManager::OwnedByTUI => e
1402
1403
  error_response(409, "owned_by_tui", e.message)
1403
- rescue ArgumentError => e
1404
- error_response(404, "not_found", e.message)
1405
1404
  end
1406
1405
 
1407
1406
  # POST /api/sessions/:id/restart: hand the session to a new worker on
@@ -1420,8 +1419,6 @@ module Samagotchi
1420
1419
  json_response(409, { error: error, reason: e.reason.to_s, detail: e.message, session_id: id })
1421
1420
  rescue SessionManager::OwnedByTUI => e
1422
1421
  error_response(409, "owned_by_tui", e.message)
1423
- rescue ArgumentError => e
1424
- error_response(404, "not_found", e.message)
1425
1422
  end
1426
1423
 
1427
1424
  # POST /api/sessions/:id/archive: hide it (and its delegates) from the
@@ -1434,11 +1431,9 @@ module Samagotchi
1434
1431
  json_response(200, { status: "archived", session_id: result[:id], archived: result[:archived],
1435
1432
  stopped: result[:stopped], discarded: result[:discarded] })
1436
1433
  rescue SessionManager::OwnedByTUI
1437
- error_response(409, "owned_by_tui", "session #{id} is open in a chi REPL; close it there first")
1434
+ owned_by_repl(id)
1438
1435
  rescue SessionManager::ArchiveRefused => e
1439
1436
  error_response(409, e.reason == :scratch ? "scratch" : "busy", e.message)
1440
- rescue ArgumentError => e
1441
- error_response(404, "not_found", e.message)
1442
1437
  end
1443
1438
 
1444
1439
  # POST /api/sessions/:id/unarchive: back to the lists, delegates too.
@@ -1446,8 +1441,6 @@ module Samagotchi
1446
1441
  result = @manager.unarchive_session(id, state_dir: @state_dir)
1447
1442
  result[:unarchived].each { |sid| @hub&.touch(sid) }
1448
1443
  json_response(200, { status: "unarchived", session_id: result[:id], unarchived: result[:unarchived] })
1449
- rescue ArgumentError => e
1450
- error_response(404, "not_found", e.message)
1451
1444
  end
1452
1445
 
1453
1446
  # DELETE /api/sessions/:id: the session for good. A live worker is
@@ -1458,11 +1451,9 @@ module Samagotchi
1458
1451
  @hub&.touch(result[:id])
1459
1452
  json_response(200, { status: "deleted", session_id: result[:id], stopped: result[:stopped] })
1460
1453
  rescue SessionManager::OwnedByTUI
1461
- error_response(409, "owned_by_tui", "session #{id} is open in a chi REPL; close it there first")
1454
+ owned_by_repl(id)
1462
1455
  rescue SessionManager::DeleteRefused => e
1463
1456
  error_response(409, e.reason.to_s, "#{e.message}; try again in a moment")
1464
- rescue ArgumentError => e
1465
- error_response(404, "not_found", e.message)
1466
1457
  end
1467
1458
 
1468
1459
  # ── Attached context (ContextSources): the session bar's chips ───────
@@ -1478,8 +1469,6 @@ module Samagotchi
1478
1469
  subs = own.subscriptions
1479
1470
  rows = attached_context(session).map { |attached| context_row(attached, own, subs) }
1480
1471
  json_response(200, { session_id: id, sources: rows, can_add_url: ContextProviders.any? })
1481
- rescue ArgumentError => e
1482
- error_response(404, "not_found", e.message)
1483
1472
  end
1484
1473
 
1485
1474
  # GET /api/sessions/:id/context/:name: one source with its text.
@@ -1488,8 +1477,6 @@ module Samagotchi
1488
1477
  attached = find_context(session, name) or return error_response(404, "not_found", "no source #{name[0, 40]}")
1489
1478
  own = ContextSources.session_location(id, state_dir: default_state_dir)
1490
1479
  json_response(200, context_row(attached, own, own.subscriptions).merge(text: attached.snapshot.text))
1491
- rescue ArgumentError => e
1492
- error_response(404, "not_found", e.message)
1493
1480
  end
1494
1481
 
1495
1482
  # POST /api/sessions/:id/context {url, why}: the source an installed
@@ -1512,8 +1499,6 @@ module Samagotchi
1512
1499
  error_response(422, "no_provider", e.message)
1513
1500
  rescue ContextSources::Invalid => e
1514
1501
  error_response(409, "exists", e.message)
1515
- rescue ArgumentError => e
1516
- error_response(404, "not_found", e.message)
1517
1502
  end
1518
1503
 
1519
1504
  def web_source(resolved, why, scope)
@@ -1535,8 +1520,6 @@ module Samagotchi
1535
1520
  ContextSources.session_location(id, state_dir: default_state_dir).mute(attached.name)
1536
1521
  json_response(200, { status: "muted", name: attached.name })
1537
1522
  end
1538
- rescue ArgumentError => e
1539
- error_response(404, "not_found", e.message)
1540
1523
  end
1541
1524
 
1542
1525
  def attached_context(session)