samagotchi 0.124.0 → 0.126.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -1
  3. data/docs/architecture.md +4 -2
  4. data/docs/cli.md +20 -8
  5. data/docs/guardrails.md +22 -0
  6. data/docs/internals/participants.md +96 -3
  7. data/docs/internals/remote-sessions.md +2 -0
  8. data/docs/internals/web-push.md +10 -6
  9. data/docs/sessions.md +5 -2
  10. data/lib/samagotchi/bridge.rb +4 -3
  11. data/lib/samagotchi/bridge_client.rb +8 -1
  12. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +25 -4
  13. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/child_reports.rb +3 -0
  16. data/lib/samagotchi/children_status.rb +6 -1
  17. data/lib/samagotchi/engine.rb +43 -7
  18. data/lib/samagotchi/guardrail_wiring.rb +10 -1
  19. data/lib/samagotchi/llm/chat_loop.rb +1 -0
  20. data/lib/samagotchi/process_group.rb +10 -1
  21. data/lib/samagotchi/reminder_carry.rb +92 -0
  22. data/lib/samagotchi/reminder_store.rb +49 -0
  23. data/lib/samagotchi/session.rb +40 -3
  24. data/lib/samagotchi/session_inbox.rb +14 -8
  25. data/lib/samagotchi/session_manager.rb +39 -8
  26. data/lib/samagotchi/session_participants.rb +10 -2
  27. data/lib/samagotchi/sessions_command.rb +1 -1
  28. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +225 -0
  29. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +138 -0
  30. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +225 -0
  31. data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +154 -0
  32. data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +302 -0
  33. data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -1253
  34. data/lib/samagotchi/terminal_ui/exit_lines.rb +141 -0
  35. data/lib/samagotchi/terminal_ui/join_replay.rb +235 -0
  36. data/lib/samagotchi/verdict.rb +20 -0
  37. data/lib/samagotchi/verify_anchor.rb +28 -0
  38. data/lib/samagotchi/verify_fresh.rb +275 -0
  39. data/lib/samagotchi/verify_fresh_origin.rb +71 -0
  40. data/lib/samagotchi/version.rb +1 -1
  41. data/lib/samagotchi/web/app.rb +10 -3
  42. data/lib/samagotchi/web/participant_access.rb +15 -4
  43. data/lib/samagotchi/web/participant_review.rb +7 -1
  44. data/lib/samagotchi/web/participant_view.rb +46 -5
  45. data/lib/samagotchi/web/participants_admin.rb +15 -4
  46. data/lib/samagotchi/web/public/annotate_bar.css +8 -4
  47. data/lib/samagotchi/web/public/annotate_bar.js +43 -7
  48. data/lib/samagotchi/web/public/app.js +134 -8
  49. data/lib/samagotchi/web/public/claim_link.js +347 -0
  50. data/lib/samagotchi/web/public/data.js +2 -1
  51. data/lib/samagotchi/web/public/index.html +1 -0
  52. data/lib/samagotchi/web/public/notify.js +13 -2
  53. data/lib/samagotchi/web/public/participant.html +3 -1
  54. data/lib/samagotchi/web/public/participant.js +113 -9
  55. data/lib/samagotchi/web/public/participants_panel.js +15 -0
  56. data/lib/samagotchi/web/public/route.js +3 -2
  57. data/lib/samagotchi/web/public/send_turn.js +6 -1
  58. data/lib/samagotchi/web/public/session_state.js +5 -0
  59. data/lib/samagotchi/web/public/stream_handlers.js +3 -0
  60. data/lib/samagotchi/web/public/tokens.css +2 -1
  61. data/lib/samagotchi/web/public/verdict_card.js +65 -0
  62. data/lib/samagotchi/web/public/verdict_fresh.js +120 -0
  63. data/lib/samagotchi/web/public/verify_card.css +17 -0
  64. data/lib/samagotchi/web/public/verify_preset.js +18 -0
  65. data/lib/samagotchi/web/push/attention.rb +25 -5
  66. data/lib/samagotchi/web/push/notifier.rb +4 -3
  67. data/lib/samagotchi/web/session_control.rb +2 -0
  68. data/lib/samagotchi/web/session_summary.rb +25 -1
  69. data/lib/samagotchi/web/verify_fresh_routes.rb +110 -0
  70. data/lib/samagotchi/worker.rb +125 -12
  71. data/lib/samagotchi/worker_idle_exit.rb +7 -6
  72. data/lib/samagotchi/worker_inbound.rb +4 -4
  73. metadata +19 -1
@@ -13,6 +13,13 @@ require_relative "input_support"
13
13
  require_relative "image_input"
14
14
  require_relative "line_reader"
15
15
  require_relative "question_prompt"
16
+ require_relative "join_replay"
17
+ require_relative "exit_lines"
18
+ require_relative "attached_loop/exit_commands"
19
+ require_relative "attached_loop/questions"
20
+ require_relative "attached_loop/queueing"
21
+ require_relative "attached_loop/composer"
22
+ require_relative "attached_loop/sending"
16
23
  require_relative "reline_seam"
17
24
  require_relative "version_lines"
18
25
  require_relative "gap_clock"
@@ -48,49 +55,16 @@ module Samagotchi
48
55
  class AttachedLoop
49
56
  include Formatting
50
57
  include InputSupport
58
+ include ExitCommands
59
+ include Questions
60
+ include Queueing
61
+ include Composer
62
+ include Sending
51
63
 
52
64
  PROMPT = "> "
53
65
  # A second Ctrl-C at an empty idle prompt within this many seconds detaches.
54
66
  DETACH_WINDOW = 2.0
55
67
  DETACH_HINT = "Ctrl-D to detach, /exit stops the worker"
56
- # Why a request the Bridge read after its deadline didn't go through.
57
- LATE = "worker not answering in time"
58
- # An exit the worker agreed to in an empty session. Said before it has
59
- # left: it checks again once it lets go, and a note or prompt arriving
60
- # first keeps the session (SessionManager.discard_left_session).
61
- EMPTY_WORDS = "the session is empty: it will be discarded if nothing arrives before its worker stops."
62
- EMPTY_EXIT = "Detached; #{EMPTY_WORDS}"
63
- # Why the worker stayed up after /exit, by the reason it gave.
64
- HELD_REASONS = {
65
- "turn_running" => "a turn is running", "input_queued" => "prompts are queued",
66
- "continue_offered" => "a continue offer is pending", "client_connected" => "another UI is attached",
67
- "reminders" => "reminders are set", "starting" => "the worker is still starting",
68
- "background_tasks" => "background tasks it started are still running",
69
- "delegates" => "delegates of this session are still running"
70
- }.freeze
71
- # How to end a background task that holds the worker up (an older
72
- # worker's 409 has no hint of its own).
73
- TASK_STOP_HINT = "stop one with the web's stop-task button or by asking for task_stop"
74
- # The /exit ask, when every hold is one the user can stop: what still
75
- # runs, then the three answers. Each kind, singular and plural.
76
- EXIT_HOLD_WORDS = {
77
- "reminders" => %w[reminder reminders],
78
- "background_tasks" => ["background task", "background tasks"],
79
- "delegates" => %w[delegate delegates]
80
- }.freeze
81
- # The 200's +stopped+ keys, by the kind the ask lists them under.
82
- STOPPED_KEYS = { "reminders" => "reminders", "tasks" => "background_tasks", "delegates" => "delegates" }.freeze
83
- EXIT_ASK_OPTIONS = ["Keep running in the background (detach)", "Stop them and exit", "Back to the session"].freeze
84
- # How the ask reads an answer.
85
- EXIT_ANSWERS = {
86
- "1" => :keep, "keep" => :keep, "detach" => :keep, "background" => :keep,
87
- "2" => :stop, "stop" => :stop, "exit" => :stop,
88
- "3" => :back, "back" => :back, "stay" => :back
89
- }.freeze
90
- # How much of the last answer a join shows.
91
- JOIN_ANSWER_LINES = 12
92
- JOIN_ANSWER_CHARS = 1200
93
-
94
68
  attr_reader :client_id
95
69
 
96
70
  # @return [String, nil] the model the worker's turns run on, as the
@@ -175,6 +149,7 @@ module Samagotchi
175
149
  @clock = clock
176
150
  # The session's last activity, for the gap dividers (GapClock).
177
151
  @gaps = GapClock.new(now: now)
152
+ @join_replay = JoinReplay.new(screen: @screen, view: @view, link: @link, gaps: @gaps, renderer: @renderer)
178
153
  @last_idle_interrupt_at = nil
179
154
  @next_input_prefill = nil
180
155
  # Prompts this run sent, by enqueued_id: only those come back into
@@ -409,19 +384,6 @@ module Samagotchi
409
384
  @screen.commit(gap_divider(line)) if line
410
385
  end
411
386
 
412
- # The divider above a typed line's echo (RelineSeam.echo_above, on the
413
- # reader thread): a prompt typed after a quiet gap, at an idle prompt.
414
- # It marks the activity, so the turn's start marks no second one.
415
- def gap_above_echo(text)
416
- return nil if @running
417
-
418
- route = ComposerRoute.call(text.to_s.strip, running: false, registry: command_registry)
419
- return nil unless %i[prompt deliver].include?(route.kind)
420
-
421
- line = @gaps.divider
422
- line && gap_divider(line)
423
- end
424
-
425
387
  # The newest chi installed, looked at once, at the join.
426
388
  def installed_version
427
389
  @link.installed_version
@@ -493,387 +455,6 @@ module Samagotchi
493
455
  nil
494
456
  end
495
457
 
496
- # @return [Symbol, nil] :detach to end the loop
497
- def submit(line)
498
- if @exit_ask
499
- # The exit ask is open: this line answers it. Ctrl-D and /detach
500
- # detach without stopping anything, the way its 1 does.
501
- return detach(kept_line(@exit_ask)) if line.nil? || command_registry.lookup_local(line.strip)&.id == :detach
502
-
503
- return answer_exit_ask(line)
504
- end
505
-
506
- return detach("Detached; the session keeps running. Re-attach with: #{via.attach(@client.session_id)}") if line.nil?
507
-
508
- text = line.strip
509
- if @question
510
- # /detach leaves the question open, as Ctrl-D does; any other
511
- # line (/exit too) is read as the answer.
512
- return submit(nil) if command_registry.lookup_local(text)&.id == :detach
513
-
514
- return answer_question(text)
515
- end
516
-
517
- text, kept = split_prefilled(text)
518
- submitted = submit_line(text)
519
- # The removed text the command was typed after, back in the input
520
- # (unless an /unqueue put its own there, or the loop ends). Set
521
- # before the read restarts with it: the read's echo reads it.
522
- if kept && submitted != :detach && @prefilled.nil?
523
- @prefilled = kept
524
- @prefilled = nil unless @reader&.prefill(kept)
525
- end
526
- submitted
527
- end
528
-
529
- # A line typed after an /unqueue's text (#unqueued) without clearing
530
- # it, the text untouched and then a command ("fix it/unqueue"): the
531
- # command alone, and the text to put back. Any other line as is.
532
- # @return [Array(String, String|nil)]
533
- def split_prefilled(text)
534
- kept = @prefilled
535
- @prefilled = nil
536
- rest = command_after(text, kept)
537
- rest ? [rest, kept] : [text, nil]
538
- end
539
-
540
- # The command typed after +kept+ (/unqueue's text) in +text+, or nil.
541
- # @return [String, nil]
542
- def command_after(text, kept)
543
- return nil unless kept && text.length > kept.length && text.start_with?(kept)
544
-
545
- rest = text[kept.length..].strip
546
- route = ComposerRoute.call(rest, running: @running, registry: command_registry)
547
- rest if route.choice || %i[local command usage].include?(route.kind)
548
- end
549
-
550
- # The read's echo (RelineSeam.echo_as, on the reader thread): the
551
- # command alone when one is typed after /unqueue's text, as
552
- # #split_prefilled runs it; nil echoes the line as typed.
553
- def prefilled_echo(typed) = command_after(typed.strip, @prefilled)
554
-
555
- # @return [Symbol, nil] :detach to end the loop
556
- def submit_line(text)
557
- # /cut TEXT and /queue TEXT are this UI's own deliveries, read before
558
- # every command check (ComposerRoute; SessionCommands registers them
559
- # for Tab and /help only). A plain line sends no delivery: it goes in
560
- # at the running turn's next step, and never cuts.
561
- route = ComposerRoute.call(text, running: @running, registry: command_registry)
562
- return submit_delivery(route) if route.choice
563
-
564
- # The terminal's own commands (SessionCommands registers them, the
565
- # REPL reads the same words). Before the continue offer: the
566
- # leaving ones are no answer to it.
567
- local = route.local
568
- return submit(nil) if local == :detach
569
- return SessionCommands.delete_on_exit?(text) ? exit_and_delete : exit_worker if local == :exit
570
- return archive_after_turn if local == :archive && (@running || !@queued_ids.empty?)
571
- return exit_and_archive if local == :archive
572
-
573
- if @continue_offer
574
- return answer_continue(text) unless local || route.kind == :command
575
-
576
- # Not an answer: the read left no echo, so show what ran.
577
- echo_answer(text)
578
- end
579
- return if route.kind == :empty
580
-
581
- if local == :stats
582
- show_stats
583
- elsif local == :recap
584
- show_recap
585
- elsif local == :unqueue
586
- unqueue_newest
587
- elsif %i[share participants].include?(local)
588
- @screen.commit(participant_commands.run(text))
589
- elsif route.kind == :command
590
- # The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
591
- persist_recent_history(text) if PromptHistory.shell_line?(text)
592
- send_command(text)
593
- elsif route.kind == :typo
594
- # A typo of a command (/modle) is not a prompt: say so, send nothing.
595
- @screen.commit(route.reply)
596
- else
597
- send_prompt(text)
598
- end
599
- nil
600
- end
601
-
602
- # /cut TEXT and /queue TEXT (+route+ has a choice). During a turn the
603
- # text goes with its delivery: a cut, or queued to run as a turn of
604
- # its own after this one. A worker that doesn't queue (its sidecar
605
- # names no "delivery" feature) would steer a queue, so it is not sent.
606
- # At the prompt both send the text as typed, a normal turn: no local
607
- # word or typo check (the worker's Bridge runs a session command as
608
- # the command).
609
- # @param route [ComposerRoute::Route]
610
- # @return [nil]
611
- def submit_delivery(route)
612
- return @screen.commit(STEER_CHOICE_USAGE) if route.kind == :usage
613
- return @screen.commit(queue_needs_newer_worker) if Delivery.queue?(route.delivery) && !worker_queues?
614
-
615
- # To an idle session it is an ordinary prompt: no delivery to send,
616
- # and its ack says it was sent now (#sent_note).
617
- send_prompt(route.text, delivery: route.delivery, choice: route.choice)
618
- nil
619
- end
620
-
621
- # Whether the attached worker runs delivery: queue (its sidecar names
622
- # Delivery::QUEUE_FEATURE). No sidecar to read: true, its Bridge
623
- # answers (a P0-P2 worker refuses a queue: #send_prompt).
624
- def worker_queues? = @link.queues?
625
-
626
- # @return [Symbol] :detach
627
- def detach(line)
628
- @view.finish_thinking_spinner
629
- @screen.commit(line)
630
- :detach
631
- end
632
-
633
- # Detach, and ask the worker to exit now. The worker decides: it stays
634
- # up while anything still needs it (a turn, queued input, a continue
635
- # offer, another UI, reminders), and the line says which. When the only
636
- # holds are ones the user can stop, it asks what to do with them instead
637
- # of detaching (#answer_exit_ask).
638
- # @return [Symbol, nil] :detach, or nil to stay at the prompt
639
- def exit_worker
640
- reply = @client.request_exit(client_id: @client_id)
641
- holds = reply.status == 409 ? reply.json&.fetch("holds", nil) : nil
642
- return detach(exit_line(reply)) unless holds.is_a?(Array) && !holds.empty?
643
-
644
- @exit_ask = holds
645
- @screen.commit(ask_lines(holds))
646
- nil
647
- rescue SystemCallError, IOError => e
648
- detach(exit_failed_line(e.message))
649
- end
650
-
651
- # The ask: what still runs, then the three answers.
652
- def ask_lines(holds)
653
- lines = holds.map do |hold|
654
- items = Array(hold["items"])
655
- singular, plural = hold_words(hold["kind"])
656
- " • #{items.size} #{items.size == 1 ? singular : plural}: #{items.map { |item| item_word(item, hold["kind"]) }.join(", ")}"
657
- end
658
- lines += EXIT_ASK_OPTIONS.each_with_index.map { |option, index| " #{index + 1}. #{option}" }
659
- (["This session still has:"] + lines).join("\n")
660
- end
661
-
662
- # One entry of a hold: the command or name, with its detail in
663
- # brackets; a delegate leads with its short id and its title in quotes.
664
- def item_word(item, kind)
665
- label = item["label"].to_s
666
- label = "#{item["id"].to_s[0, 8]} \"#{label}\"" if kind == "delegates"
667
- detail = item["detail"].to_s
668
- detail.empty? ? label : "#{label} (#{detail})"
669
- end
670
-
671
- # The answer to the exit ask.
672
- # @return [Symbol, nil] :detach for 1 and 2, nil for 3 or a line the
673
- # ask doesn't understand
674
- def answer_exit_ask(line)
675
- choice = EXIT_ANSWERS[line.to_s.strip.downcase]
676
- unless choice
677
- @screen.commit("Say 1 (keep running), 2 (stop them and exit) or 3 (back to the session).")
678
- return nil
679
- end
680
-
681
- holds = @exit_ask
682
- @exit_ask = nil
683
- case choice
684
- when :keep then detach(kept_line(holds))
685
- when :stop then stop_and_exit
686
- else
687
- @screen.commit("(nothing stopped; the worker keeps running)")
688
- nil
689
- end
690
- end
691
-
692
- # 1: detach, everything left running.
693
- def kept_line(holds)
694
- "Detached; the session keeps running (#{hold_words_list(holds)}). " \
695
- "Re-attach with: #{via.attach(@client.session_id)}"
696
- end
697
-
698
- # 2: ask the worker to stop them all and exit.
699
- # @return [Symbol] :detach
700
- def stop_and_exit
701
- reply = @client.request_exit(client_id: @client_id, stop: true)
702
- detach(exit_line(reply))
703
- rescue SystemCallError, IOError => e
704
- detach(exit_failed_line(e.message))
705
- end
706
-
707
- # "2 reminders, 1 background task, 1 delegate" from the ask's holds.
708
- def hold_words_list(holds)
709
- holds.map do |hold|
710
- items = Array(hold["items"])
711
- singular, plural = hold_words(hold["kind"])
712
- "#{items.size} #{items.size == 1 ? singular : plural}"
713
- end.join(", ")
714
- end
715
-
716
- def hold_words(kind) = EXIT_HOLD_WORDS.fetch(kind.to_s, [kind.to_s, kind.to_s])
717
-
718
- # What a stop-all exit stopped: "stopped 2 reminders (api_health, git
719
- # push), 1 background task (rails s)"; nil when it stopped nothing.
720
- def stopped_words(stopped)
721
- return nil unless stopped.is_a?(Hash)
722
-
723
- parts = STOPPED_KEYS.filter_map do |key, kind|
724
- names = Array(stopped[key])
725
- next if names.empty?
726
-
727
- singular, plural = hold_words(kind)
728
- "#{names.size} #{names.size == 1 ? singular : plural} (#{names.join(", ")})"
729
- end
730
- parts.empty? ? nil : "stopped #{parts.join(", ")}"
731
- end
732
-
733
- # /exit --delete: the worker exits as for /exit, then the session is
734
- # deleted. When the worker stays up (another UI, queued input, ...),
735
- # nothing is deleted.
736
- def exit_and_delete
737
- reply = @client.request_exit(client_id: @client_id, delete: true)
738
- id = @client.session_id
739
- unless reply.status == 200
740
- line = exit_line(reply)
741
- return detach(line.sub("Detached; the session keeps running", "Detached; not deleted: the session keeps running")) if reply.status == 409
742
-
743
- return detach("#{line} Not deleted.")
744
- end
745
- # An empty session: the worker deletes it as it leaves.
746
- return detach(EMPTY_EXIT) if discards?(reply)
747
-
748
- @link.delete_session
749
- detach("Detached; deleted session #{id}.")
750
- rescue SystemCallError, IOError => e
751
- detach("#{exit_failed_line(e.message)} Not deleted.")
752
- rescue SessionManager::DeleteRefused, SessionManager::OwnedByTUI, ArgumentError => e
753
- detach("Detached; the worker is stopping, but session #{id} was not deleted (#{e.message}): " \
754
- "#{via.command("sessions delete #{id}")}")
755
- end
756
-
757
- # /archive during a turn (D2): it waits for the turn's end and the
758
- # commands queued behind it (#archive_due?), then runs.
759
- # @return [nil]
760
- def archive_after_turn
761
- @archive_after_turn = true
762
- @screen.commit("(archives after this turn)")
763
- nil
764
- end
765
-
766
- def archive_due? = @archive_after_turn && !@running && @queued_ids.empty?
767
-
768
- # @return [Symbol] :detached
769
- def archive_now
770
- @archive_after_turn = false
771
- exit_and_archive
772
- :detached
773
- end
774
-
775
- # /archive: the worker is asked to exit as for /exit, then the session
776
- # is archived. A worker staying up for another UI (or reminders) is
777
- # stopped by the archive, as the web's archive stops it; a turn
778
- # running, a queued prompt or an open continue offer refuses it
779
- # (SessionManager.archive_session). An empty session: the worker
780
- # deletes it as it leaves.
781
- def exit_and_archive
782
- # /archive can't ask about what still runs: a child the archive will
783
- # stop is not worth a question here.
784
- reply = @client.request_exit(client_id: @client_id, ask: false)
785
- id = @client.session_id
786
- return detach(EMPTY_EXIT) if reply.status == 200 && discards?(reply)
787
- return detach("#{exit_line(reply)} Not archived.") unless [200, 409].include?(reply.status)
788
-
789
- result = @link.archive_session
790
- return detach("Detached; the session was empty, so it is discarded.") if result[:discarded].include?(id)
791
-
792
- detach("Detached; archived session #{id}. #{via.command("sessions list --archived")} finds it.")
793
- rescue SystemCallError, IOError => e
794
- detach("#{exit_failed_line(e.message)} Not archived.")
795
- rescue SessionManager::ArchiveRefused, SessionManager::OwnedByTUI, ArgumentError => e
796
- detach("Detached; session #{id} was not archived (#{e.message}). Re-attach with: #{via.attach(id)}")
797
- end
798
-
799
- def exit_line(reply)
800
- id = @client.session_id
801
- error = reply.json&.fetch("error", nil)
802
- case reply.status
803
- when 200
804
- stopped = stopped_words(reply.json&.fetch("stopped", nil))
805
- return EMPTY_EXIT if discards?(reply) && stopped.nil?
806
- return "Detached; #{stopped}. #{EMPTY_WORDS.capitalize}" if discards?(reply)
807
-
808
- "Detached; #{stopped || "the session's worker is stopping"}. Resume with: #{via.resume(id)}"
809
- when 409
810
- reason = reply.json&.fetch("reason", nil).to_s
811
- "Detached; the session keeps running (#{held_words(reason, reply.json&.fetch("tasks", nil))}). " \
812
- "Re-attach with: #{via.attach(id)}"
813
- when 0 then exit_failed_line("no reply")
814
- when 408 then exit_failed_line(LATE)
815
- else
816
- # A worker from before the route answers the Bridge's plain 404.
817
- if error == "not_found"
818
- return "Detached; this worker runs an older chi and can't be stopped from here: #{via.command("sessions stop #{id}")}"
819
- end
820
-
821
- exit_failed_line([reply.status, reply.json&.fetch("detail", nil) || error].compact.join(" "))
822
- end
823
- end
824
-
825
- # Why the worker stayed up; the background tasks it waits for by
826
- # their short id and command (a finish wake's, D3), and how to stop one.
827
- def held_words(reason, tasks)
828
- words = HELD_REASONS.fetch(reason, reason)
829
- listed = Array(tasks).filter_map { |t| "#{Tools::TaskRecord.short_id(t["id"])} (#{t["command"]})" if t.is_a?(Hash) }
830
- listed.empty? ? words : "#{words}: #{listed.join(", ")}; #{TASK_STOP_HINT}"
831
- end
832
-
833
- # An older worker leaves the field out.
834
- def discards?(reply) = reply.json&.fetch("discard", nil) == true
835
-
836
- def exit_failed_line(why)
837
- "Detached (could not ask the worker to stop: #{why}). Re-attach with: #{via.attach(@client.session_id)}"
838
- end
839
-
840
- # The worker says what became of it (:continue_resolved); an invalid
841
- # answer gets its error and the offer stays.
842
- def answer_continue(text)
843
- echo_answer(text) if TurnFlow.continue_decision(text).first == :invalid
844
- @continue_answer = text.empty? ? "yes" : text
845
- send_command(continue_line(text))
846
- nil
847
- end
848
-
849
- # A bare answer at the continue prompt, as the REPL reads it (an empty
850
- # one is yes).
851
- def continue_line(text)
852
- text.empty? ? SessionCommands::CONTINUE_COMMAND : "#{SessionCommands::CONTINUE_COMMAND} #{text}"
853
- end
854
-
855
- # The worker runs it and every UI renders its :command_ran, this one
856
- # too, so nothing waits here.
857
- def send_command(line)
858
- reply = @client.post_command(line: line, client_id: client_id_for(line))
859
- if reply.status == 202
860
- # Input from a pipe ends once its output is in (#command_ran).
861
- command_id = reply.json&.fetch("command_id", nil)
862
- @open_ids << command_id if command_id && @wait_at_eof
863
- return
864
- end
865
- return @screen.commit(stale_worker("run commands")) if reply.status == 404
866
-
867
- name = older_worker_command(reply, line)
868
- return @screen.commit(stale_worker("run #{name}")) if name
869
- return @screen.commit("could not run the command (#{LATE})") if too_late?(reply)
870
-
871
- detail = reply.json&.fetch("detail", nil) || reply.json&.fetch("error", nil)
872
- @screen.commit("could not run the command (#{[reply.status, detail].compact.join(" ")})")
873
- rescue SystemCallError, IOError => e
874
- @screen.commit("could not run the command (#{worker_down(e)})")
875
- end
876
-
877
458
  # A request the Bridge read after its deadline and dropped
878
459
  # (BridgeClient::DEADLINE_SHARE): it didn't go through.
879
460
  def too_late?(reply) = reply.status == 408 && reply.json&.fetch("error", nil) == "deadline_passed"
@@ -890,316 +471,6 @@ module Samagotchi
890
471
  "worker unreachable: #{head.downcase}"
891
472
  end
892
473
 
893
- QUEUED_NOTE = "(queued: runs after this turn)"
894
- QUEUE_RUNS_NOW = "(the turn had ended: runs as the next turn)"
895
- NOTHING_QUEUED = "(nothing queued: /queue TEXT queues a message for after the running turn)"
896
- UNQUEUE_REFUSED = { "already_started" => "(not removed: it already started)",
897
- "unknown_enqueued_id" => "(not removed: it is already gone)",
898
- "not_queued" => "(not removed: it goes in at the next step)" }.freeze
899
-
900
- # /queue to a worker from before queueing, which would steer it.
901
- def queue_needs_newer_worker
902
- "(/queue needs a newer worker: restart it with #{via.command("sessions restart")}, then send again)"
903
- end
904
-
905
- def unqueue_needs_newer_worker = "(/unqueue needs a newer worker: restart it with #{via.command("sessions restart")})"
906
-
907
- # /unqueue: take back the newest prompt this run queued for after the
908
- # turn, before it runs (POST turn/dequeue). Its :turn_dequeued comes
909
- # to every UI; here the reply says what became of it. A worker whose
910
- # sidecar lacks "unqueue" is not asked.
911
- def unqueue_newest
912
- return @screen.commit(unqueue_needs_newer_worker) unless worker_unqueues?
913
-
914
- id = @my_queued.last
915
- return @screen.commit(NOTHING_QUEUED) unless id
916
-
917
- reply = @client.dequeue(enqueued_id: id, client_id: @client_id)
918
- error = reply.json&.fetch("error", nil)
919
- return @screen.commit(unqueue_needs_newer_worker) if reply.status == 404 && error == "not_found"
920
-
921
- # Removed, started or gone: no longer this run's to take back.
922
- @my_queued.delete(id) if [200, 404, 409].include?(reply.status)
923
- return unqueued(reply.json&.fetch("prompt", nil).to_s) if reply.status == 200
924
-
925
- @screen.commit(UNQUEUE_REFUSED[error] || "could not remove the queued message (#{[reply.status, error].compact.join(" ")})")
926
- rescue SystemCallError, IOError => e
927
- @screen.commit("could not remove the queued message (#{worker_down(e)})")
928
- end
929
-
930
- # The removal's line, and its text back in the input to change and
931
- # send again (as a failed turn's prompt comes back, #restore_prompt):
932
- # not over typed text, and not for lines from a pipe. Its images are
933
- # not put back.
934
- def unqueued(prompt)
935
- @screen.commit("removed: #{first_line_clip(prompt)}")
936
- return if @wait_at_eof || prompt.strip.empty?
937
-
938
- # Set before the read restarts with it: the read's echo reads it.
939
- @prefilled = prompt.strip
940
- return if @reader&.prefill(prompt)
941
-
942
- @prefilled = nil
943
- @screen.commit("(the removed message is in the input history: ↑)")
944
- end
945
-
946
- def first_line_clip(text, max = 60)
947
- line = text.to_s.strip.lines.first.to_s.strip
948
- line.length > max ? "#{line[0, max]}…" : line
949
- end
950
-
951
- # Whether the attached worker takes queued messages back (its sidecar
952
- # names SessionInbox::UNQUEUE_FEATURE). No sidecar to read: true, its Bridge
953
- # answers (an older one's 404 not_found says so).
954
- def worker_unqueues? = @link.unqueues?
955
-
956
- # A command queued for after the running turn (command_queued with
957
- # waits): its line now (another client's) and a note; its command_ran
958
- # has no line again (queued: true).
959
- def command_waits(event)
960
- @queued_ids << event[:command_id]
961
- if own?(event[:client_id])
962
- first = event[:command_id] == @first_command_id
963
- return @screen.commit(first ? first_command_queued : QUEUED_NOTE)
964
- end
965
-
966
- @screen.commit("#{prompt_line(event[:client_id], event[:line])} #{QUEUED_NOTE}")
967
- end
968
-
969
- def first_command_queued
970
- "(#{first_command_flag} queued: applies after the running turn)"
971
- end
972
-
973
- def command_ran(event)
974
- @open_ids.delete(event[:command_id])
975
- @queued_ids.delete(event[:command_id])
976
- unless own?(event[:client_id]) || event[:anytime] || event[:card] || event[:queued]
977
- @screen.commit(prompt_line(event[:client_id], event[:line]))
978
- end
979
- output = event[:output].to_s
980
- if output.empty?
981
- nil
982
- elsif %w[busy dropped].include?(event[:status]) || PromptHistory.shell_line?(event[:line])
983
- @screen.commit(output)
984
- put_back_busy_command(event) if event[:status] == "busy"
985
- else
986
- @screen.commit("#{paint("model>", 36)} #{output}")
987
- end
988
- prefill_confirm(event)
989
- # /title from any client: the row's title follows.
990
- @status.update(title: event[:title]) if Array(event[:changed]).include?("title")
991
- return unless event[:model_name]
992
-
993
- # After the output, so the status row changes with the line saying why.
994
- served = @status[:model] == event[:model_name] ? @status[:served] : nil
995
- @status.update(model: event[:model_name], default_model: default_model_name, served: served)
996
- end
997
-
998
- # Our change that asks first (CacheConfirm) puts its --yes line in the
999
- # input: Enter applies it. Another client's question is its own.
1000
- def prefill_confirm(event)
1001
- return unless own?(event[:client_id])
1002
-
1003
- confirm = event[:confirm]
1004
- line = confirm.is_a?(Hash) ? confirm[:line] || confirm["line"] : nil
1005
- @reader&.prefill(line.to_s) if line
1006
- end
1007
-
1008
- # Our command the worker couldn't run yet goes back into the prompt, for
1009
- # Enter once the turn ends (not the launch's --model: that ends the launch).
1010
- def put_back_busy_command(event)
1011
- return unless own?(event[:client_id]) && event[:command_id] != @first_command_id
1012
-
1013
- @screen.commit("(the command is in the input history: ↑)") unless @reader&.prefill(event[:line].to_s)
1014
- end
1015
-
1016
- def offer_continue(event)
1017
- @continue_offer = { context: event[:context], no_interrupt: event[:no_interrupt] }
1018
- sync_prompt
1019
- sync_continue_slot
1020
- end
1021
-
1022
- CONTINUE_DECISIONS = { "resume" => "yes", "abort" => "no", "abort_with_reason" => "no, with a reason",
1023
- "refused" => "not continued: the participant may no longer run turns" }.freeze
1024
-
1025
- # The offer's choices go; one line says what became of it. Another
1026
- # UI's answer shows as its command line (web> /continue yes) instead.
1027
- def continue_resolved(event)
1028
- @continue_offer = nil
1029
- # Asked as the step-limit question: its line says what became of it.
1030
- asked = @offer_asked
1031
- @offer_asked = false
1032
- sync_continue_slot
1033
- who = client_label(event[:client_id], "another UI")
1034
- outcome = if event[:decision] == "dropped"
1035
- "(dropped: #{own?(event[:client_id]) ? "you" : who} sent a new prompt)"
1036
- elsif event[:decision] == "refused"
1037
- CONTINUE_DECISIONS["refused"]
1038
- elsif own?(event[:client_id])
1039
- @continue_answer || CONTINUE_DECISIONS.fetch(event[:decision].to_s, event[:decision].to_s)
1040
- end
1041
- @continue_answer = nil
1042
- @screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome && !asked
1043
- # Back to the > prompt: with the text a question set aside meanwhile
1044
- # (#close_question), else what the ? read holds.
1045
- set_aside = @set_aside
1046
- @set_aside = nil
1047
- set_aside ? sync_prompt(keep_text: false, prefill: set_aside) : sync_prompt
1048
- end
1049
-
1050
- # The notes slot shows the offer's choices while it waits (a question
1051
- # has the slot while it is open).
1052
- def sync_continue_slot
1053
- return if @question || @offer_asked
1054
-
1055
- if @continue_offer
1056
- @screen.set_slot(:notes, QuestionSlot.continue_offer(@continue_offer[:context], paint: method(:paint)))
1057
- else
1058
- @screen.clear_slot(:notes)
1059
- end
1060
- end
1061
-
1062
- # What a prompt's 202 says became of it, or nil when there is nothing
1063
- # to say: the dim line both TUIs print for a delivery. A plain line
1064
- # for a running turn goes in at its next step; a cut's outcome comes
1065
- # from the ack (the worker knows whether cutting is on for its model);
1066
- # a queued one waits for the turn's end (the ack says queue).
1067
- # An idle session's prompt simply runs, and a prompt with images runs
1068
- # as its own next turn (never merging mid-turn), so nothing is said;
1069
- # one typed as /cut or /queue says it was sent now (+choice+).
1070
- # @param ack [Hash, nil] the Bridge's 202 body
1071
- # @param delivery [String, nil] what was asked for
1072
- # @return [String, nil]
1073
- def sent_note(ack, delivery: nil, images: [], running: false, choice: nil)
1074
- return STEER_CHOICE_IDLE if choice && !running
1075
- return nil unless running && ack.is_a?(Hash)
1076
- # The Bridge acks queue only while a turn ran at enqueue; a /queue
1077
- # it acks next_step found the turn over: it is the next turn.
1078
- return QUEUED_NOTE if ack["delivery"] == Delivery::QUEUE
1079
- return QUEUE_RUNS_NOW if Delivery.queue?(delivery)
1080
- return nil unless images.empty?
1081
-
1082
- return NEXT_STEP_NOTE unless delivery == Delivery::CUT
1083
-
1084
- steer_cut_note(ack["cut"].to_s.to_sym, ack["cut_after"])
1085
- end
1086
-
1087
- # The client id +line+ goes with, as a command (#send_command) or as a
1088
- # message the Bridge may run as one (#send_prompt: /cut and /queue
1089
- # text). A parent agent's /continue yes and /parent-answers go as chi
1090
- # answer's: the worker holds the first to guardrails.parent_answers and
1091
- # never takes the second as a user's loosening. The command is the
1092
- # registry's own match (the worker's), not a pattern of ours.
1093
- def client_id_for(line)
1094
- return @client_id unless @as_parent
1095
- return ClientId::CLI_ANSWER if TurnFlow.continue_yes?(line)
1096
- return ClientId::CLI_ANSWER if command_registry.lookup(line)&.id == :parent_answers
1097
-
1098
- @client_id
1099
- end
1100
-
1101
- # @param choice [Symbol, nil] :cut or :queue when typed with that word
1102
- def send_prompt(text, delivery: nil, choice: nil)
1103
- persist_recent_history(text)
1104
- images = attach_images(text)
1105
- return own_prompt_failed if images.nil?
1106
-
1107
- running = @running
1108
- # Off a live region (no echo seam) the divider comes here; after
1109
- # #gap_above_echo it marks none.
1110
- mark_activity
1111
- options = { prompt: text, client_id: client_id_for(text) }
1112
- options[:no_interrupt] = true if @no_interrupt
1113
- options[:images] = images unless images.empty?
1114
- options[:delivery] = delivery if delivery
1115
- reply = @client.post_turn(**options)
1116
- if reply.status == 202
1117
- enqueued_id = reply.json&.fetch("enqueued_id", nil)
1118
- @sent_ids << enqueued_id if enqueued_id
1119
- @open_ids << enqueued_id if enqueued_id && @wait_at_eof
1120
- @my_queued << enqueued_id if enqueued_id && reply.json&.fetch("delivery", nil) == Delivery::QUEUE
1121
- note = sent_note(reply.json, delivery: delivery, images: images, running: running, choice: choice)
1122
- @screen.commit(note) if note
1123
- return
1124
- end
1125
-
1126
- own_prompt_failed
1127
-
1128
- detail = reply.json&.fetch("error", nil)
1129
- # Read after its deadline and dropped (BridgeClient::DEADLINE_SHARE).
1130
- return @screen.commit("could not send the prompt (#{LATE})") if detail == "deadline_passed"
1131
- # A worker from 0.51 to 0.63 refuses a queue (nothing was sent).
1132
- return @screen.commit(queue_needs_newer_worker) if detail == "delivery_unavailable"
1133
-
1134
- @screen.commit("could not send the prompt (#{[reply.status, detail].compact.join(" ")})")
1135
- rescue SystemCallError, IOError => e
1136
- own_prompt_failed
1137
- @screen.commit("could not send the prompt (#{worker_down(e)})")
1138
- end
1139
-
1140
- # Whether the input's end must wait: the -p prompt isn't sent yet, a
1141
- # prompt this run sent hasn't had its turn, or its turn's after_turn
1142
- # hooks still run (their notices, source-links' sources:, come next).
1143
- def own_turns_pending? = !@first_prompt.to_s.strip.empty? || !@open_ids.empty? || @display_pending == true
1144
-
1145
- # A turn ended: this run's prompt (and ours merged into it) had its turn.
1146
- def own_turn_ended(event)
1147
- return unless @wait_at_eof
1148
-
1149
- id = origin_of(event[:origin]).enqueued_id
1150
- ours = @open_ids.include?(id) || !@merged_ids.empty?
1151
- @own_failed = true if ours && event[:type] == :turn_failed
1152
- @own_empty = true if ours && event[:type] == :turn_completed && event.dig(:turn_summary, :empty_answer)
1153
- # The worker sends answer_display once its after_turn hooks ran.
1154
- @display_pending = true if ours && event[:type] == :turn_completed && event[:display_pending]
1155
- @open_ids.delete(id)
1156
- @open_ids.subtract(@merged_ids)
1157
- @merged_ids.clear
1158
- end
1159
-
1160
- # Ours merged into the running turn: they end with it.
1161
- def merged_own_prompts(event)
1162
- Array(event[:origins]).each do |value|
1163
- origin = origin_of(value)
1164
- id = origin.enqueued_id
1165
- @merged_ids << id if own?(origin.client_id) && @open_ids.include?(id)
1166
- end
1167
- nil
1168
- end
1169
-
1170
- def own_prompt_failed
1171
- @own_failed = true if @wait_at_eof
1172
- nil
1173
- end
1174
-
1175
- # The input ended and nothing of ours is left to wait for.
1176
- def end_of_input
1177
- detach("Detached; the session keeps running. Re-attach with: #{via.attach(@client.session_id)}") if @input_ended
1178
- return :turn_failed if @own_failed
1179
-
1180
- @own_empty ? :empty_answer : :detached
1181
- end
1182
-
1183
- def unanswered_question(pending)
1184
- @unanswered = pending
1185
- detach("A question waits for an answer: #{via.attach(@client.session_id)}")
1186
- :unanswered
1187
- end
1188
-
1189
- # The prompt's `@path` images, stored where the worker reads them
1190
- # (AttachLink#ingest_images: the session's images/ here) and sent as
1191
- # refs: the Bridge never takes a path. nil (after a line saying why)
1192
- # when one can't be.
1193
- def attach_images(text)
1194
- paths = ImageInput.extract(text)
1195
- return [] if paths.empty?
1196
-
1197
- @link.ingest_images(paths.map { |image| image[:path] })
1198
- rescue ImageStore::Error => e
1199
- @screen.commit("could not attach the image: #{e.message}")
1200
- nil
1201
- end
1202
-
1203
474
  # /share and /participants: chi share for this session, where it is.
1204
475
  def participant_commands
1205
476
  @participant_commands ||= ParticipantCommands.new(access: @link.session_access, session_id: @client.session_id,
@@ -1214,36 +485,6 @@ module Samagotchi
1214
485
  @screen.commit("(no metrics: #{via.remote? ? e.message : "the worker did not answer"})")
1215
486
  end
1216
487
 
1217
- # One read on the reader thread: the REPL's multiline read with Tab
1218
- # completion at the main prompt, a plain line for a question's choice
1219
- # or the continue offer's answer.
1220
- def read_input_line(prompt, prefill)
1221
- return $stdin.gets&.chomp unless $stdin.tty?
1222
- # A question's answer leaves only its summary line.
1223
- return RelineSeam.without_echo { Reline.readline(prompt, true) } if @question || @continue_offer
1224
-
1225
- queue_input_prefill(prefill) if prefill
1226
- read_prompt_line(prompt)
1227
- end
1228
-
1229
- # The attached TUI's commands, for Tab.
1230
- def slash_commands = command_registry.completions(:attached)
1231
-
1232
- # @path and #memory candidates, as the link offers them
1233
- # (AttachLink#completions).
1234
- def build_project_path_completion_candidates(path_fragment)
1235
- @link.completions.paths ? super : []
1236
- end
1237
-
1238
- def build_memory_completion_candidates(memory_fragment)
1239
- @link.completions.memories ? super : []
1240
- end
1241
-
1242
- # The session's commands as its worker's snapshot names them (its
1243
- # plugins' too); the built-ins until one arrives, or from a worker too
1244
- # old to name them. An unknown /foo is a prompt, as in the REPL.
1245
- def command_registry = @command_registry || SessionCommands.builtin_registry
1246
-
1247
488
  # The saved recap, and a new one asked for at once (it arrives as
1248
489
  # :recap_ready), with the REPL's words.
1249
490
  def show_recap
@@ -1258,95 +499,6 @@ module Samagotchi
1258
499
  @screen.commit("(no recap: #{via.remote? ? e.message : "the worker did not answer"})")
1259
500
  end
1260
501
 
1261
- def prompt_text
1262
- return paint(QUESTION_PROMPT, 33) if @question || @continue_offer
1263
-
1264
- paint(PROMPT, 92)
1265
- end
1266
-
1267
- # Switch the open prompt to ? for the answer and show the choices in
1268
- # the notes slot, fitted to the terminal. Once the question closes,
1269
- # one line (the question and what became of it) stays.
1270
- def ask(pending)
1271
- return unless pending
1272
-
1273
- @view.finish_thinking_spinner
1274
- # What was typed at the prompt waits for the question to close.
1275
- # Not over text already set aside (a question that closed while a
1276
- # continue offer waits): the ? read holds an answer then, if anything.
1277
- @set_aside = @reader&.typed_text.to_s.then { |typed| typed.empty? ? nil : typed } unless @question || @set_aside
1278
- @question = QuestionPrompt.new(pending)
1279
- @question_pending = pending
1280
- # The step-limit question stands for the continue offer's own slot
1281
- # until the offer is resolved.
1282
- @offer_asked = true if @question.continue?
1283
- # An edit's diff goes above, into the scrollback, once per question
1284
- # (a join gets it from the snapshot and may get the event too).
1285
- unless @previewed_id == @question.id
1286
- @previewed_id = @question.id
1287
- preview = @question.preview_lines(paint: method(:paint))
1288
- @screen.commit(preview.join("\n")) unless preview.empty?
1289
- end
1290
- # The prompt first, so the choices never show under the typed text.
1291
- sync_prompt(keep_text: false)
1292
- @screen.set_slot(:notes, @question.slot(paint: method(:paint)))
1293
- end
1294
-
1295
- def answer_question(text)
1296
- # Enter alone at the step-limit question continues (it can't be
1297
- # dismissed), as at the continue prompt.
1298
- return dismiss_question if text.empty? && !@question.continue?
1299
-
1300
- answer = @question.parse(text)
1301
- @screen.commit(answer.note) if answer.note
1302
- unless answer.ok?
1303
- echo_answer(text)
1304
- return @screen.commit(answer.error)
1305
- end
1306
-
1307
- parent = @as_parent ? { client_id: ClientId::CLI_ANSWER } : {}
1308
- reply = @client.answer(id: @question.id, selected: answer.selected, freeform: answer.freeform, **parent)
1309
- case reply.status
1310
- when 200
1311
- @answered_ids << @question.id
1312
- close_question(@question.answer_text(answer))
1313
- when 409 then close_question("(already answered in another UI)")
1314
- when 408 then @screen.commit("could not answer (#{LATE})")
1315
- else
1316
- detail = reply.json&.fetch("detail", nil) || reply.json&.fetch("error", nil)
1317
- @screen.commit("could not answer (#{[reply.status, detail].compact.join(" ")})")
1318
- end
1319
- nil
1320
- rescue SystemCallError, IOError => e
1321
- @screen.commit("could not answer (#{worker_down(e)})")
1322
- nil
1323
- end
1324
-
1325
- # An empty answer dismisses the question, as in the REPL: the tool
1326
- # returns unanswered (an approval: denied) and the turn goes on. The
1327
- # :question_cancelled every UI gets finds it already closed here.
1328
- def dismiss_question
1329
- reply = @client.dismiss_question(id: @question.id)
1330
- case reply.status
1331
- when 200 then close_question(@question.approval? ? "(denied)" : "(cancelled)")
1332
- when 409 then close_question("(question already closed in another UI)")
1333
- when 404 then @screen.commit("#{stale_worker("dismiss questions")}; Ctrl-C cancels the turn")
1334
- when 408 then @screen.commit("could not dismiss the question (#{LATE}); Ctrl-C cancels the turn")
1335
- else
1336
- # The question stays open (after a 404 too).
1337
- detail = reply.json&.fetch("error", nil)
1338
- @screen.commit("could not dismiss the question (#{[reply.status, detail].compact.join(" ")}); Ctrl-C cancels the turn")
1339
- end
1340
- nil
1341
- rescue SystemCallError, IOError => e
1342
- @screen.commit("could not dismiss the question (#{worker_down(e)}); Ctrl-C cancels the turn")
1343
- nil
1344
- end
1345
-
1346
- # A line read at ? that isn't a (valid) answer: the read left no echo,
1347
- # so it shows above what it got.
1348
- def echo_answer(text) = @screen.commit("#{paint(QUESTION_PROMPT, 33)}#{text}")
1349
-
1350
502
  # The name of this chi's own command +line+ when the worker answered it
1351
503
  # unknown_command (it runs an older chi without it), else nil.
1352
504
  def older_worker_command(reply, line)
@@ -1374,100 +526,6 @@ module Samagotchi
1374
526
  end
1375
527
  end
1376
528
 
1377
- def question_relayed(event)
1378
- return unless @question && @question.id == event[:id].to_s
1379
-
1380
- @question.relayed_to = event[:relayed_to]
1381
- @screen.set_slot(:notes, @question.slot(paint: method(:paint)))
1382
- end
1383
-
1384
- def question_answered(event)
1385
- return unless @question
1386
- return close_question(nil) if @answered_ids.include?(event[:id])
1387
-
1388
- answer = event[:answer] || {}
1389
- picked = [*Array(answer[:selected]), answer[:freeform]].compact.join(", ")
1390
- close_question("#{picked.empty? ? "(answered)" : picked} (in another UI)")
1391
- end
1392
-
1393
- # @param outcome [String, nil] what became of it, for the line that
1394
- # stays in the scrollback (nil: none, it was closed already)
1395
- def close_question(outcome)
1396
- @screen.clear_slot(:notes)
1397
- @screen.commit(@question.summary(outcome, paint: method(:paint))) if outcome
1398
- @question = nil
1399
- # A continue offer still waiting keeps the ? prompt (the step-limit
1400
- # question closes before its offer resolves): the text set aside
1401
- # waits for the > prompt (#continue_resolved).
1402
- return sync_continue_slot if @continue_offer
1403
-
1404
- set_aside = @set_aside
1405
- @set_aside = nil
1406
- sync_prompt(keep_text: false, prefill: set_aside)
1407
- end
1408
-
1409
- # Restart the open read when its prompt no longer fits (a question or a
1410
- # continue offer opened or closed), first erasing the prompt Reline drew.
1411
- # What is typed there goes along (+keep_text+), or +prefill+ goes in.
1412
- def sync_prompt(keep_text: true, prefill: nil)
1413
- return unless @reader
1414
-
1415
- @screen.synchronize do
1416
- next if @reader.current == prompt_text
1417
-
1418
- @screen.clear_slot(:editor)
1419
- @reader.reprompt(keep_text: keep_text, prefill: prefill)
1420
- end
1421
- end
1422
-
1423
- # Ctrl-C cancels the running turn (whoever started it). At an idle
1424
- # prompt it clears the line; on an empty one it says how to detach, and
1425
- # a second press within DETACH_WINDOW detaches (D4).
1426
- # @param pressed [Hash, nil] {text:, at:} from the read the press ended
1427
- # (nil: pressed with no read open)
1428
- # @return [Symbol, nil] :detach
1429
- def interrupt(pressed = nil)
1430
- typed = pressed&.fetch(:text, nil).to_s
1431
- if @running
1432
- begin
1433
- @client.cancel(reason: "ctrl_c")
1434
- rescue SystemCallError, IOError => e
1435
- @screen.commit("could not cancel the turn (#{worker_down(e)})")
1436
- end
1437
- return nil
1438
- end
1439
- unless typed.strip.empty?
1440
- @last_idle_interrupt_at = nil
1441
- return nil
1442
- end
1443
-
1444
- now = pressed&.fetch(:at, nil) || @clock.call
1445
- return submit(nil) if @last_idle_interrupt_at && now - @last_idle_interrupt_at <= DETACH_WINDOW
1446
-
1447
- @last_idle_interrupt_at = now
1448
- @screen.commit(DETACH_HINT)
1449
- nil
1450
- end
1451
-
1452
- # On the reader thread, from Reline's Ctrl-C: what the line held, and
1453
- # when. During a turn the press goes to the loop (it cancels the turn)
1454
- # and the read goes on with the typed text still in it, as in the REPL;
1455
- # otherwise it is noted for the Interrupt the LineReader queues next.
1456
- # @return [Boolean] true: handled, the read goes on; false: Reline ends
1457
- # the read with Interrupt
1458
- def note_interrupted_line
1459
- pressed = { text: Reline.line_buffer.to_s, at: @clock.call }
1460
- if @running
1461
- @queue << [:interrupt, pressed]
1462
- return true
1463
- end
1464
-
1465
- Thread.current[:interrupted_line] = pressed
1466
- false
1467
- rescue StandardError
1468
- false
1469
- end
1470
-
1471
529
  def render_snapshot(snapshot, reset:)
1472
530
  @view.finish_thinking_spinner
1473
531
  queued = Array(snapshot[:queued])
@@ -1488,8 +546,7 @@ module Samagotchi
1488
546
  # The recap first: what the session was about, then where it stopped.
1489
547
  saved = snapshot[:saved_recap]
1490
548
  @screen.commit(recap_block(saved[:text], turns_since: saved[:turns_since], at: saved[:created_at])) if saved && !saved[:text].to_s.strip.empty?
1491
- render_join_header(Array(snapshot[:messages]))
1492
- render_join_gap(snapshot, saved)
549
+ @join_replay.render_join(snapshot, saved)
1493
550
  @screen.commit("guardrails> #{snapshot[:guardrail_warning]}") if snapshot[:guardrail_warning]
1494
551
  @screen.commit("plugins> #{snapshot[:plugin_warning]}") if snapshot[:plugin_warning]
1495
552
  version_line = VersionLines.at_attach(worker: snapshot[:chi_version], installed: installed_version,
@@ -1501,7 +558,7 @@ module Samagotchi
1501
558
  end
1502
559
  Array(snapshot[:init_tasks]).each { |task| @view.init_started(task) }
1503
560
  cards = Array(snapshot[:cards])
1504
- render_snapshot_cards(cards.reject { |card| card[:current] }, joining: !reset)
561
+ @join_replay.render_snapshot_cards(cards.reject { |card| card[:current] }, joining: !reset)
1505
562
  offer = snapshot[:continue_offer]
1506
563
  had_offer = !@continue_offer.nil?
1507
564
  @continue_offer = offer && { context: offer[:context], no_interrupt: offer[:no_interrupt] }
@@ -1512,7 +569,7 @@ module Samagotchi
1512
569
  render_current_turn(snapshot[:current_turn])
1513
570
  # A question between turns (the step-limit one) belongs to no turn.
1514
571
  ask(snapshot[:pending_question]) if snapshot[:current_turn].nil? && snapshot[:pending_question]
1515
- render_snapshot_cards(cards.select { |card| card[:current] }, joining: !reset)
572
+ @join_replay.render_snapshot_cards(cards.select { |card| card[:current] }, joining: !reset)
1516
573
  queued.each do |entry|
1517
574
  next if own?(entry[:client_id])
1518
575
 
@@ -1533,270 +590,6 @@ module Samagotchi
1533
590
  nil
1534
591
  end
1535
592
 
1536
- # --model (or --llm-context, --thinking) on a resumed session: run it in its
1537
- # worker before the first prompt goes; its :command_ran decides
1538
- # (#first_command_ran), and sends the next one.
1539
- # @return [Symbol, nil] :failed when the worker didn't take it
1540
- def send_first_command
1541
- line = @first_commands.shift
1542
- @first_command_line = line
1543
- reply = @client.post_command(line: line, client_id: @client_id)
1544
- if reply.status == 202
1545
- @first_command_id = reply.json&.fetch("command_id", nil)
1546
- return nil if @first_command_id
1547
- end
1548
-
1549
- why =
1550
- if reply.status == 404 then stale_worker("run commands")
1551
- elsif (name = older_worker_command(reply, line)) then stale_worker("run #{name}")
1552
- elsif too_late?(reply) then LATE
1553
- else "the worker answered #{reply.status}"
1554
- end
1555
- @screen.commit("#{first_command_failed}: #{why}")
1556
- :failed
1557
- rescue SystemCallError, IOError => e
1558
- @screen.commit("#{first_command_failed}: #{worker_down(e)}")
1559
- :failed
1560
- end
1561
-
1562
- # @return [Symbol, nil] :failed unless the command went through
1563
- def first_command_ran(event)
1564
- @first_command_id = nil
1565
- if event[:status] == "ok"
1566
- return send_first_command unless @first_commands.empty?
1567
-
1568
- send_first_prompt
1569
- return nil
1570
- end
1571
-
1572
- @screen.commit("#{first_command_failed}: #{event[:output]}")
1573
- :failed
1574
- end
1575
-
1576
- def first_command_failed
1577
- flag = first_command_flag
1578
- flag == "--model" ? "could not switch to the --model" : "could not set the #{flag}"
1579
- end
1580
-
1581
- # The launch flag the first command came from: "/model X" is --model,
1582
- # "/llm-context …" --llm-context, "/thinking X --yes" --thinking.
1583
- def first_command_flag
1584
- "--#{@first_command_line.to_s[%r{\A/([\w-]+)}, 1]}"
1585
- end
1586
-
1587
- # After the join, so it lands below what the session already had. Our
1588
- # own prompts are never echoed from events (a typed one stays on screen),
1589
- # so it is shown here as if typed.
1590
- def send_first_prompt
1591
- text = @first_prompt.to_s.strip
1592
- @first_prompt = nil
1593
- return if text.empty?
1594
-
1595
- @screen.commit("#{paint(PROMPT, 92)}#{text}")
1596
- # As if typed: a session command runs as the command; a typo of one
1597
- # gets the hint, not a turn.
1598
- if command_registry.command?(text)
1599
- send_command(text)
1600
- elsif (hint = command_registry.unknown_command_hint(text))
1601
- @screen.commit(hint)
1602
- else
1603
- send_prompt(text)
1604
- end
1605
- end
1606
-
1607
- # Where the session's last activity was: its newest stamped entry,
1608
- # else its saved recap's time. A gap ago (and no turn running): one
1609
- # line after the join block says when.
1610
- def render_join_gap(snapshot, saved)
1611
- @gaps.seed(snapshot[:messages], fallback: saved&.dig(:created_at))
1612
- return if snapshot[:current_turn]
1613
-
1614
- line = @gaps.join_line
1615
- @screen.commit(gap_divider(line)) if line
1616
- end
1617
-
1618
- def render_join_header(messages)
1619
- exchange = messages.select { |m| %w[user model assistant].include?(m[:role].to_s) }
1620
- # The id stays findable: the detach line and chi sessions list show it.
1621
- Log.session_id = @client.session_id
1622
- Log.info(:attached, "joined", session: @client.session_id, messages: exchange.size,
1623
- notes: messages.count { |m| ContextNote.note?(m) })
1624
- last_user = exchange.rindex { |m| Steer.prompt?(m) }
1625
- render_join_notes(messages)
1626
- return unless last_user
1627
-
1628
- @screen.commit(join_prompt_line(exchange[last_user]))
1629
- # An image-only turn's text names its images already.
1630
- unless ImageRef.placeholder_only?(exchange[last_user][:content])
1631
- Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
1632
- end
1633
- from = messages.index { |m| m.equal?(exchange[last_user]) }
1634
- render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
1635
- record = @link.saved_turn_record(exchange[last_user][:turn_id] || exchange[last_user]["turn_id"])
1636
- failed = join_failed_record(record)
1637
- # A turn that ended with no answer: its notice, as live.
1638
- empty = messages.drop(from + 1).filter_map { |m| TurnNote.empty_answer(m) }.last
1639
- if empty
1640
- @screen.commit(format_empty_answer_line(empty[:retries] || empty["retries"], empty[:refusal] || empty["refusal"]))
1641
- elsif failed
1642
- # A failed turn whose steps stayed: the line the live view left,
1643
- # and no step text as if it were the answer (the web keeps a
1644
- # failed turn's texts as steps too).
1645
- failure = failed["failure"]
1646
- @screen.commit(@view.turn_failed_line(failure["summary"] || failure["message"], failed["duration_ms"]))
1647
- @screen.commit(@view.turn_end_hint(ROLLBACK_HINT)) if failure["kept_steps"]
1648
- elsif record && record["status"] == "canceled"
1649
- # A canceled turn (a Stop, Ctrl-C): its line, as live, and no
1650
- # text it streamed before the cancel as if it were the answer.
1651
- @screen.commit(@view.turn_canceled_line(record["cancellation_reason"], record["duration_ms"], by: record["cancelled_by"]))
1652
- @screen.commit(@view.turn_end_hint(ROLLBACK_HINT)) unless record["continue"]
1653
- else
1654
- # The saved answer is raw: the latest keeps its thinking, and one may
1655
- # be only a tool call.
1656
- answer = exchange[(last_user + 1)..].reverse_each
1657
- .map { |m| OutputFormatter.strip_markup(m[:content]) }
1658
- .find { |text| !text.empty? }
1659
- @screen.commit(last_lines(answer)) if answer
1660
- end
1661
- render_join_notes(messages, after_exchange: true)
1662
- end
1663
-
1664
- # What the last exchange did, as the live turn drew it: each saved
1665
- # call's tool row (action, params, status from its result, and its
1666
- # duration from +durations+, the turn's saved tool records) and a
1667
- # plugin's steer where it came. The step texts stay out, as live (the
1668
- # ticker's).
1669
- def render_join_steps(messages, from, durations: [])
1670
- records = durations.dup
1671
- messages.each_with_index.drop(from + 1).each do |m, i|
1672
- if Steer.steer?(m)
1673
- @screen.commit(format_steer_line(source: m[:source], text: m[:content]))
1674
- elsif %w[model assistant].include?(m[:role].to_s)
1675
- responses = messages.drop(i + 1).take_while { |r| r[:role].to_s == "tool_response" }
1676
- Array(MessageParts.for_message(m, responses)&.dig(:tools)).each do |part|
1677
- record = join_record(records, part)
1678
- duration = record&.dig("duration_ms")
1679
- @screen.commit(join_tool_line(part, duration_ms: duration.is_a?(Numeric) ? duration : nil,
1680
- saved_status: record&.dig("status"),
1681
- auto_allowed: record&.dig("guardrail")))
1682
- end
1683
- end
1684
- end
1685
- end
1686
-
1687
- # The prompt's turn's saved tool records (AttachLink: SessionMetrics,
1688
- # in this machine's state dir, as #attach_images): none for a prompt
1689
- # with no turn id (an older session) or a session with no analytics.
1690
- def join_tool_records(prompt)
1691
- @link.saved_tool_records(prompt[:turn_id] || prompt["turn_id"])
1692
- end
1693
-
1694
- # The turn's saved +record+ when it failed with the fields its line is
1695
- # made of (SessionMetrics#failure_fields), else nil: a completed or
1696
- # older turn — or one saved before the failure field — draws its
1697
- # answer as before (a canceled one its line).
1698
- def join_failed_record(record)
1699
- return nil unless record && record["status"] == "failed" && record["failure"].is_a?(Hash)
1700
-
1701
- failure = record["failure"]
1702
- return nil if failure["summary"].to_s.empty? && failure["message"].to_s.empty?
1703
-
1704
- record
1705
- end
1706
-
1707
- # The saved record of +part+'s call (its duration and status): the
1708
- # next record of its tool, in call order, consumed with the ones it
1709
- # skipped. By tool and order, not by iteration: an empty retry spends
1710
- # an iteration that left no message (as the web's timing.js
1711
- # takeRecord). nil for a call that left none: it never finished, or a
1712
- # worker before turn ids saved it.
1713
- def join_record(records, part)
1714
- return nil if part[:output].nil?
1715
-
1716
- at = records.index { |r| r["tool"].to_s == part[:tool].to_s }
1717
- return nil unless at
1718
-
1719
- records.shift(at + 1).last
1720
- end
1721
-
1722
- # A saved call's row; "no result" for a call the turn never answered
1723
- # (it ended first). Its status is the saved record's, as live and the
1724
- # web's reload (a guardrail's "blocked" is no error), else read from
1725
- # its result (a guardrail's deny text reads blocked too). A record that
1726
- # System 1 allowed once (+auto_allowed+, its saved {rule, p}) says so,
1727
- # as the live row does.
1728
- def join_tool_line(part, duration_ms: nil, saved_status: nil, auto_allowed: nil)
1729
- output = part[:output]
1730
- command = part.dig(:view, :truncated) ? nil : part.dig(:view, :command)
1731
- text = ToolActivity.saved_result_text(output) unless output.nil?
1732
- status = if output.nil?
1733
- "no result"
1734
- elsif !saved_status.to_s.empty?
1735
- saved_status.to_s
1736
- elsif ToolActivity.guardrail_denied?(text)
1737
- "blocked"
1738
- else
1739
- ToolActivity.tool_activity_status(text, part[:tool], command: command)
1740
- end
1741
- activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
1742
- params: part[:params], status: status,
1743
- description: ToolView.description_title(part[:view] || {}) }
1744
- "#{format_tool_activity_line(activity, duration_ms: duration_ms)}" \
1745
- "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}#{format_tool_cut_suffix(part[:output_truncated])}" \
1746
- "#{auto_allowed_suffix(auto_allowed)}"
1747
- end
1748
-
1749
- # The live row's System 1 suffix for a saved record's {rule, p}.
1750
- def auto_allowed_suffix(saved)
1751
- return "" unless saved.is_a?(Hash)
1752
-
1753
- format_tool_guardrail_suffix({ decided_by: "system one", p: saved["p"] || saved[:p] })
1754
- end
1755
-
1756
- # The context notes that came since the last prompt (all of them in a
1757
- # session with none), after the exchange the header shows.
1758
- def render_join_notes(messages, after_exchange: false)
1759
- last_user = messages.rindex { |m| Steer.prompt?(m) }
1760
- return if after_exchange != !last_user.nil?
1761
-
1762
- messages[(last_user ? last_user + 1 : 0)..].select { |m| ContextNote.note?(m) }.each do |m|
1763
- @screen.commit(context_note_line(ContextNote.label_of(m), ContextNote.text_of(m)))
1764
- end
1765
- end
1766
-
1767
- # The end of a long answer; the whole of it is in the session.
1768
- def last_lines(text)
1769
- lines = text.split("\n", -1)
1770
- if lines.size > JOIN_ANSWER_LINES
1771
- text = "(… #{lines.size - JOIN_ANSWER_LINES} earlier lines)\n#{lines.last(JOIN_ANSWER_LINES).join("\n")}"
1772
- end
1773
- return text if text.length <= JOIN_ANSWER_CHARS
1774
-
1775
- "(… earlier text)\n…#{text[-JOIN_ANSWER_CHARS..]}"
1776
- end
1777
-
1778
- # The snapshot's cards and between-turns notices (Bridge CardStore):
1779
- # on join the ones since the last turn (and the running turn's), as
1780
- # the join header shows the last exchange; on a resync only cards not
1781
- # shown here yet, and not the ones an earlier worker saved (a resync
1782
- # onto a new worker: earlier turns, not news). A turn's own rows (notices, retries) are left out:
1783
- # the running turn's come with its parts, and the join shows no older
1784
- # turn's steps.
1785
- def render_snapshot_cards(entries, joining:)
1786
- entries.each do |entry|
1787
- case entry[:type].to_s
1788
- when "hook_notice"
1789
- next if entry[:in_turn]
1790
-
1791
- @screen.commit(EventRenderer.hook_notice_line(entry)) if joining && entry[:turns_since].to_i.zero?
1792
- when "card"
1793
- next unless joining ? entry[:turns_since].to_i.zero? : !entry[:earlier] && !@renderer.card_shown?(entry[:id])
1794
-
1795
- @renderer.render_card(entry, updated: entry[:updated] == true)
1796
- end
1797
- end
1798
- end
1799
-
1800
593
  def render_current_turn(turn)
1801
594
  @running = !turn.nil?
1802
595
  @joined_mid_turn = @running
@@ -1852,36 +645,6 @@ module Samagotchi
1852
645
  images: event[:images], diff: event[:diff] }))
1853
646
  end
1854
647
 
1855
- def show_enqueued(event)
1856
- return if own?(event[:client_id])
1857
-
1858
- # Another client's prompt shows here, before its turn starts.
1859
- mark_activity
1860
- @shown_enqueued << event[:enqueued_id]
1861
- line = prompt_line(event[:client_id], event[:prompt], **author_of(event[:author]))
1862
- @screen.commit(event[:delivery] == Delivery::QUEUE ? "#{line} #{QUEUED_NOTE}" : line)
1863
- end
1864
-
1865
- # A queued prompt was taken back before it ran (the web's ×, /unqueue
1866
- # in a terminal). One of this run's (wait_at_eof) will have no turn:
1867
- # it ends the wait, as a failed one does. Another client's shown
1868
- # line says so.
1869
- def turn_dequeued(event)
1870
- id = event[:enqueued_id]
1871
- @my_queued.delete(id)
1872
- if @open_ids.delete?(id)
1873
- if own?(event[:client_id])
1874
- @own_empty = true
1875
- @screen.commit("(removed before it ran)")
1876
- else
1877
- @own_failed = true
1878
- @screen.commit("(your queued message was removed before it ran)")
1879
- end
1880
- elsif @shown_enqueued.delete?(id)
1881
- @screen.commit("(a queued message was removed)")
1882
- end
1883
- end
1884
-
1885
648
  def start_turn(event)
1886
649
  @running = true
1887
650
  @joined_mid_turn = false