samagotchi 0.63.0 → 0.64.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/docs/architecture.md +12 -2
  4. data/docs/cli.md +16 -8
  5. data/docs/configuration.md +15 -8
  6. data/docs/internals/engine.md +10 -3
  7. data/docs/plugins.md +7 -0
  8. data/docs/sessions.md +11 -8
  9. data/docs/sub-agent.md +21 -4
  10. data/docs/testing.md +6 -0
  11. data/lib/samagotchi/answer_command.rb +5 -1
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  13. data/lib/samagotchi/bridge.rb +18 -4
  14. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/child_reports.rb +7 -2
  19. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  20. data/lib/samagotchi/composer_route.rb +90 -0
  21. data/lib/samagotchi/config.rb +6 -3
  22. data/lib/samagotchi/continue_offer.rb +17 -3
  23. data/lib/samagotchi/delivery.rb +13 -8
  24. data/lib/samagotchi/engine.rb +2 -0
  25. data/lib/samagotchi/host_config.rb +6 -6
  26. data/lib/samagotchi/host_model.rb +2 -2
  27. data/lib/samagotchi/interrupted_turn.rb +7 -3
  28. data/lib/samagotchi/last_turn.rb +5 -2
  29. data/lib/samagotchi/pending_input_queue.rb +10 -3
  30. data/lib/samagotchi/plugin/sessions.rb +27 -5
  31. data/lib/samagotchi/reply_wait.rb +171 -6
  32. data/lib/samagotchi/send_command.rb +63 -19
  33. data/lib/samagotchi/session_commands.rb +1 -1
  34. data/lib/samagotchi/session_inbox.rb +63 -7
  35. data/lib/samagotchi/session_manager.rb +49 -20
  36. data/lib/samagotchi/terminal_ui/attached_loop.rb +47 -26
  37. data/lib/samagotchi/terminal_ui/formatting.rb +3 -19
  38. data/lib/samagotchi/terminal_ui.rb +72 -57
  39. data/lib/samagotchi/tool_activity.rb +5 -5
  40. data/lib/samagotchi/tools/delegate.rb +28 -1
  41. data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
  42. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  43. data/lib/samagotchi/tools/execute.rb +11 -4
  44. data/lib/samagotchi/turn_checkpoint.rb +9 -3
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/public/app.js +6 -0
  47. data/lib/samagotchi/web/public/commands.js +8 -8
  48. data/lib/samagotchi/web/public/composer.js +3 -3
  49. data/lib/samagotchi/web/public/timing.js +2 -1
  50. data/lib/samagotchi/web/public/turn_model.js +13 -3
  51. data/lib/samagotchi/worker.rb +7 -5
  52. metadata +2 -1
@@ -205,8 +205,9 @@ module Samagotchi
205
205
  # child inherits this process's ENV and reads config.yml itself (as it
206
206
  # is when the worker starts); opts[:env] adds to it (merged, not
207
207
  # replaced) what it can't read there: this `chi`'s CLI settings
208
- # (Config.cli_env), the hosts and the absolute log file. It unsets
209
- # SAMAGOTCHI_GUARDRAILS_* and the session markers (SESSION_MARKER_ENV).
208
+ # (Config.cli_env) and the absolute log file. It unsets
209
+ # SAMAGOTCHI_GUARDRAILS_*, the session markers (SESSION_MARKER_ENV) and
210
+ # SAMAGOTCHI_HOSTS_JSON (HOSTS_JSON_ENV).
210
211
  # XDG_CONFIG_HOME passes: whoever starts or
211
212
  # wakes the worker picks which config.yml it reads (docs/guardrails.md).
212
213
  private_class_method def self.spawn_options(session)
@@ -223,14 +224,12 @@ module Samagotchi
223
224
  Log.warn(:worker, "cwd_gone", sid: session.id, dir: dir, cwd: Dir.pwd)
224
225
  end
225
226
  child_env = {}
226
- # Propagate hosts config for multi-host routing
227
- begin
228
- require_relative "config"
229
- hosts_json = ConfigFile.hosts_json_for_env
230
- child_env["SAMAGOTCHI_HOSTS_JSON"] = hosts_json if hosts_json && !hosts_json.strip.empty?
231
- rescue StandardError
232
- nil
233
- end
227
+ # The worker reads config.yml's hosts: as it starts, like every other
228
+ # setting: a host added or changed after this process (a parent
229
+ # worker, `chi web`) started reaches it. Unset, not just left out: a
230
+ # worker an older chi started still has one, and its delegates would
231
+ # inherit its start-time hosts.
232
+ child_env[HOSTS_JSON_ENV] = nil
234
233
  # A worker gets no CLI args: --model, --thinking, --log-level and the
235
234
  # other flags this chi was started with.
236
235
  child_env.merge!(Config.cli_env)
@@ -694,7 +693,7 @@ module Samagotchi
694
693
  # so a resume right after spawns a fresh worker instead of finding the
695
694
  # dying one.
696
695
  # A turn still running is canceled first (STOP_CANCEL_WAIT), as the
697
- # web's Cancel does: its worker saves it. The TERM stops (and saves)
696
+ # web's Cancel does, with reason STOP_CANCEL_REASON: its worker saves it. The TERM stops (and saves)
698
697
  # what still runs (WorkerSignals), with a short grace after the marker.
699
698
  # @return [Boolean, nil] with +wait+: whether the owner was gone in time
700
699
  # @raise [OwnedByTUI] when the interactive TUI owns the session
@@ -714,7 +713,14 @@ module Samagotchi
714
713
  wait_for_owner_release(session_id, timeout: wait, state_dir: sd) if wait
715
714
  end
716
715
 
716
+ # ConfigFile.hosts_config's override of config.yml's hosts:, which no
717
+ # spawned worker inherits (spawn_options).
718
+ HOSTS_JSON_ENV = "SAMAGOTCHI_HOSTS_JSON"
719
+
717
720
  STOP_CANCEL_WAIT = 3.0
721
+ # The cancel reason of a turn a session stop cancels (Bridge::CANCEL_REASONS):
722
+ # not the user's Stop, which only ends the turn ("user").
723
+ STOP_CANCEL_REASON = "session_stopped"
718
724
 
719
725
  # Cancel the session's running turn over its Bridge and wait (up to
720
726
  # STOP_CANCEL_WAIT) for the worker to save it. Best effort: without a
@@ -723,7 +729,7 @@ module Samagotchi
723
729
  return unless Session.load(session_id, state_dir: state_dir).status == Session::STATUS_RUNNING
724
730
 
725
731
  client = BridgeClient.discover(session_id, session_dir: Session.session_dir(session_id, state_dir: state_dir))
726
- return unless client && client.cancel(reason: "user").status == 202
732
+ return unless client && client.cancel(reason: STOP_CANCEL_REASON).status == 202
727
733
 
728
734
  BridgeClient.poll(STOP_CANCEL_WAIT) do
729
735
  Session.load(session_id, state_dir: state_dir).status != Session::STATUS_RUNNING
@@ -1167,9 +1173,12 @@ module Samagotchi
1167
1173
  # the session's sidecar, waiting TURN_BRIDGE_WAIT for a new worker's
1168
1174
  # @return [Hash] {status: :accepted, ack: Hash} (the Bridge's reply, or
1169
1175
  # {status:, enqueued_id:, session_id:} for a file), {status: :refused,
1170
- # code:, ack:} when the Bridge refused the images, {status: :timeout,
1171
- # ack:} when it took the request but never answered (no file then), or
1172
- # {status: :failed} when the input file couldn't be written
1176
+ # code:, ack:} when the Bridge refused the request (any 4xx with an
1177
+ # error: bad images, a delivery it doesn't run) or a queue would reach
1178
+ # a worker that doesn't queue (no file then: it would steer),
1179
+ # {status: :timeout, ack:} when it took the request but never answered
1180
+ # (no file then), or {status: :failed} when the input file couldn't be
1181
+ # written
1173
1182
  # @raise [OwnedByTUI] a chi REPL owns the session
1174
1183
  # @raise [ArgumentError] no such session
1175
1184
  def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, state_dir: nil, manager: self,
@@ -1178,20 +1187,28 @@ module Samagotchi
1178
1187
  bridge ||= -> { BridgeClient.wait_for(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT) }
1179
1188
  manager.resume_session(session_id, state_dir: state_dir)
1180
1189
  if (client = bridge.call)
1190
+ # A worker from before queueing (no "delivery" feature) ignores the
1191
+ # field or refuses it; either way a file would steer the running turn.
1192
+ return queue_unavailable if Delivery.queue?(delivery) && !worker_queues?(session_dir)
1193
+
1181
1194
  begin
1182
1195
  options = { prompt: prompt, client_id: client_id }
1183
1196
  options[:images] = images unless images.empty?
1184
- # An old worker ignores the field: it cuts anyway (its own default)
1185
- # and its file never carries a queue, so a queue would steer there.
1197
+ # An old worker ignores the field: it cuts anyway (its own default).
1186
1198
  options[:delivery] = delivery unless Delivery.next_step?(delivery)
1187
1199
  reply = client.post_turn(**options)
1188
1200
  ack = reply.json
1189
1201
  return { status: :accepted, ack: ack } if reply.status == 202 && ack.is_a?(Hash)
1190
- if ack.is_a?(Hash) && ack["error"] == "bad_images"
1191
- return { status: :refused, code: reply.status, ack: ack }
1192
- end
1193
1202
  # Read after its deadline and dropped: a file would run it after all.
1194
1203
  return turn_timeout if ack.is_a?(Hash) && ack["error"] == "deadline_passed"
1204
+ # Any other 4xx with an error is a refusal (bad images, a delivery
1205
+ # it doesn't run, a client bug): a file would bypass it, and a
1206
+ # refused queue would steer. A 413 (a body over the Bridge's
1207
+ # limit, a big piped chi send) and a 5xx keep the file fallback
1208
+ # below, which carries the delivery (a queue too).
1209
+ if (400..499).cover?(reply.status) && reply.status != 413 && ack.is_a?(Hash) && ack["error"]
1210
+ return { status: :refused, code: reply.status, ack: ack }
1211
+ end
1195
1212
  rescue Errno::ETIMEDOUT
1196
1213
  # The Bridge drops a turn it reads after the request's deadline
1197
1214
  # (BridgeClient#post_turn), so a worker that wakes later won't run
@@ -1222,6 +1239,18 @@ module Samagotchi
1222
1239
  { status: :accepted, ack: { status: "accepted", enqueued_id: enqueued_id, session_id: session_id } }
1223
1240
  end
1224
1241
 
1242
+ # Whether the session's live worker runs delivery: queue (its sidecar
1243
+ # names the feature). No sidecar to read (the client came from a stand-in,
1244
+ # or it went between the wait and here): true, the post finds out.
1245
+ private_class_method def self.worker_queues?(session_dir)
1246
+ sidecar = WorkerSidecar.read(session_dir)
1247
+ sidecar.nil? || sidecar.features.include?(Delivery::QUEUE_FEATURE)
1248
+ end
1249
+
1250
+ private_class_method def self.queue_unavailable
1251
+ { status: :refused, code: 409, ack: Delivery::QUEUE_UNAVAILABLE }
1252
+ end
1253
+
1225
1254
  private_class_method def self.turn_timeout
1226
1255
  { status: :timeout, ack: { "error" => "worker_timeout",
1227
1256
  "detail" => "the session's worker did not answer, so the message was not sent" } }
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../client_id"
4
+ require_relative "../composer_route"
4
5
  require_relative "event_renderer"
5
6
  require_relative "formatting"
6
7
  require_relative "../prompt_history"
@@ -387,61 +388,72 @@ module Samagotchi
387
388
  end
388
389
 
389
390
  # /cut TEXT and /queue TEXT are this UI's own deliveries, read before
390
- # every command check (SessionCommands registers them for Tab and
391
- # /help only). A plain line sends no delivery: it goes in at the
392
- # running turn's next step, and never cuts.
393
- if (choice = steer_choice(text))
394
- return submit_delivery(*choice)
395
- end
391
+ # every command check (ComposerRoute; SessionCommands registers them
392
+ # for Tab and /help only). A plain line sends no delivery: it goes in
393
+ # at the running turn's next step, and never cuts.
394
+ route = ComposerRoute.call(text, running: @running, registry: command_registry)
395
+ return submit_delivery(route) if route.choice
396
396
 
397
397
  # The terminal's own commands (SessionCommands registers them, the
398
398
  # REPL reads the same words). Before the continue offer: the
399
399
  # leaving ones are no answer to it.
400
- local = command_registry.lookup_local(text)&.id
400
+ local = route.local
401
401
  return submit(nil) if local == :detach
402
402
  return SessionCommands.delete_on_exit?(text) ? exit_and_delete : exit_worker if local == :exit
403
403
  return archive_after_turn if local == :archive && (@running || !@queued_ids.empty?)
404
404
  return exit_and_archive if local == :archive
405
405
 
406
406
  if @continue_offer
407
- return answer_continue(text) unless local || command_registry.command?(text)
407
+ return answer_continue(text) unless local || route.kind == :command
408
408
 
409
409
  # Not an answer: the read left no echo, so show what ran.
410
410
  echo_answer(text)
411
411
  end
412
- return if text.empty?
412
+ return if route.kind == :empty
413
413
 
414
414
  if local == :stats
415
415
  show_stats
416
416
  elsif local == :recap
417
417
  show_recap
418
- elsif command_registry.command?(text)
418
+ elsif route.kind == :command
419
419
  # The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
420
420
  persist_recent_history(text) if PromptHistory.shell_line?(text)
421
421
  send_command(text)
422
- elsif (hint = command_registry.unknown_command_hint(text))
422
+ elsif route.kind == :typo
423
423
  # A typo of a command (/modle) is not a prompt: say so, send nothing.
424
- @screen.commit(hint)
424
+ @screen.commit(route.reply)
425
425
  else
426
426
  send_prompt(text)
427
427
  end
428
428
  nil
429
429
  end
430
430
 
431
- # /cut TEXT and /queue TEXT while a turn runs. Only /cut goes in P1:
432
- # the worker's drain doesn't skip queued files yet, so a queued
433
- # message would steer the running turn; /queue says so and sends
434
- # nothing. At the prompt both run the text as a normal turn.
431
+ # /cut TEXT and /queue TEXT (+route+ has a choice). During a turn the
432
+ # text goes with its delivery: a cut, or queued to run as a turn of
433
+ # its own after this one. A worker that doesn't queue (its sidecar
434
+ # names no "delivery" feature) would steer a queue, so it is not sent.
435
+ # At the prompt both send the text as typed, a normal turn: no local
436
+ # word or typo check (the worker's Bridge runs a session command as
437
+ # the command).
438
+ # @param route [ComposerRoute::Route]
435
439
  # @return [nil]
436
- def submit_delivery(choice, text)
437
- return @screen.commit(STEER_CHOICE_USAGE) if text.empty?
438
- return @screen.commit(QUEUE_UNAVAILABLE) if choice == :queue && @running
440
+ def submit_delivery(route)
441
+ return @screen.commit(STEER_CHOICE_USAGE) if route.kind == :usage
442
+ return @screen.commit(QUEUE_NEEDS_NEWER_WORKER) if Delivery.queue?(route.delivery) && !worker_queues?
439
443
 
440
444
  # To an idle session it is an ordinary prompt: no delivery to send.
441
- send_prompt(text, delivery: @running ? Delivery::CUT : nil)
445
+ send_prompt(route.text, delivery: route.delivery)
442
446
  nil
443
447
  end
444
448
 
449
+ # Whether the attached worker runs delivery: queue (its sidecar names
450
+ # Delivery::QUEUE_FEATURE). No sidecar to read: true, its Bridge
451
+ # answers (a P0-P2 worker refuses a queue: #send_prompt).
452
+ def worker_queues?
453
+ sidecar = WorkerSidecar.read(Session.session_dir(@client.session_id, state_dir: @state_dir))
454
+ sidecar.nil? || sidecar.features.include?(Delivery::QUEUE_FEATURE)
455
+ end
456
+
445
457
  # @return [Symbol] :detach
446
458
  def detach(line)
447
459
  @view.finish_thinking_spinner
@@ -601,9 +613,9 @@ module Samagotchi
601
613
  end
602
614
 
603
615
  QUEUED_NOTE = "(queued: runs after this turn)"
604
- # /queue needs a worker whose drain skips queued input files (P3):
605
- # until then a queued message would steer the running turn.
606
- QUEUE_UNAVAILABLE = "(/queue isn't available in an attached session yet)"
616
+ QUEUE_RUNS_NOW = "(the turn had ended: runs as the next turn)"
617
+ # /queue to a worker from before queueing, which would steer it.
618
+ QUEUE_NEEDS_NEWER_WORKER = "(/queue needs a newer worker: restart it with chi sessions restart, then send again)"
607
619
 
608
620
  # A command queued for after the running turn (command_queued with
609
621
  # waits): its line now (another client's) and a note; its command_ran
@@ -709,14 +721,20 @@ module Samagotchi
709
721
  # What a prompt's 202 says became of it, or nil when there is nothing
710
722
  # to say: the dim line both TUIs print for a delivery. A plain line
711
723
  # for a running turn goes in at its next step; a cut's outcome comes
712
- # from the ack (the worker knows whether cutting is on for its model).
724
+ # from the ack (the worker knows whether cutting is on for its model);
725
+ # a queued one waits for the turn's end (the ack says queue).
713
726
  # An idle session's prompt simply runs, and a prompt with images runs
714
727
  # as its own next turn (never merging mid-turn), so nothing is said.
715
728
  # @param ack [Hash, nil] the Bridge's 202 body
716
729
  # @param delivery [String, nil] what was asked for
717
730
  # @return [String, nil]
718
731
  def sent_note(ack, delivery: nil, images: [], running: false)
719
- return nil unless running && images.empty? && ack.is_a?(Hash)
732
+ return nil unless running && ack.is_a?(Hash)
733
+ # The Bridge acks queue only while a turn ran at enqueue; a /queue
734
+ # it acks next_step found the turn over: it is the next turn.
735
+ return QUEUED_NOTE if ack["delivery"] == Delivery::QUEUE
736
+ return QUEUE_RUNS_NOW if Delivery.queue?(delivery)
737
+ return nil unless images.empty?
720
738
 
721
739
  return NEXT_STEP_NOTE unless delivery == Delivery::CUT
722
740
 
@@ -748,6 +766,8 @@ module Samagotchi
748
766
  detail = reply.json&.fetch("error", nil)
749
767
  # Read after its deadline and dropped (BridgeClient::DEADLINE_SHARE).
750
768
  return @screen.commit("could not send the prompt (#{LATE})") if detail == "deadline_passed"
769
+ # A worker from 0.51 to 0.63 refuses a queue (nothing was sent).
770
+ return @screen.commit(QUEUE_NEEDS_NEWER_WORKER) if detail == "delivery_unavailable"
751
771
 
752
772
  @screen.commit("could not send the prompt (#{[reply.status, detail].compact.join(" ")})")
753
773
  rescue SystemCallError, IOError => e
@@ -1411,7 +1431,8 @@ module Samagotchi
1411
1431
  return if own?(event[:client_id])
1412
1432
 
1413
1433
  @shown_enqueued << event[:enqueued_id]
1414
- @screen.commit(prompt_line(event[:client_id], event[:prompt]))
1434
+ line = prompt_line(event[:client_id], event[:prompt])
1435
+ @screen.commit(event[:delivery] == Delivery::QUEUE ? "#{line} #{QUEUED_NOTE}" : line)
1415
1436
  end
1416
1437
 
1417
1438
  def start_turn(event)
@@ -108,14 +108,8 @@ module Samagotchi
108
108
  CUT_NOW_NOTE = "(cut in now)"
109
109
  CUT_WAITS_NOTE = "(cuts in once the thinking passes %d s)"
110
110
  CUT_OFF_NOTE = "(cutting is off; goes in at the next step)"
111
+ # A bare /cut or /queue (ComposerRoute's :usage).
111
112
  STEER_CHOICE_USAGE = "(/cut and /queue need a message: /cut TEXT, /queue TEXT)"
112
- # The UIs' own words for a delivery, listed for Tab and /help only
113
- # (a local: entry, so no command or bundle may use the names).
114
- CUT_COMMAND = "/cut"
115
- QUEUE_COMMAND = "/queue"
116
- # The word, then any whitespace and the text (as the registry's
117
- # entries match them, so a line Tab or /help knows is never a prompt).
118
- STEER_CHOICE = /\A(#{CUT_COMMAND}|#{QUEUE_COMMAND})(?:\s+(.*))?\z/m
119
113
 
120
114
  # @param outcome [Symbol] SteerCut#cut_for_steer's answer: :now, :waits or :off
121
115
  # @param after [Integer, nil] the run's cut wait (the model's own
@@ -133,17 +127,6 @@ module Samagotchi
133
127
  # The same words, for a UI that includes the module.
134
128
  def steer_cut_note(outcome, after = nil) = Formatting.steer_cut_note(outcome, after)
135
129
 
136
- # The choice a mid-turn line carries, as [:cut, "TEXT"] or
137
- # [:queue, "TEXT"]; nil for any other line. The text is "" when the
138
- # line named the choice without one.
139
- # @param line [String, nil]
140
- # @return [Array(Symbol, String), nil]
141
- def steer_choice(line)
142
- match = STEER_CHOICE.match(line.to_s.strip) or return nil
143
-
144
- [match[1] == CUT_COMMAND ? :cut : :queue, match[2].to_s.strip]
145
- end
146
-
147
130
  # "✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens (paid
148
131
  # off)": a batch of LLM context edits went in (LLMContextNotice builds
149
132
  # the line; the web prints the same), dim like the retry row.
@@ -363,7 +346,8 @@ module Samagotchi
363
346
 
364
347
  # A cancel's reason as the UIs name it (anything else as it is);
365
348
  # spec/shared/labels_matrix.json pins it for both.
366
- CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook", "worker_stopped" => "worker stopped" }.freeze
349
+ CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook", "worker_stopped" => "worker stopped",
350
+ "session_stopped" => "session stopped" }.freeze
367
351
  # A canceled turn's mark: a stop someone chose (Ctrl-C, Stop, a hook),
368
352
  # not an error's ✕ (timing.js STOP_MARK; the labels matrix pins it).
369
353
  STOP_MARK = "■"
@@ -25,6 +25,7 @@ require_relative "tools/memory"
25
25
  require_relative "output_formatter"
26
26
  require_relative "turn_flow"
27
27
  require_relative "session_commands"
28
+ require_relative "composer_route"
28
29
  require_relative "terminal_ui/attached_view"
29
30
  require_relative "terminal_ui/between_turns"
30
31
  require_relative "terminal_ui/event_renderer"
@@ -296,11 +297,11 @@ module Samagotchi
296
297
 
297
298
  if @prompt && @non_interactive
298
299
  # A /cut TEXT / /queue TEXT prompt runs its text as the turn's
299
- # prompt, the same as the idle REPL does (Formatting#steer_choice);
300
+ # prompt, the same as the idle REPL does (ComposerRoute.steer_choice);
300
301
  # a bare /cut / /queue (no text) is refused above as a UI command,
301
302
  # so only the text here. The images are the text's, not the words'.
302
303
  one_shot_prompt =
303
- if (choice = steer_choice(@prompt)) && !choice.last.empty?
304
+ if (choice = ComposerRoute.steer_choice(@prompt)) && !choice.last.empty?
304
305
  choice.last
305
306
  else
306
307
  @prompt
@@ -834,34 +835,35 @@ module Samagotchi
834
835
  return if input.empty?
835
836
 
836
837
  # /cut TEXT and /queue TEXT are deliveries only while a turn runs; at
837
- # the prompt both run the text as a normal turn, as their usage says.
838
- if (choice = steer_choice(input))
839
- return @surface.commit(STEER_CHOICE_USAGE) if choice.last.empty?
838
+ # the prompt both run the text as a normal turn, as their usage says
839
+ # (the route is the text's).
840
+ route = ComposerRoute.call(input, running: false, registry: command_registry)
841
+ return @surface.commit(STEER_CHOICE_USAGE) if route.kind == :usage
840
842
 
841
- input = choice.last
842
- end
843
+ input = route.text if route.choice
843
844
 
844
845
  # /model, /models, !rollback, !cmd, /continue (shared with workers);
845
846
  # an anytime command's cards print as it shows them (btw's
846
847
  # "thinking…" before the answer). Piped input has nobody to answer a
847
848
  # confirm (the next line is already written): a costly /thinking or
848
849
  # /reasoning-history applies at once.
849
- confirm = !piped_input?
850
- command = if command_registry.anytime?(input)
851
- @engine.running_anytime { @commands.run(input, confirm: confirm) }
852
- else
853
- @commands.run(input, confirm: confirm)
854
- end
855
- if command
856
- show_command_result(command)
857
- persist_recent_history(input) if command.shell
858
- return
859
- end
860
- return if show_local_command(input)
861
-
850
+ case route.kind
851
+ when :command
852
+ confirm = !piped_input?
853
+ command = if command_registry.anytime?(input)
854
+ @engine.running_anytime { @commands.run(input, confirm: confirm) }
855
+ else
856
+ @commands.run(input, confirm: confirm)
857
+ end
858
+ if command
859
+ show_command_result(command)
860
+ persist_recent_history(input) if command.shell
861
+ return
862
+ end
863
+ when :local then return if show_local_command(input)
862
864
  # A typo of a command (/modle) is not a prompt: say so, send nothing.
863
- hint = command_registry.unknown_command_hint(input)
864
- return @surface.commit(hint) if hint
865
+ when :typo then return @surface.commit(route.reply)
866
+ end
865
867
 
866
868
  @turn_flow.before_prompt_turn
867
869
  persist_recent_history(input)
@@ -1164,29 +1166,42 @@ module Samagotchi
1164
1166
  #
1165
1167
  # A command that waits for the turn's end (/model X, !cmd) and every line
1166
1168
  # after it wait in @after_turn, in the order typed (D1), with the image
1167
- # lines (which never steer): once the turn ends they come back after the
1168
- # steering it didn't merge (typed before them), ahead of anything sent
1169
- # after Ctrl-C.
1169
+ # lines (which never steer) and /queue's: once the turn ends they come
1170
+ # back with the steering it didn't merge, in the order sent (each line
1171
+ # the turn takes gets the next @input_seq), ahead of anything sent after
1172
+ # Ctrl-C.
1170
1173
  def with_steering(&)
1171
1174
  return yield unless @repl_input
1172
1175
 
1173
1176
  @after_turn = []
1174
1177
  @command_waits = false
1175
- @repl_input.during_turn(method(:steer_line), leftovers: -> { @pending_input_queue.drain + lines_after_turn }, &)
1178
+ @input_seq = 0
1179
+ @repl_input.during_turn(method(:steer_line), leftovers: -> { leftover_lines }, &)
1180
+ end
1181
+
1182
+ # The turn's leftovers in send order: steering it didn't merge and the
1183
+ # lines that waited for its end.
1184
+ def leftover_lines
1185
+ (@pending_input_queue.drain_entries + lines_after_turn).sort_by { |seq, _| seq || 0 }.map(&:last)
1176
1186
  end
1177
1187
 
1178
- # The lines that waited for the turn's end, taken from @after_turn. After
1179
- # a canceled or failed turn (Ctrl-C raises through here; a failure too)
1180
- # a !cmd among them is dropped (D4): running it would close the
1181
- # !rollback the turn left.
1188
+ # The next line's place in send order (#leftover_lines).
1189
+ def next_input_seq
1190
+ @input_seq = (@input_seq || 0) + 1
1191
+ end
1192
+
1193
+ # The [seq, line] entries that waited for the turn's end, taken from
1194
+ # @after_turn. After a canceled or failed turn (Ctrl-C raises through
1195
+ # here; a failure too) a !cmd among them is dropped (D4): running it
1196
+ # would close the !rollback the turn left.
1182
1197
  def lines_after_turn
1183
- lines = @after_turn.slice!(0..)
1198
+ entries = @after_turn.slice!(0..)
1184
1199
  ended = if @active_cancel_controller&.cancelled? || $!.is_a?(Interrupt) then "canceled"
1185
1200
  elsif $! then "failed"
1186
1201
  end
1187
- return lines unless ended
1202
+ return entries unless ended
1188
1203
 
1189
- lines.reject do |line|
1204
+ entries.reject do |_seq, line|
1190
1205
  next false unless command_registry.lookup(line)&.id == :shell
1191
1206
 
1192
1207
  @surface.commit("turn #{ended}: #{line.strip} not run; send it again")
@@ -1207,28 +1222,32 @@ module Samagotchi
1207
1222
  # to end and runs as a turn of its own.
1208
1223
  # @return [Boolean, :back] whether the turn took it, :back to put it back
1209
1224
  def steer_line(line)
1210
- local = local_command(line) unless line.nil?
1225
+ route = ComposerRoute.call(line, running: true, registry: command_registry) unless line.nil?
1226
+ local = route&.local
1211
1227
  return exit_after_turn(delete: SessionCommands.delete_on_exit?(line)) if line.nil? || local == :exit
1212
1228
  return archive_after_turn if local == :archive
1213
1229
  return detach_note if local == :detach
1214
1230
  return false if @active_cancel_controller&.cancelled?
1215
1231
 
1216
- # Before every command check: /cut and /queue are the UIs' own words
1217
- # for a delivery (they are listed for Tab and /help, never run).
1218
- if (choice = steer_choice(line))
1219
- return submit_steer_choice(*choice)
1232
+ case route.kind
1233
+ # Before every command check (ComposerRoute): /cut and /queue are the
1234
+ # UIs' own words for a delivery (listed for Tab and /help, never run).
1235
+ when :usage
1236
+ @surface.commit(STEER_CHOICE_USAGE)
1237
+ return true
1238
+ when :deliver then return submit_steer_choice(route.delivery, route.text)
1239
+ when :command
1240
+ policy = command_registry.mid_turn(line)
1241
+ return start_anytime_command(line) if policy == :anytime
1242
+ return command_queued(line) if policy == :queue
1220
1243
  end
1221
-
1222
- policy = command_registry.mid_turn(line)
1223
- return start_anytime_command(line) if policy == :anytime
1224
- return command_queued(line) if policy == :queue
1225
1244
  # A command, never steering text.
1226
- return command_during_turn(line) if local || command_registry.command?(line)
1227
- return true if line.strip.empty?
1245
+ return command_during_turn(line) if %i[local command].include?(route.kind)
1246
+ return true if route.kind == :empty
1228
1247
 
1229
1248
  # A typo of a command (/modle) is not steering text: say so, take it.
1230
- if (hint = command_registry.unknown_command_hint(line))
1231
- @surface.commit(hint)
1249
+ if route.kind == :typo
1250
+ @surface.commit(route.reply)
1232
1251
  return true
1233
1252
  end
1234
1253
  # Steering merges text only: a line with images runs as the next turn.
@@ -1236,7 +1255,7 @@ module Samagotchi
1236
1255
  # Behind a command that waits for the turn's end, a line waits too.
1237
1256
  return wait_for_turn_end(line) if @command_waits
1238
1257
 
1239
- @pending_input_queue.push(line.strip)
1258
+ @pending_input_queue.push(route.text, seq: next_input_seq)
1240
1259
  @surface.commit(NEXT_STEP_NOTE)
1241
1260
  true
1242
1261
  end
@@ -1245,25 +1264,21 @@ module Samagotchi
1245
1264
  # otherwise read as prompts (SessionCommands registers them for Tab and
1246
1265
  # help only). A cut asks the engine to cut the generation only thinking
1247
1266
  # too long; a queue waits for the turn's end (the REPL's own after-turn
1248
- # list). Empty text gets the usage line. A line with images never steers
1249
- # (as a plain one doesn't).
1267
+ # list). A line with images never steers (as a plain one doesn't).
1268
+ # @param delivery [String] Delivery::CUT or Delivery::QUEUE (ComposerRoute)
1250
1269
  # @return [Boolean] whether the turn took it
1251
- def submit_steer_choice(choice, text)
1252
- if text.empty?
1253
- @surface.commit(STEER_CHOICE_USAGE)
1254
- return true
1255
- end
1270
+ def submit_steer_choice(delivery, text)
1256
1271
  return image_line_waits(text) if ImageInput.extract(text).any?
1257
1272
 
1258
1273
  # A /queue waits for the turn's end; a /cut behind a command that
1259
1274
  # already waits there goes after it too.
1260
- if choice == :queue || @command_waits
1275
+ if Delivery.queue?(delivery) || @command_waits
1261
1276
  @surface.commit(COMMAND_QUEUED)
1262
1277
  return wait_for_turn_end(text)
1263
1278
  end
1264
1279
 
1265
1280
  epoch = @engine.input_epoch
1266
- @pending_input_queue.push(text)
1281
+ @pending_input_queue.push(text, seq: next_input_seq)
1267
1282
  # With no session running the cut is :off; the text still waits for
1268
1283
  # the running turn's next step.
1269
1284
  @surface.commit(steer_cut_note(@engine.cut_for_steer(nil, epoch: epoch), @engine.steer_cut_after))
@@ -1278,7 +1293,7 @@ module Samagotchi
1278
1293
  # +line+ runs once the turn has ended, in the order typed (#with_steering).
1279
1294
  # @return [true] the turn took it
1280
1295
  def wait_for_turn_end(line)
1281
- @after_turn << line
1296
+ @after_turn << [next_input_seq, line]
1282
1297
  true
1283
1298
  end
1284
1299
 
@@ -211,14 +211,14 @@ module Samagotchi
211
211
  # "stopped" for a
212
212
  # task_wait the user's Stop ended (the task itself runs on), for one
213
213
  # whose task was stopped (the stop-task button, or the model's task_stop),
214
- # and for a command the user's Stop (or a signal to chi) killed or kept
215
- # from starting: a cancel, not a failure (its text still starts "Error:"
216
- # for the model).
214
+ # and for a command the user's Stop (or a signal to chi, a session stop)
215
+ # killed or kept from starting: a cancel, not a failure (its text still
216
+ # starts "Error:" for the model).
217
217
  def tool_activity_status(result, tool_name = nil, command: nil)
218
218
  text = result.to_s
219
- return "stopped" if [Tools::Execute::NOT_RUN_ON_STOP, Tools::Execute::NOT_RUN_ON_SIGNAL].include?(text)
219
+ return "stopped" if [Tools::Execute::NOT_RUN_ON_STOP, *Tools::Execute::NOT_RUN.values].include?(text)
220
220
  if tool_name == Tools::Execute::NAME &&
221
- text.start_with?(Tools::Execute::STOPPED_BY_USER, Tools::Execute::STOPPED_BY_SIGNAL)
221
+ text.start_with?(Tools::Execute::STOPPED_BY_USER, *Tools::Execute::STOPPED_BY.values)
222
222
  return "stopped"
223
223
  end
224
224
  return "error" if text.start_with?("Error:")
@@ -203,7 +203,9 @@ module Samagotchi
203
203
  delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{ClientId::DELEGATE_PREFIX}#{parent.id[0, 8]}",
204
204
  state_dir: state_dir)
205
205
  case delivered[:status]
206
- when :accepted then id
206
+ when :accepted
207
+ DelegateWait.mark_turn(parent.id, id, follow_up_turn(id, delivered[:ack], state_dir: state_dir), state_dir: state_dir)
208
+ id
207
209
  when :refused then "Error: session #{id[0, 8]} refused the message (#{delivered.dig(:ack, "error")})"
208
210
  when :timeout then "Error: the worker of session #{id[0, 8]} did not answer in time, so the message was not sent"
209
211
  else "Error: the message to session #{id[0, 8]} could not be written"
@@ -215,6 +217,31 @@ module Samagotchi
215
217
  end
216
218
  private_class_method :follow_up
217
219
 
220
+ # The child's turn a delivered follow-up joins, for the wait
221
+ # (DelegateCursor#turn): the running turn's (its checkpoint mark), the
222
+ # follow-up merging into it; else the ack's enqueued_id, the follow-up
223
+ # running as a turn of its own. Only from a worker that names
224
+ # turn_ids; nil otherwise, or when the running turn has no id (a
225
+ # delegate's first turn): the wait takes the next reply.
226
+ # A follow-up steered into a turn that ended before it merged runs as
227
+ # its own turn, yet the wait is on the ended one (LOW, as before).
228
+ # One the child's idle look missed being merged into a turn starting
229
+ # then never runs as its own: ReplyWait falls back to that turn's
230
+ # reply (merged_elsewhere?).
231
+ # @return [String, nil]
232
+ def self.follow_up_turn(id, ack, state_dir:)
233
+ return nil unless ReplyWait.turn_ids?(Session.session_dir(id, state_dir: state_dir))
234
+
235
+ child = Session.load(id, state_dir: state_dir)
236
+ return child.interrupted_turn&.enqueued_id if child.status == Session::STATUS_RUNNING
237
+
238
+ enqueued_id = ack.is_a?(Hash) ? (ack["enqueued_id"] || ack[:enqueued_id]) : nil
239
+ enqueued_id.to_s.empty? ? nil : enqueued_id.to_s
240
+ rescue ArgumentError
241
+ nil
242
+ end
243
+ private_class_method :follow_up_turn
244
+
218
245
  # The model as typed (spawn_session stores its resolved ref), else the
219
246
  # parent's. No existence check here: spawn_session checks the host
220
247
  # (check_host!, its UnknownHost comes back as this tool's Error: line)