samagotchi 0.92.0 → 0.93.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -1
  3. data/bin/chi +4 -4
  4. data/docs/architecture.md +4 -4
  5. data/docs/guardrails.md +4 -5
  6. data/docs/internals/engine.md +6 -0
  7. data/docs/sessions.md +1 -1
  8. data/docs/testing.md +2 -1
  9. data/lib/samagotchi/bootstrap/probe.rb +2 -2
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/child_reports.rb +12 -14
  12. data/lib/samagotchi/client_id.rb +18 -0
  13. data/lib/samagotchi/config/restart_watch.rb +112 -0
  14. data/lib/samagotchi/config.rb +18 -59
  15. data/lib/samagotchi/engine.rb +4 -14
  16. data/lib/samagotchi/guardrails/data_readers.rb +34 -30
  17. data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
  18. data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
  19. data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
  20. data/lib/samagotchi/hook_question.rb +21 -0
  21. data/lib/samagotchi/hook_routes.rb +6 -6
  22. data/lib/samagotchi/hooks/registry.rb +5 -5
  23. data/lib/samagotchi/host_registry.rb +1 -1
  24. data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
  25. data/lib/samagotchi/parent_answers_command.rb +6 -6
  26. data/lib/samagotchi/plugin/context.rb +6 -5
  27. data/lib/samagotchi/plugin/http_transport.rb +14 -3
  28. data/lib/samagotchi/plugin/sessions.rb +2 -3
  29. data/lib/samagotchi/question_desk.rb +12 -9
  30. data/lib/samagotchi/reply_wait/poll.rb +267 -0
  31. data/lib/samagotchi/reply_wait.rb +47 -190
  32. data/lib/samagotchi/send_command/parent_gate.rb +83 -0
  33. data/lib/samagotchi/send_command/wait_target.rb +55 -0
  34. data/lib/samagotchi/send_command.rb +31 -100
  35. data/lib/samagotchi/session.rb +10 -9
  36. data/lib/samagotchi/session_inbox.rb +31 -8
  37. data/lib/samagotchi/session_manager.rb +48 -15
  38. data/lib/samagotchi/session_metrics.rb +22 -3
  39. data/lib/samagotchi/sessions_command.rb +4 -4
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  41. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  42. data/lib/samagotchi/testing/bundle.rb +1 -1
  43. data/lib/samagotchi/testing/host.rb +2 -8
  44. data/lib/samagotchi/testing/recorder.rb +6 -9
  45. data/lib/samagotchi/tools/delegate.rb +10 -10
  46. data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
  47. data/lib/samagotchi/tools/delegate_relay.rb +31 -16
  48. data/lib/samagotchi/tools/delegate_wait.rb +4 -4
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +46 -63
  51. data/lib/samagotchi/web/public/app.js +122 -556
  52. data/lib/samagotchi/web/public/index.html +1 -1
  53. data/lib/samagotchi/web/public/send_turn.js +173 -0
  54. data/lib/samagotchi/web/public/session_actions.js +203 -0
  55. data/lib/samagotchi/web/public/start_page.js +236 -0
  56. metadata +10 -1
@@ -27,10 +27,7 @@ module Samagotchi
27
27
  messages: -> { recorder[:messages] },
28
28
  messages_partial: -> { recorder[:messages_partial] },
29
29
  notify: ->(text, level, label, fallback_for: nil) { recorder.notify(text, level, label, fallback_for) },
30
- ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:, effects: nil|
31
- recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook,
32
- effects: effects)
33
- },
30
+ ask_user: ->(question) { recorder.ask_user(question) },
34
31
  cancelled: -> { recorder[:cancelled] },
35
32
  card: ->(**card) { recorder.card(**card) },
36
33
  ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil|
@@ -89,10 +86,7 @@ module Samagotchi
89
86
  def build(recorder)
90
87
  Hooks::Runtime.new(
91
88
  notify: ->(text:, level:, hook:, fallback_for: nil) { recorder.notify(text, level, hook, fallback_for) },
92
- ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:, effects: nil|
93
- recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook,
94
- effects: effects)
95
- },
89
+ ask_user: ->(question) { recorder.ask_user(question) },
96
90
  stop_turn: ->(reason:, hook:) { recorder.stop(:turn, reason, hook) },
97
91
  steer: ->(text:, hook:) { recorder.steer(text, hook) },
98
92
  stop_generation: ->(reason:, hook:) { recorder.stop(:generation, reason, hook) }
@@ -3,6 +3,7 @@
3
3
  require_relative "../plugin/card"
4
4
  require_relative "../plugin/context"
5
5
  require_relative "../guardrails/parent_policy"
6
+ require_relative "../hook_question"
6
7
 
7
8
  module Samagotchi
8
9
  module Testing
@@ -13,11 +14,6 @@ module Samagotchi
13
14
  Steer = Data.define(:text, :hook)
14
15
  # A stop_turn or stop_generation (+kind+ :turn or :generation).
15
16
  Stop = Data.define(:kind, :reason, :hook)
16
- # An ask_user question; effects: its options' tags as the bundle gave
17
- # them (label => effect), nil for none.
18
- Question = Data.define(:question, :options, :header, :allow_freeform, :hook, :effects) do
19
- def initialize(effects: nil, **) = super
20
- end
21
17
  # An ask_model request: the request Context built (SideQuestion), its
22
18
  # limits, cancel controller and the model: it named (nil: the session's;
23
19
  # the kit doesn't check it against any hosts), and +source+, the asking
@@ -107,11 +103,12 @@ module Samagotchi
107
103
  # Effects chi would refuse (a label the options don't have, an
108
104
  # unknown effect: Guardrails::ParentPolicy.option_tags) ask no one, as
109
105
  # in chi: nil, nothing recorded, no answer taken.
110
- def ask_user(question:, options:, header:, allow_freeform:, hook:, effects: nil)
111
- return nil if Guardrails::ParentPolicy.option_tags(options, effects) == false
106
+ # @param question [HookQuestion] recorded as asked (#questions; its
107
+ # effects as the bundle gave them: label => effect, nil for none)
108
+ def ask_user(question)
109
+ return nil if Guardrails::ParentPolicy.option_tags(question.options, question.effects) == false
112
110
 
113
- record(@questions, Question.new(question: question, options: options, header: header,
114
- allow_freeform: allow_freeform, hook: hook, effects: effects))
111
+ record(@questions, question)
115
112
  answer = @mutex.synchronize { @answers.shift }
116
113
  answer.respond_to?(:call) ? answer.call(@questions.last) : answer
117
114
  end
@@ -234,11 +234,11 @@ module Samagotchi
234
234
  DelegateWait.mark_seen(parent.id, child, state_dir: state_dir)
235
235
  delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{ClientId::DELEGATE_PREFIX}#{parent.id[0, 8]}",
236
236
  state_dir: state_dir)
237
- case delivered[:status]
237
+ case delivered.status
238
238
  when :accepted
239
- DelegateWait.mark_turn(parent.id, id, follow_up_turn(id, delivered[:ack], state_dir: state_dir), state_dir: state_dir)
239
+ DelegateWait.mark_turn(parent.id, id, follow_up_turn(id, delivered.enqueued_id, state_dir: state_dir), state_dir: state_dir)
240
240
  id
241
- when :refused then "Error: session #{id[0, 8]} refused the message (#{delivered.dig(:ack, "error")})"
241
+ when :refused then "Error: session #{id[0, 8]} refused the message (#{delivered.error})"
242
242
  when :timeout then "Error: the worker of session #{id[0, 8]} did not answer in time, so the message was not sent"
243
243
  else "Error: the message to session #{id[0, 8]} could not be written"
244
244
  end
@@ -251,24 +251,24 @@ module Samagotchi
251
251
 
252
252
  # The child's turn a delivered follow-up joins, for the wait
253
253
  # (DelegateCursor#turn): the running turn's (its checkpoint mark), the
254
- # follow-up merging into it; else the ack's enqueued_id, the follow-up
255
- # running as a turn of its own. Only from a worker that names
256
- # turn_ids; nil otherwise, or when the running turn has no id (a
257
- # delegate's first turn): the wait takes the next reply.
254
+ # follow-up merging into it; else the delivery's +enqueued_id+, the
255
+ # follow-up running as a turn of its own. Only
256
+ # from a worker that names turn_ids; nil otherwise, or when the
257
+ # running turn has no id (a delegate's first turn): the wait takes the
258
+ # next reply.
258
259
  # A follow-up steered into a turn that ended before it merged runs as
259
260
  # its own turn, yet the wait is on the ended one (LOW, as before).
260
261
  # One the child's idle look missed being merged into a turn starting
261
262
  # then never runs as its own: ReplyWait falls back to that turn's
262
263
  # reply (merged_elsewhere?).
263
264
  # @return [String, nil]
264
- def self.follow_up_turn(id, ack, state_dir:)
265
+ def self.follow_up_turn(id, enqueued_id, state_dir:)
265
266
  return nil unless ReplyWait.turn_ids?(Session.session_dir(id, state_dir: state_dir))
266
267
 
267
268
  child = Session.load(id, state_dir: state_dir)
268
269
  return child.interrupted_turn&.enqueued_id if child.status == Session::STATUS_RUNNING
269
270
 
270
- enqueued_id = ack.is_a?(Hash) ? (ack["enqueued_id"] || ack[:enqueued_id]) : nil
271
- enqueued_id.to_s.empty? ? nil : enqueued_id.to_s
271
+ enqueued_id
272
272
  rescue ArgumentError
273
273
  nil
274
274
  end
@@ -4,6 +4,7 @@ require "json"
4
4
  require "fileutils"
5
5
  require_relative "../atomic_file"
6
6
  require_relative "../session"
7
+ require_relative "../reply_wait"
7
8
 
8
9
  module Samagotchi
9
10
  module Tools
@@ -14,32 +15,54 @@ module Samagotchi
14
15
  # of the child's turn the last follow-up went to (Delegate.follow_up),
15
16
  # whose own reply a wait takes (ReplyWait's turn:); nil once that turn
16
17
  # was reported, or when unknown (a new child, an older worker).
17
- # +report_claim+: a report reserved until the parent's turn is settled.
18
+ # +report_claim+: a report reserved until the parent's turn is settled
19
+ # (ReportClaim).
18
20
  DelegateCursor = Data.define(:reply_file, :last_turn, :question_id, :messages, :turn, :report_claim) do
19
21
  def initialize(reply_file: nil, last_turn: nil, question_id: nil, messages: nil, turn: nil, report_claim: nil) = super
20
22
 
21
23
  def self.empty = new
22
24
 
23
- # @param baseline [Hash, nil] ReplyWait.baseline_of's
25
+ # The cursor with no report reserved: what it is once the reserving
26
+ # turn is settled, and how two cursors compare apart from a claim.
27
+ def without_claim = with(report_claim: nil)
28
+
29
+ # @param baseline [ReplyWait::Baseline, nil] ReplyWait.baseline_of's
24
30
  def with_baseline(baseline)
25
- b = baseline || {}
26
- with(messages: b[:messages], question_id: b[:question_id], last_turn: b[:last_turn])
31
+ with(messages: baseline&.messages, question_id: baseline&.question_id, last_turn: baseline&.last_turn)
27
32
  end
28
33
 
29
- # @return [Hash, nil] ReplyWait's baseline:, nil when none was taken
34
+ # @return [ReplyWait::Baseline, nil] ReplyWait's baseline:, nil when
35
+ # none was taken
30
36
  def baseline
31
37
  return nil if messages.nil?
32
38
 
33
- { messages: messages, question_id: question_id, last_turn: last_turn }
39
+ ReplyWait::Baseline.new(messages: messages, question_id: question_id, last_turn: last_turn)
34
40
  end
35
41
 
36
- def to_json_hash = to_h.transform_keys(&:to_s).compact
42
+ def to_json_hash = to_h.merge(report_claim: report_claim&.to_json_hash).transform_keys(&:to_s).compact
37
43
 
38
44
  def self.from_json_hash(data)
39
45
  return empty unless data.is_a?(Hash)
40
46
 
41
47
  new(reply_file: data["reply_file"], last_turn: data["last_turn"], question_id: data["question_id"],
42
- messages: data["messages"], turn: data["turn"], report_claim: data["report_claim"])
48
+ messages: data["messages"], turn: data["turn"], report_claim: ReportClaim.from_json_hash(data["report_claim"]))
49
+ end
50
+ end
51
+
52
+ # A delegate report reserved in the parent's cursor (ChildReports) until
53
+ # the parent's turn that took it is kept or rolled back: +id+ the
54
+ # claim's id (the taking turn's Report#claim_id), +file+ the reply file
55
+ # reported (nil for a question), +cursor+ the DelegateCursor the report
56
+ # moves the parent to once kept (its own claim empty). In delegates.json
57
+ # it is {"id", "file", "cursor"}, the file's key kept even when nil.
58
+ ReportClaim = Data.define(:id, :file, :cursor) do
59
+ def to_json_hash = { "id" => id, "file" => file, "cursor" => cursor.to_json_hash }
60
+
61
+ # @return [ReportClaim, nil] nil for no claim (or not a Hash)
62
+ def self.from_json_hash(data)
63
+ return nil unless data.is_a?(Hash)
64
+
65
+ new(id: data["id"], file: data["file"], cursor: DelegateCursor.from_json_hash(data["cursor"]))
43
66
  end
44
67
  end
45
68
 
@@ -77,6 +100,7 @@ module Samagotchi
77
100
  end
78
101
 
79
102
  # Reserve a report only if the cursor read to build it is still current.
103
+ # @param claim [ReportClaim]
80
104
  # @return [DelegateCursor, nil] the reserved cursor, or nil if another path moved it
81
105
  def claim_report(parent_id, child_id, expected:, claim:, state_dir:)
82
106
  locked(parent_id, state_dir: state_dir) do
@@ -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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.92.0"
4
+ VERSION = "0.93.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)