samagotchi 0.17.0 → 0.18.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -1
  3. data/docs/architecture.md +25 -2
  4. data/docs/cli.md +34 -8
  5. data/docs/configuration.md +4 -2
  6. data/docs/guardrails.md +51 -8
  7. data/docs/hooks.md +4 -1
  8. data/docs/memory.md +5 -3
  9. data/docs/plugins.md +3 -2
  10. data/docs/sessions.md +6 -5
  11. data/docs/sub-agent.md +42 -4
  12. data/lib/samagotchi/answer_command.rb +23 -2
  13. data/lib/samagotchi/answer_tail.rb +52 -0
  14. data/lib/samagotchi/bridge.rb +49 -8
  15. data/lib/samagotchi/bridge_client.rb +18 -3
  16. data/lib/samagotchi/bundle_command.rb +108 -2
  17. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  18. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  19. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  20. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  21. data/lib/samagotchi/commands/registry.rb +28 -0
  22. data/lib/samagotchi/config.rb +7 -1
  23. data/lib/samagotchi/continue_offer.rb +177 -0
  24. data/lib/samagotchi/engine.rb +42 -14
  25. data/lib/samagotchi/guardrails/outside.rb +46 -0
  26. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  27. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  28. data/lib/samagotchi/guardrails/rules.rb +17 -5
  29. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  30. data/lib/samagotchi/guardrails/targets.rb +30 -13
  31. data/lib/samagotchi/guardrails.rb +2 -0
  32. data/lib/samagotchi/iteration_limit.rb +38 -0
  33. data/lib/samagotchi/kernel_loop.rb +2 -1
  34. data/lib/samagotchi/llm/backend.rb +2 -1
  35. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  36. data/lib/samagotchi/llm/native_backend.rb +2 -1
  37. data/lib/samagotchi/memory_bundle/trash.rb +81 -2
  38. data/lib/samagotchi/parent_report.rb +39 -9
  39. data/lib/samagotchi/question_desk.rb +205 -46
  40. data/lib/samagotchi/reply_wait.rb +10 -4
  41. data/lib/samagotchi/send_command.rb +2 -1
  42. data/lib/samagotchi/session.rb +3 -2
  43. data/lib/samagotchi/session_commands.rb +3 -2
  44. data/lib/samagotchi/session_manager.rb +4 -1
  45. data/lib/samagotchi/sessions_command.rb +3 -2
  46. data/lib/samagotchi/system_prompt.rb +1 -1
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -5
  48. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  49. data/lib/samagotchi/terminal_ui.rb +11 -2
  50. data/lib/samagotchi/tool_declarations.rb +2 -2
  51. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  52. data/lib/samagotchi/tools/edit.rb +6 -1
  53. data/lib/samagotchi/tools/execute.rb +5 -4
  54. data/lib/samagotchi/tools/read.rb +2 -1
  55. data/lib/samagotchi/turn_flow.rb +8 -7
  56. data/lib/samagotchi/turn_note.rb +7 -0
  57. data/lib/samagotchi/version.rb +1 -1
  58. data/lib/samagotchi/web/app.rb +187 -42
  59. data/lib/samagotchi/web/public/app.js +122 -25
  60. data/lib/samagotchi/web/public/data.js +12 -13
  61. data/lib/samagotchi/web/public/index.html +6 -1
  62. data/lib/samagotchi/web/public/notify.js +5 -2
  63. data/lib/samagotchi/web/public/question_card.js +28 -1
  64. data/lib/samagotchi/web/public/question_cards.js +74 -3
  65. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  66. data/lib/samagotchi/web/public/timing.js +41 -0
  67. data/lib/samagotchi/web/public/turn_events.js +61 -0
  68. data/lib/samagotchi/web/public/update_notice.js +15 -0
  69. data/lib/samagotchi/worker.rb +30 -58
  70. data/lib/samagotchi/worker_sidecar.rb +6 -3
  71. metadata +8 -1
@@ -31,8 +31,10 @@ module Samagotchi
31
31
  module_function
32
32
 
33
33
  # @param result [ReplyWait::Result]
34
- # @return [String] answered, question, failed, canceled, no_answer,
35
- # error, worker_gone, stopped or running
34
+ # @return [String] answered, question, failed, canceled, limit (the
35
+ # turn ran out of iterations, nobody asked), not_continued (a Stop
36
+ # answered the step-limit question), no_answer, error, worker_gone,
37
+ # stopped or running
36
38
  def status(result)
37
39
  case result.status
38
40
  when :done then "answered"
@@ -41,6 +43,8 @@ module Samagotchi
41
43
  case result.outcome
42
44
  when "failed" then "failed"
43
45
  when "canceled" then "canceled"
46
+ when "exhausted" then "limit"
47
+ when "not_continued" then "not_continued"
44
48
  else "no_answer"
45
49
  end
46
50
  when :error then "error"
@@ -52,11 +56,12 @@ module Samagotchi
52
56
  end
53
57
  end
54
58
 
55
- # @return [Integer] 0 answered, 3 a question waits, 4 still running,
56
- # 1 anything else
59
+ # @return [Integer] 0 answered (or not continued: the Stop asked for
60
+ # was carried out), 3 a question waits, 4 still running, 1 anything
61
+ # else
57
62
  def exit_status(result)
58
63
  case status(result)
59
- when "answered" then CLI::Exit::OK
64
+ when "answered", "not_continued" then CLI::Exit::OK
60
65
  when "question" then CLI::Exit::QUESTION
61
66
  when "running" then CLI::Exit::RUNNING
62
67
  else CLI::Exit::FAILED
@@ -116,7 +121,8 @@ module Samagotchi
116
121
 
117
122
  # A pending question (Session#pending_question) as a parent sees it.
118
123
  # kind is "question" for the model's own (the desk stores none),
119
- # "hook" or "approval".
124
+ # "hook", "approval" or "continue" (the step-limit question, with the
125
+ # turn's limit).
120
126
  # @return [Hash]
121
127
  def question(pending)
122
128
  pending ||= {}
@@ -129,15 +135,18 @@ module Samagotchi
129
135
  options: Array(pending[:options]).map(&:to_s),
130
136
  multi_select: !!pending[:multi_select],
131
137
  allow_freeform: !!pending[:allow_freeform],
138
+ limit: pending[:limit],
132
139
  approval: approval&.slice(*APPROVAL_KEYS)
133
140
  }.compact
134
141
  end
135
142
 
136
143
  # The command that answers +question+; N is the parent's to fill in.
137
144
  # An approval's is a deny: allowing it is the user's
138
- # (Guardrails::ParentApprovals).
145
+ # (Guardrails::ParentApprovals). A step-limit question's continues the
146
+ # turn (its Stop form is in #question_text).
139
147
  def answer_with(session_id, question)
140
148
  return "chi answer #{session_id} --question #{question[:id]} --option Deny --text WHY" if approval?(question)
149
+ return "chi answer #{session_id} --question #{question[:id]} --option Continue" if continue?(question)
141
150
 
142
151
  "chi answer #{session_id} --question #{question[:id]} --option N"
143
152
  end
@@ -156,6 +165,16 @@ module Samagotchi
156
165
  question.is_a?(Hash) && (question[:kind] || question["kind"]).to_s == "approval"
157
166
  end
158
167
 
168
+ # The step-limit question (ContinueOffer).
169
+ def continue?(question)
170
+ question.is_a?(Hash) && (question[:kind] || question["kind"]).to_s == "continue"
171
+ end
172
+
173
+ # What a message does to a step-limit question.
174
+ def message_drops(session_id)
175
+ "a message instead (chi send #{session_id} -m …) drops it and starts a new turn"
176
+ end
177
+
159
178
  # What a wait that ended without an answer says, one line.
160
179
  def detail(result, session_id:, timeout: nil)
161
180
  attach = "chi --attach #{session_id}"
@@ -163,10 +182,13 @@ module Samagotchi
163
182
  when :waiting_for_answer
164
183
  if approval?(result.question)
165
184
  "waiting for an approval: #{first_line(result.question[:question])}; #{DENY_AND_TELL}"
185
+ elsif continue?(result.question)
186
+ "waiting at the step limit: #{first_line(result.question[:question])}; " \
187
+ "answer Continue or Stop: chi answer #{session_id} --question #{result.question[:id]} --option Continue"
166
188
  else
167
189
  "waiting for an answer: #{first_line(result.question&.dig(:question))}; open it: #{attach} or the web"
168
190
  end
169
- when :no_reply then "#{no_reply_line(result)}; #{attach} shows it"
191
+ when :no_reply then "#{no_reply_line(result, session_id)}; #{attach} shows it"
170
192
  when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
171
193
  when :worker_gone then "the worker is gone; #{attach} shows what happened"
172
194
  when :stopped then "the session was stopped (chi sessions stop)"
@@ -188,6 +210,10 @@ module Samagotchi
188
210
  lines << " allowing it is up to your user: #{DENY_AND_TELL}"
189
211
  lines << " deny: #{answer_with(session_id, q)}"
190
212
  lines << " #{leave_open(session_id)}"
213
+ elsif continue?(q)
214
+ lines << " continue: #{answer_with(session_id, q)}"
215
+ lines << " stop: chi answer #{session_id} --question #{q[:id]} --option Stop --text WHY (the text is optional; the model reads it)"
216
+ lines << " #{message_drops(session_id)}"
191
217
  else
192
218
  lines << " more than one allowed: repeat --option" if q[:multi_select]
193
219
  lines << " free text allowed: --text" if q[:allow_freeform]
@@ -197,11 +223,15 @@ module Samagotchi
197
223
  lines.map { |line| "#{line}\n" }.join
198
224
  end
199
225
 
200
- def no_reply_line(result)
226
+ def no_reply_line(result, session_id = nil)
201
227
  case result.outcome
202
228
  when "failed" then result.text.to_s.strip.empty? ? "the turn failed" : "the turn failed: #{result.text.strip}"
203
229
  when "canceled" then "the turn was canceled"
204
230
  when "completed" then "the turn ended with no visible answer"
231
+ when "not_continued" then "the turn was not continued (Stop); its work so far stays"
232
+ when "exhausted"
233
+ "the turn ran out of iterations#{" (#{result.limit} steps)" if result.limit} before it answered; " \
234
+ "chi send #{session_id} -m '/continue yes' continues it, a message drops it"
205
235
  else "the turn ended without a reply (canceled, failed or empty)"
206
236
  end
207
237
  end
@@ -8,6 +8,7 @@ require "time"
8
8
  require_relative "log"
9
9
  require_relative "tools/ask_user_question"
10
10
  require_relative "guardrails/parent_approvals"
11
+ require_relative "guardrails/parent_continue"
11
12
 
12
13
  module Samagotchi
13
14
  # An Engine's question flow: the one open question (ask_user_question, a
@@ -15,6 +16,15 @@ module Samagotchi
15
16
  # by every UI. The turn thread blocks in #open_question; the UIs answer or
16
17
  # cancel from theirs (a REPL answers inline through the sync handler).
17
18
  #
19
+ # A standing question (#post: a worker's step-limit question, kind
20
+ # "continue") blocks nobody: it waits between turns, its answer goes to
21
+ # its on_answer, and its poster withdraws it (#withdraw). It can't be
22
+ # dismissed. One question stays pending at a time: a question opened
23
+ # while one stands supersedes it (withdrawn, reason "superseded"), and
24
+ # once that one closes the standing one is offered again
25
+ # (on_superseded_close); one posted while another is open waits for it
26
+ # the same way.
27
+ #
18
28
  # What it needs from the Engine comes through lookups, read at call time.
19
29
  class QuestionDesk
20
30
  # Raised by #answer when the question it targets is no longer open
@@ -24,18 +34,28 @@ module Samagotchi
24
34
 
25
35
  # Raised by #answer when a parent agent's answer (chi answer's client
26
36
  # id) allows an approval further than this worker's
27
- # guardrails.parent_approvals lets it (Guardrails::ParentApprovals).
28
- # The question stays open; transports map it to 403.
37
+ # guardrails.parent_approvals lets it (Guardrails::ParentApprovals), or
38
+ # continues a turn turn.parent_continue keeps it from
39
+ # (Guardrails::ParentContinue). The question stays open; transports map
40
+ # it to 403.
29
41
  class Refused < StandardError
30
- # @return [Symbol] :off or :once_only
42
+ # @return [Symbol] :off, :once_only, :protected or :stop_only
31
43
  attr_reader :reason
32
44
 
33
45
  def initialize(reason)
34
46
  @reason = reason
35
- super("a parent may not allow this approval (#{reason})")
47
+ super(reason == :stop_only ? "a parent may not continue this turn" : "a parent may not allow this approval (#{reason})")
36
48
  end
37
49
  end
38
50
 
51
+ # Raised by #cancel for a standing question (#post): its poster closes
52
+ # it, an answer settles it. Transports map it to 409 not_dismissable.
53
+ class NotDismissable < StandardError; end
54
+
55
+ # A step-limit question's options (kind "continue").
56
+ CONTINUE_KIND = "continue"
57
+ CONTINUE_OPTION = "Continue"
58
+
39
59
  # Seconds between two looks of an open question's watch (#open_question).
40
60
  WATCH_INTERVAL = 0.5
41
61
 
@@ -62,6 +82,11 @@ module Samagotchi
62
82
  @pending = nil
63
83
  @answer = nil
64
84
  @sync_handler = nil
85
+ # The standing question while it is @pending: {id:, fields:,
86
+ # on_answer:, on_superseded_close:}; @shelved, one superseded by (or
87
+ # posted during) another question, offered again when that closes.
88
+ @standing = nil
89
+ @shelved = nil
65
90
  end
66
91
 
67
92
  # @return [Hash, nil] current pending question (thread-safe copy)
@@ -99,23 +124,68 @@ module Samagotchi
99
124
  # @return [Hash, String] the answer {id:, selected:, freeform:, selected_indices:},
100
125
  # or {error:, …}; a String when a sync handler returned text itself
101
126
  def open_question(fields, watch: nil)
102
- id = SecureRandom.uuid
103
- pending = { id: id, **fields, status: "pending", created_at: Time.now.iso8601(3) }.compact
127
+ supersede_standing
128
+ ask(fields, watch)
129
+ ensure
130
+ offer_shelved
131
+ end
104
132
 
105
- @lock.synchronize do
106
- @pending = pending
107
- @answer = nil
133
+ # Publish a standing question: pending (saved, announced with
134
+ # standing: true) without blocking anyone. An answer to it is checked
135
+ # as any other and handed to +on_answer+ (answer, client_id:) on the
136
+ # answering thread, after it is cleared and :question_answered is out.
137
+ # Posted while another question is open, it waits for that one to
138
+ # close (as a superseded one does). A standing one already up is
139
+ # replaced (withdrawn, reason "replaced").
140
+ # @param fields [Hash] as for #open_question
141
+ # @param on_answer [#call] (answer, client_id:)
142
+ # @param on_superseded_close [#call, nil] called when a question that
143
+ # superseded it closes (its poster posts it again if it still
144
+ # stands); nil: posted again as it was
145
+ # @return [Hash, nil] the pending question; nil when it waits for another
146
+ def post(fields, on_answer:, on_superseded_close: nil)
147
+ standing = { fields: fields, on_answer: on_answer, on_superseded_close: on_superseded_close }
148
+ shelved = @lock.synchronize do
149
+ next false unless @pending && !@standing
150
+
151
+ @shelved = standing
152
+ true
108
153
  end
109
- # Persist to session file for WEB stub + resume (generic for all UIs)
110
- if session
111
- session.pending_question = pending.dup
112
- begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
154
+ return nil if shelved
155
+
156
+ withdraw("replaced")
157
+ publish(fields, standing: standing)
158
+ end
159
+
160
+ # Withdraw the standing question (its poster's close: the offer went,
161
+ # was answered by a command, the worker leaves), announced as
162
+ # :question_cancelled with +reason+. A shelved one just goes.
163
+ # @param id [String, nil] only this one
164
+ # @return [Boolean] whether one was pending
165
+ def withdraw(reason, id: nil)
166
+ withdrawn = @lock.synchronize do
167
+ @shelved = nil if id.nil?
168
+ standing = @standing
169
+ next nil unless standing && @pending && @pending[:id] == standing[:id]
170
+ next nil if id && standing[:id].to_s != id.to_s
171
+
172
+ @standing = nil
173
+ @pending = nil
174
+ standing[:id]
113
175
  end
114
- # Generic emit for all UIs (TUI, WEB, Bridge, future). Observers that
115
- # stash this event (e.g. TerminalUI handle_question_event) will
116
- # discard it as stale if the synchronous handler below already answers
117
- # and clears pending — see drain_pending_question? staleness check.
118
- emit({ type: :question_requested, pending_question: pending })
176
+ return false unless withdrawn
177
+
178
+ clear_saved_question
179
+ emit({ type: :question_cancelled, id: withdrawn, reason: reason.to_s })
180
+ true
181
+ end
182
+
183
+ # @return [Boolean] the pending question is a standing one
184
+ def standing? = @lock.synchronize { !@standing.nil? }
185
+
186
+ private def ask(fields, watch)
187
+ pending = publish(fields)
188
+ id = pending[:id]
119
189
 
120
190
  # If a synchronous UI handler is registered (TUI), invoke it inline on the
121
191
  # SAME thread that called request_question (TerminalUI's REPL thread is the
@@ -202,7 +272,9 @@ module Samagotchi
202
272
  parent_agent = client_id.to_s == Guardrails::ParentApprovals::CLIENT_ID if parent_agent.nil?
203
273
  # Read before the lock (config may touch the disk); this worker's own.
204
274
  parent_setting = Guardrails::ParentApprovals.setting if parent_agent
205
- @lock.synchronize do
275
+ parent_continue = Guardrails::ParentContinue.allowed? if parent_agent
276
+ standing = nil
277
+ answer = @lock.synchronize do
206
278
  pending = @pending
207
279
  raise NotPending, "no pending question" unless pending
208
280
  raise NotPending, "id mismatch" unless pending[:id].to_s == id.to_s
@@ -212,37 +284,25 @@ module Samagotchi
212
284
  raise NotPending, "question already answered" if @answer
213
285
  raise NotPending, "question #{pending[:status]}" unless pending[:status].to_s == "pending"
214
286
 
215
- opts = Array(pending[:options])
216
- # Validate selected subset of options (value == label in v1)
217
- invalid = sel.reject { |v| opts.include?(v) }
218
- unless invalid.empty?
219
- raise ArgumentError, "invalid selection: #{invalid.join(', ')} (valid: #{opts.join(', ')})"
287
+ answer = validated_answer(pending, sel, fm, parent_agent: parent_agent, parent_setting: parent_setting)
288
+ if parent_agent && (reason = Guardrails::ParentContinue.refusal(pending, sel, allowed: parent_continue))
289
+ raise Refused, reason
220
290
  end
221
- if !pending[:multi_select] && sel.size > 1
222
- raise ArgumentError, "single-select question: got #{sel.size} selections"
291
+ if @standing
292
+ # No waiter: cleared here, so a second answer finds none.
293
+ standing = @standing
294
+ @standing = nil
295
+ @pending = nil
296
+ else
297
+ @answer = answer
298
+ @cv.broadcast
223
299
  end
224
- if pending[:multi_select] == false && sel.empty? && fm.nil?
225
- raise ArgumentError, "selection required"
226
- end
227
- indices = sel.map { |v| opts.index(v) }
228
- # Checked against the question pending now, under the lock: the one
229
- # whose answer settles the approval (Approval.settle, by index).
230
- if parent_setting
231
- reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: parent_setting)
232
- raise Refused, reason if reason
233
- end
234
- # Persist pending cleared elsewhere; just set answer
235
- answer = { id: id.to_s, selected: sel, freeform: fm }
236
- # Derive indices for convenience
237
- answer[:selected_indices] = indices.compact
238
- answer[:by] = "parent_agent" if parent_agent
239
- @answer = answer
240
- @cv.broadcast
241
300
  answer
242
- end.tap do
243
- # A human answered: the session is back in the lists (ArchiveStore).
244
- @user_input.call(session&.id) unless parent_agent
245
301
  end
302
+ settle_standing(standing, answer, client_id) if standing
303
+ # A human answered: the session is back in the lists (ArchiveStore).
304
+ @user_input.call(session&.id) unless parent_agent
305
+ answer
246
306
  end
247
307
 
248
308
  # Mark the pending question as relayed to a parent session's user, or
@@ -287,12 +347,16 @@ module Samagotchi
287
347
  # names the one it showed)
288
348
  # @return [Boolean] whether it was cancelled (true with none pending and
289
349
  # no id, as before)
350
+ # @raise [NotDismissable] +id+ names a standing question (#post)
290
351
  def cancel(reason = "user", id: nil)
291
352
  cancelled_id = @lock.synchronize do
292
353
  pending = @pending
293
354
  next unless pending
294
355
  next if id && pending[:id].to_s != id.to_s
295
356
  next if @answer || pending[:status].to_s != "pending"
357
+ # Its poster closes it; nothing waits on a cancel.
358
+ raise NotDismissable, "answer #{Array(pending[:options]).join(' or ')}" if @standing && id
359
+ next if @standing
296
360
 
297
361
  pending[:status] = "cancelled"
298
362
  @cv.broadcast
@@ -330,6 +394,101 @@ module Samagotchi
330
394
 
331
395
  private
332
396
 
397
+ # Make +fields+ the pending question: set under the lock, saved to the
398
+ # session file (the web's stub, a resume, the lists) and announced as
399
+ # :question_requested to every UI. Observers that stash the event (e.g.
400
+ # TerminalUI handle_question_event) discard it as stale if a sync
401
+ # handler answers and clears it first (drain_pending_question?).
402
+ # @return [Hash] the pending question
403
+ # @param standing [Hash, nil] a standing question's record (#post)
404
+ def publish(fields, standing: nil)
405
+ pending = { id: SecureRandom.uuid, **fields, status: "pending", created_at: Time.now.iso8601(3) }.compact
406
+ @lock.synchronize do
407
+ @pending = pending
408
+ @answer = nil
409
+ @standing = standing&.merge(id: pending[:id])
410
+ end
411
+ if session
412
+ session.pending_question = pending.dup
413
+ begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
414
+ end
415
+ event = { type: :question_requested, pending_question: pending }
416
+ event[:standing] = true if standing
417
+ emit(event)
418
+ pending
419
+ end
420
+
421
+ # A question is about to open: a standing one gives way (withdrawn,
422
+ # reason "superseded") and is shelved until the new one closes.
423
+ def supersede_standing
424
+ standing = @lock.synchronize do
425
+ next nil unless @standing
426
+
427
+ @shelved = @standing
428
+ @standing = nil
429
+ @pending = nil
430
+ @shelved[:id]
431
+ end
432
+ emit({ type: :question_cancelled, id: standing, reason: "superseded" }) if standing
433
+ end
434
+
435
+ # The question that superseded a standing one closed: offer it again.
436
+ def offer_shelved
437
+ shelved = @lock.synchronize do
438
+ next nil if @pending || @shelved.nil?
439
+
440
+ @shelved.tap { @shelved = nil }
441
+ end
442
+ return unless shelved
443
+
444
+ if shelved[:on_superseded_close]
445
+ shelved[:on_superseded_close].call
446
+ else
447
+ post(shelved[:fields], on_answer: shelved[:on_answer])
448
+ end
449
+ rescue StandardError => e
450
+ Log.warn(:turn, "standing_question_repost_failed", error: e.class.name, message: e.message)
451
+ end
452
+
453
+ # A standing question's answer: cleared from the file, announced, then
454
+ # handed to its poster with who answered.
455
+ def settle_standing(standing, answer, client_id)
456
+ clear_saved_question
457
+ emit({ type: :question_answered, id: answer[:id], answer: answer })
458
+ standing[:on_answer].call(answer.except(:by), client_id: client_id)
459
+ rescue StandardError => e
460
+ Log.warn(:turn, "standing_answer_failed", error: e.class.name, message: e.message)
461
+ end
462
+
463
+ # An answer to +pending+, checked: the selections are its options (value
464
+ # == label in v1), one for a single-select, one or a text when required,
465
+ # and a parent agent's within guardrails.parent_approvals. Under the
466
+ # question lock: checked against the question pending now.
467
+ # @return [Hash] {id:, selected:, freeform:, selected_indices:, by:}
468
+ # @raise [ArgumentError, Refused]
469
+ def validated_answer(pending, sel, fm, parent_agent:, parent_setting:)
470
+ opts = Array(pending[:options])
471
+ invalid = sel.reject { |v| opts.include?(v) }
472
+ raise ArgumentError, "invalid selection: #{invalid.join(', ')} (valid: #{opts.join(', ')})" unless invalid.empty?
473
+ raise ArgumentError, "single-select question: got #{sel.size} selections" if !pending[:multi_select] && sel.size > 1
474
+ raise ArgumentError, "selection required" if pending[:multi_select] == false && sel.empty? && fm.nil?
475
+ # A step-limit question: Continue resumes the turn as it was; a text
476
+ # goes with Stop (steering a continue is not a thing).
477
+ if pending[:kind].to_s == CONTINUE_KIND && sel == [CONTINUE_OPTION] && fm
478
+ raise ArgumentError, "Continue takes no text (a text goes with Stop)"
479
+ end
480
+
481
+ indices = sel.map { |v| opts.index(v) }
482
+ # The one whose answer settles the approval (Approval.settle, by index).
483
+ if parent_setting
484
+ reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: parent_setting)
485
+ raise Refused, reason if reason
486
+ end
487
+ answer = { id: pending[:id].to_s, selected: sel, freeform: fm, selected_indices: indices.compact }
488
+ answer[:by] = "parent_agent" if parent_agent
489
+ answer
490
+ end
491
+
333
492
  # Wait until the question is answered, cancelled or closed by +watch+.
334
493
  # @return [String, nil] the watch's close reason
335
494
  def wait_for_answer(watch)
@@ -26,10 +26,14 @@ module Samagotchi
26
26
  # @!attribute question [Hash, nil] the pending question
27
27
  # (:waiting_for_answer)
28
28
  # @!attribute outcome [String, nil] how the turn ended when it left no
29
- # reply (:no_reply): "failed", "canceled" or "completed" (empty), from
30
- # the session's last_turn; nil when unknown. With "failed", text is
31
- # the failure's summary when the turn note has one.
32
- Result = Struct.new(:status, :text, :file, :question, :outcome, keyword_init: true)
29
+ # reply (:no_reply): "failed", "canceled", "completed" (empty),
30
+ # "exhausted" (it ran out of iterations, with nobody asked whether to
31
+ # continue) or "not_continued" (a Stop answered the step-limit
32
+ # question; no turn ran), from the session's last_turn; nil when unknown. With
33
+ # "failed", text is the failure's summary when the turn note has one.
34
+ # @!attribute limit [Integer, nil] the iteration limit an "exhausted"
35
+ # turn ran out at
36
+ Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, keyword_init: true)
33
37
 
34
38
  module_function
35
39
 
@@ -144,6 +148,8 @@ module Samagotchi
144
148
  last = session.last_turn.is_a?(Hash) ? session.last_turn : {}
145
149
  fresh = baseline.nil? || !baseline.key?(:last_turn) || (last["ended_at"] && last["ended_at"] != baseline[:last_turn])
146
150
  outcome = fresh ? last["outcome"] : nil
151
+ return Result.new(status: :no_reply, outcome: "exhausted", limit: last["limit"]) if fresh && last["exhausted"]
152
+
147
153
  text = outcome == "failed" ? failure_summary(session.messages) : nil
148
154
  Result.new(status: :no_reply, outcome: outcome, text: text)
149
155
  end
@@ -66,7 +66,8 @@ module Samagotchi
66
66
  (--wait) one JSON object on stdout, whatever the end:
67
67
  status answered (text), question (question,
68
68
  answer_with), running, or failed, canceled,
69
- no_answer, error, worker_gone, stopped, command (detail)
69
+ limit, no_answer, error, worker_gone, stopped,
70
+ command (detail)
70
71
  Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
71
72
  question waits, 4 still running (--timeout), 130 Ctrl-C.
72
73
  Only sessions on this machine. Answers show in the attached TUI
@@ -100,7 +100,8 @@ module Samagotchi
100
100
  end
101
101
 
102
102
  # The question this session waits on, as the lists show it: {id:, kind:}
103
- # with kind "question" (the model's), "approval" or "hook"; nil with
103
+ # with kind "question" (the model's), "approval", "hook" or "continue"
104
+ # (the step-limit question, asked between turns); nil with
104
105
  # none. Only while a worker runs it (+live+): a question saved by a
105
106
  # worker that died waits for no one (the next worker drops it). The
106
107
  # web's summaries and `chi sessions list` both ask this.
@@ -113,7 +114,7 @@ module Samagotchi
113
114
  kind = pending[:kind].to_s
114
115
  relayed = pending[:relayed_to]
115
116
  relayed_to = relayed.is_a?(Hash) ? (relayed[:parent_short] || relayed["parent_short"]) : nil
116
- { id: pending[:id], kind: %w[approval hook].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
117
+ { id: pending[:id], kind: %w[approval hook continue].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
117
118
  end
118
119
 
119
120
  # The stored project root, else (a file from before the field) the
@@ -158,11 +158,11 @@ module Samagotchi
158
158
  when :abort
159
159
  @turn_flow.abort_continue!
160
160
  save_session
161
- reply("interrupted turn cancelled; enter your next prompt", changed: [:messages])
161
+ reply("turn not continued; its work so far stays (!rollback erases it)", changed: [:messages])
162
162
  when :abort_with_reason
163
163
  @turn_flow.abort_continue!(reason: reason)
164
164
  save_session
165
- reply("interrupted turn cancelled; noted your explanation", changed: [:messages])
165
+ reply("turn not continued; noted your reason", changed: [:messages])
166
166
  else
167
167
  reply("answer yes, no, or no, <reason>", status: :error)
168
168
  end
@@ -252,6 +252,7 @@ module Samagotchi
252
252
  ("tool #{rule.tools.join(",")}" if rule.tools),
253
253
  ("command /#{shorten_pattern(rule.command.source)}/" if rule.command),
254
254
  ("path #{rule.path}" if rule.path),
255
+ ("git #{rule.git}" if rule.git),
255
256
  ("models #{rule.models.join(",")}" if rule.models)
256
257
  ].compact.join(", ")
257
258
  off = "disabled (guardrails.disable) — " if rules.disabled?(rule)
@@ -364,7 +364,10 @@ module Samagotchi
364
364
  cut(summary_text(session), SUMMARY_PREVIEW_LIMIT)
365
365
  end
366
366
 
367
- private_class_method def self.summary_text(session)
367
+ # A session as one line of text for the lists: its last prompt, else the
368
+ # first message it was started with (a session command like `/model x`,
369
+ # or a first turn not saved yet).
370
+ def self.summary_text(session)
368
371
  one_line(session.last_prompt.to_s.strip.empty? ? session.first_preview.to_s : session.last_prompt.to_s)
369
372
  end
370
373
 
@@ -191,11 +191,12 @@ module Samagotchi
191
191
  order ||= "desc"
192
192
  sessions = Samagotchi::SessionManager.list_sessions(sort: sort, order: order, limit: limit, project_root: project,
193
193
  include_archived: include_archived)
194
- # The saved recap's first sentence, else the last prompt, cut as before.
194
+ # The saved recap's first sentence, else the last prompt (or the first
195
+ # message, before any turn), cut as before.
195
196
  state_dir = Samagotchi::Session.default_state_dir
196
197
  list_text = lambda do |s|
197
198
  recap = Samagotchi::RecapStore.preview(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
198
- (recap || Samagotchi::SessionManager.one_line(s.last_prompt))[0, 60]
199
+ (recap || Samagotchi::SessionManager.summary_text(s))[0, 60]
199
200
  end
200
201
  # A delegated session points at its parent: ↳ <parent's short id>.
201
202
  row = lambda do |s|
@@ -381,7 +381,7 @@ module Samagotchi
381
381
  root = MemoryPaths.project_root(cwd)
382
382
  lines = ["Current working directory:", cwd]
383
383
  unless root == cwd
384
- lines << "Project root (project memories are shared by all worktrees and subdirectories of this repository):"
384
+ lines << "Project root (only where shared project memories come from; read, edit, run and commit in the current working directory above):"
385
385
  lines << root
386
386
  end
387
387
  home = Dir.home
@@ -342,6 +342,9 @@ module Samagotchi
342
342
  # The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
343
343
  persist_recent_history(text) if PromptHistory.shell_line?(text)
344
344
  send_command(text)
345
+ elsif (hint = command_registry.unknown_command_hint(text))
346
+ # A typo of a command (/modle) is not a prompt: say so, send nothing.
347
+ @screen.commit(hint)
345
348
  else
346
349
  send_prompt(text)
347
350
  end
@@ -522,6 +525,9 @@ module Samagotchi
522
525
  # UI's answer shows as its command line (web> /continue yes) instead.
523
526
  def continue_resolved(event)
524
527
  @continue_offer = nil
528
+ # Asked as the step-limit question: its line says what became of it.
529
+ asked = @offer_asked
530
+ @offer_asked = false
525
531
  sync_continue_slot
526
532
  who = event[:client_id] ? CLIENT_LABELS.fetch(event[:client_id].to_s.split(":", 2).first, "another UI") : "another UI"
527
533
  outcome = if event[:decision] == "dropped"
@@ -530,14 +536,14 @@ module Samagotchi
530
536
  @continue_answer || CONTINUE_DECISIONS.fetch(event[:decision].to_s, event[:decision].to_s)
531
537
  end
532
538
  @continue_answer = nil
533
- @screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome
539
+ @screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome && !asked
534
540
  sync_prompt
535
541
  end
536
542
 
537
543
  # The notes slot shows the offer's choices while it waits (a question
538
544
  # has the slot while it is open).
539
545
  def sync_continue_slot
540
- return if @question
546
+ return if @question || @offer_asked
541
547
 
542
548
  if @continue_offer
543
549
  @screen.set_slot(:notes, QuestionSlot.continue_offer(@continue_offer[:context], paint: method(:paint)))
@@ -692,6 +698,9 @@ module Samagotchi
692
698
  @set_aside = @reader&.typed_text unless @question
693
699
  @question = QuestionPrompt.new(pending)
694
700
  @question_pending = pending
701
+ # The step-limit question stands for the continue offer's own slot
702
+ # until the offer is resolved.
703
+ @offer_asked = true if @question.continue?
695
704
  # An edit's diff goes above, into the scrollback, once per question
696
705
  # (a join gets it from the snapshot and may get the event too).
697
706
  unless @previewed_id == @question.id
@@ -705,7 +714,9 @@ module Samagotchi
705
714
  end
706
715
 
707
716
  def answer_question(text)
708
- return dismiss_question if text.empty?
717
+ # Enter alone at the step-limit question continues (it can't be
718
+ # dismissed), as at the continue prompt.
719
+ return dismiss_question if text.empty? && !@question.continue?
709
720
 
710
721
  answer = @question.parse(text)
711
722
  @screen.commit(answer.note) if answer.note
@@ -893,6 +904,8 @@ module Samagotchi
893
904
  sync_continue_slot
894
905
  end
895
906
  render_current_turn(snapshot[:current_turn])
907
+ # A question between turns (the step-limit one) belongs to no turn.
908
+ ask(snapshot[:pending_question]) if snapshot[:current_turn].nil? && snapshot[:pending_question]
896
909
  render_snapshot_cards(cards.select { |card| card[:current] }, joining: !reset)
897
910
  Array(snapshot[:queued]).each do |entry|
898
911
  next if own?(entry[:client_id])
@@ -951,8 +964,15 @@ module Samagotchi
951
964
  return if text.empty?
952
965
 
953
966
  @screen.commit("#{paint(PROMPT, 92)}#{text}")
954
- # As if typed: a session command runs as the command.
955
- command_registry.command?(text) ? send_command(text) : send_prompt(text)
967
+ # As if typed: a session command runs as the command; a typo of one
968
+ # gets the hint, not a turn.
969
+ if command_registry.command?(text)
970
+ send_command(text)
971
+ elsif (hint = command_registry.unknown_command_hint(text))
972
+ @screen.commit(hint)
973
+ else
974
+ send_prompt(text)
975
+ end
956
976
  end
957
977
 
958
978
  def render_join_header(messages)