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
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../delivery"
4
+
5
+ module Samagotchi
6
+ class TerminalUI
7
+ class AttachedLoop
8
+ # Queue and unqueue handling for the attached loop.
9
+ # Reads @client, @client_id, @first_command_id, @link, @my_queued,
10
+ # @open_ids, @reader, @screen, @shown_enqueued, @wait_at_eof; writes
11
+ # @my_queued, @open_ids, @own_empty, @own_failed, @prefilled, @queued_ids,
12
+ # @shown_enqueued.
13
+ # Calls the loop helpers #author_of, #first_command_flag, #mark_activity,
14
+ # #own?, #prompt_line, #send_prompt, #via, and #worker_down.
15
+ module Queueing
16
+ private
17
+
18
+ QUEUED_NOTE = "(queued: runs after this turn)"
19
+ QUEUE_RUNS_NOW = "(the turn had ended: runs as the next turn)"
20
+ NOTHING_QUEUED = "(nothing queued: /queue TEXT queues a message for after the running turn)"
21
+ UNQUEUE_REFUSED = { "already_started" => "(not removed: it already started)",
22
+ "unknown_enqueued_id" => "(not removed: it is already gone)",
23
+ "not_queued" => "(not removed: it goes in at the next step)" }.freeze
24
+
25
+ # /cut TEXT and /queue TEXT (+route+ has a choice). During a turn the
26
+ # text goes with its delivery: a cut, or queued to run as a turn of
27
+ # its own after this one. A worker that doesn't queue (its sidecar
28
+ # names no "delivery" feature) would steer a queue, so it is not sent.
29
+ # At the prompt both send the text as typed, a normal turn: no local
30
+ # word or typo check (the worker's Bridge runs a session command as
31
+ # the command).
32
+ # @param route [ComposerRoute::Route]
33
+ # @return [nil]
34
+ def submit_delivery(route)
35
+ return @screen.commit(Formatting::STEER_CHOICE_USAGE) if route.kind == :usage
36
+ return @screen.commit(queue_needs_newer_worker) if Delivery.queue?(route.delivery) && !worker_queues?
37
+
38
+ # To an idle session it is an ordinary prompt: no delivery to send,
39
+ # and its ack says it was sent now (#sent_note).
40
+ send_prompt(route.text, delivery: route.delivery, choice: route.choice)
41
+ nil
42
+ end
43
+
44
+ # Whether the attached worker runs delivery: queue (its sidecar names
45
+ # Delivery::QUEUE_FEATURE). No sidecar to read: true, its Bridge
46
+ # answers (a P0-P2 worker refuses a queue: #send_prompt).
47
+ def worker_queues? = @link.queues?
48
+
49
+ # /queue to a worker from before queueing, which would steer it.
50
+ def queue_needs_newer_worker
51
+ "(/queue needs a newer worker: restart it with #{via.command("sessions restart")}, then send again)"
52
+ end
53
+
54
+ def unqueue_needs_newer_worker = "(/unqueue needs a newer worker: restart it with #{via.command("sessions restart")})"
55
+
56
+ # /unqueue: take back the newest prompt this run queued for after the
57
+ # turn, before it runs (POST turn/dequeue). Its :turn_dequeued comes
58
+ # to every UI; here the reply says what became of it. A worker whose
59
+ # sidecar lacks "unqueue" is not asked.
60
+ def unqueue_newest
61
+ return @screen.commit(unqueue_needs_newer_worker) unless worker_unqueues?
62
+
63
+ id = @my_queued.last
64
+ return @screen.commit(NOTHING_QUEUED) unless id
65
+
66
+ reply = @client.dequeue(enqueued_id: id, client_id: @client_id)
67
+ error = reply.json&.fetch("error", nil)
68
+ return @screen.commit(unqueue_needs_newer_worker) if reply.status == 404 && error == "not_found"
69
+
70
+ # Removed, started or gone: no longer this run's to take back.
71
+ @my_queued.delete(id) if [200, 404, 409].include?(reply.status)
72
+ return unqueued(reply.json&.fetch("prompt", nil).to_s) if reply.status == 200
73
+
74
+ @screen.commit(UNQUEUE_REFUSED[error] || "could not remove the queued message (#{[reply.status, error].compact.join(" ")})")
75
+ rescue SystemCallError, IOError => e
76
+ @screen.commit("could not remove the queued message (#{worker_down(e)})")
77
+ end
78
+
79
+ # The removal's line, and its text back in the input to change and
80
+ # send again (as a failed turn's prompt comes back, #restore_prompt):
81
+ # not over typed text, and not for lines from a pipe. Its images are
82
+ # not put back.
83
+ def unqueued(prompt)
84
+ @screen.commit("removed: #{first_line_clip(prompt)}")
85
+ return if @wait_at_eof || prompt.strip.empty?
86
+
87
+ # Set before the read restarts with it: the read's echo reads it.
88
+ @prefilled = prompt.strip
89
+ return if @reader&.prefill(prompt)
90
+
91
+ @prefilled = nil
92
+ @screen.commit("(the removed message is in the input history: ↑)")
93
+ end
94
+
95
+ def first_line_clip(text, max = 60)
96
+ line = text.to_s.strip.lines.first.to_s.strip
97
+ line.length > max ? "#{line[0, max]}…" : line
98
+ end
99
+
100
+ # Whether the attached worker takes queued messages back (its sidecar
101
+ # names SessionInbox::UNQUEUE_FEATURE). No sidecar to read: true, its Bridge
102
+ # answers (an older one's 404 not_found says so).
103
+ def worker_unqueues? = @link.unqueues?
104
+
105
+ # A command queued for after the running turn (command_queued with
106
+ # waits): its line now (another client's) and a note; its command_ran
107
+ # has no line again (queued: true).
108
+ def command_waits(event)
109
+ @queued_ids << event[:command_id]
110
+ if own?(event[:client_id])
111
+ first = event[:command_id] == @first_command_id
112
+ return @screen.commit(first ? first_command_queued : QUEUED_NOTE)
113
+ end
114
+
115
+ @screen.commit("#{prompt_line(event[:client_id], event[:line])} #{QUEUED_NOTE}")
116
+ end
117
+
118
+ def first_command_queued
119
+ "(#{first_command_flag} queued: applies after the running turn)"
120
+ end
121
+
122
+ def show_enqueued(event)
123
+ return if own?(event[:client_id])
124
+
125
+ # Another client's prompt shows here, before its turn starts.
126
+ mark_activity
127
+ @shown_enqueued << event[:enqueued_id]
128
+ line = prompt_line(event[:client_id], event[:prompt], **author_of(event[:author]))
129
+ @screen.commit(event[:delivery] == Delivery::QUEUE ? "#{line} #{QUEUED_NOTE}" : line)
130
+ end
131
+
132
+ # A queued prompt was taken back before it ran (the web's ×, /unqueue
133
+ # in a terminal). One of this run's (wait_at_eof) will have no turn:
134
+ # it ends the wait, as a failed one does. Another client's shown
135
+ # line says so.
136
+ def turn_dequeued(event)
137
+ id = event[:enqueued_id]
138
+ @my_queued.delete(id)
139
+ if @open_ids.delete?(id)
140
+ if own?(event[:client_id])
141
+ @own_empty = true
142
+ @screen.commit("(removed before it ran)")
143
+ else
144
+ @own_failed = true
145
+ @screen.commit("(your queued message was removed before it ran)")
146
+ end
147
+ elsif @shown_enqueued.delete?(id)
148
+ @screen.commit("(a queued message was removed)")
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../client_id"
4
+ require_relative "../../delivery"
5
+ require_relative "../../prompt_history"
6
+ require_relative "../../turn_origin"
7
+ require_relative "../exit_lines"
8
+ require_relative "../image_input"
9
+
10
+ module Samagotchi
11
+ class TerminalUI
12
+ class AttachedLoop
13
+ # Bridge sending, first-command launch, and this run's turn ledger.
14
+ # Reads @as_parent, @client, @client_id, @display_pending, @first_command_id,
15
+ # @first_command_line, @first_commands, @first_prompt, @input_ended, @link,
16
+ # @merged_ids, @my_queued, @no_interrupt, @open_ids, @own_empty, @own_failed,
17
+ # @queued_ids, @reader, @running, @screen, @sent_ids, @status, @wait_at_eof;
18
+ # writes @display_pending, @first_command_id, @first_command_line,
19
+ # @first_commands, @first_prompt, @merged_ids, @my_queued, @open_ids,
20
+ # @own_empty, @own_failed, @queued_ids, @sent_ids.
21
+ # Calls the loop helpers #client_id_for, #command_registry, #default_model_name,
22
+ # #detach, #mark_activity, #older_worker_command, #origin_of, #own?, #paint,
23
+ # #persist_recent_history, #prompt_line, #put_back_busy_command, #prefill_confirm,
24
+ # #queue_needs_newer_worker, #send_command, #send_prompt, #stale_worker,
25
+ # #steer_cut_note, #too_late?, #via, and #worker_down.
26
+ module Sending
27
+ private
28
+
29
+ # The worker runs it and every UI renders its :command_ran, this one
30
+ # too, so nothing waits here.
31
+ def send_command(line)
32
+ reply = @client.post_command(line: line, client_id: client_id_for(line))
33
+ if reply.status == 202
34
+ # Input from a pipe ends once its output is in (#command_ran).
35
+ command_id = reply.json&.fetch("command_id", nil)
36
+ @open_ids << command_id if command_id && @wait_at_eof
37
+ return
38
+ end
39
+ return @screen.commit(stale_worker("run commands")) if reply.status == 404
40
+
41
+ name = older_worker_command(reply, line)
42
+ return @screen.commit(stale_worker("run #{name}")) if name
43
+ return @screen.commit("could not run the command (#{ExitLines::LATE})") if too_late?(reply)
44
+
45
+ detail = reply.json&.fetch("detail", nil) || reply.json&.fetch("error", nil)
46
+ @screen.commit("could not run the command (#{[reply.status, detail].compact.join(" ")})")
47
+ rescue SystemCallError, IOError => e
48
+ @screen.commit("could not run the command (#{worker_down(e)})")
49
+ end
50
+
51
+ def command_ran(event)
52
+ @open_ids.delete(event[:command_id])
53
+ @queued_ids.delete(event[:command_id])
54
+ unless own?(event[:client_id]) || event[:anytime] || event[:card] || event[:queued]
55
+ @screen.commit(prompt_line(event[:client_id], event[:line]))
56
+ end
57
+ output = event[:output].to_s
58
+ if output.empty?
59
+ nil
60
+ elsif %w[busy dropped].include?(event[:status]) || PromptHistory.shell_line?(event[:line])
61
+ @screen.commit(output)
62
+ put_back_busy_command(event) if event[:status] == "busy"
63
+ else
64
+ @screen.commit("#{paint("model>", 36)} #{output}")
65
+ end
66
+ prefill_confirm(event)
67
+ # /title from any client: the row's title follows.
68
+ @status.update(title: event[:title]) if Array(event[:changed]).include?("title")
69
+ return unless event[:model_name]
70
+
71
+ # After the output, so the status row changes with the line saying why.
72
+ served = @status[:model] == event[:model_name] ? @status[:served] : nil
73
+ @status.update(model: event[:model_name], default_model: default_model_name, served: served)
74
+ end
75
+
76
+ # Our change that asks first (CacheConfirm) puts its --yes line in the
77
+ # input: Enter applies it. Another client's question is its own.
78
+ def prefill_confirm(event)
79
+ return unless own?(event[:client_id])
80
+
81
+ confirm = event[:confirm]
82
+ line = confirm.is_a?(Hash) ? confirm[:line] || confirm["line"] : nil
83
+ @reader&.prefill(line.to_s) if line
84
+ end
85
+
86
+ # Our command the worker couldn't run yet goes back into the prompt, for
87
+ # Enter once the turn ends (not the launch's --model: that ends the launch).
88
+ def put_back_busy_command(event)
89
+ return unless own?(event[:client_id]) && event[:command_id] != @first_command_id
90
+
91
+ @screen.commit("(the command is in the input history: ↑)") unless @reader&.prefill(event[:line].to_s)
92
+ end
93
+
94
+ # What a prompt's 202 says became of it, or nil when there is nothing
95
+ # to say: the dim line both TUIs print for a delivery. A plain line
96
+ # for a running turn goes in at its next step; a cut's outcome comes
97
+ # from the ack (the worker knows whether cutting is on for its model);
98
+ # a queued one waits for the turn's end (the ack says queue).
99
+ # An idle session's prompt simply runs, and a prompt with images runs
100
+ # as its own next turn (never merging mid-turn), so nothing is said;
101
+ # one typed as /cut or /queue says it was sent now (+choice+).
102
+ # @param ack [Hash, nil] the Bridge's 202 body
103
+ # @param delivery [String, nil] what was asked for
104
+ # @return [String, nil]
105
+ def sent_note(ack, delivery: nil, images: [], running: false, choice: nil)
106
+ return Formatting::STEER_CHOICE_IDLE if choice && !running
107
+ return nil unless running && ack.is_a?(Hash)
108
+ # The Bridge acks queue only while a turn ran at enqueue; a /queue
109
+ # it acks next_step found the turn over: it is the next turn.
110
+ return Queueing::QUEUED_NOTE if ack["delivery"] == Delivery::QUEUE
111
+ return Queueing::QUEUE_RUNS_NOW if Delivery.queue?(delivery)
112
+ return nil unless images.empty?
113
+
114
+ return Formatting::NEXT_STEP_NOTE unless delivery == Delivery::CUT
115
+
116
+ steer_cut_note(ack["cut"].to_s.to_sym, ack["cut_after"])
117
+ end
118
+
119
+ # The client id +line+ goes with, as a command (#send_command) or as a
120
+ # message the Bridge may run as one (#send_prompt: /cut and /queue
121
+ # text). A parent agent's /continue yes and /parent-answers go as chi
122
+ # answer's: the worker holds the first to guardrails.parent_answers and
123
+ # never takes the second as a user's loosening. The command is the
124
+ # registry's own match (the worker's), not a pattern of ours.
125
+ def client_id_for(line)
126
+ return @client_id unless @as_parent
127
+ return ClientId::CLI_ANSWER if TurnFlow.continue_yes?(line)
128
+ return ClientId::CLI_ANSWER if command_registry.lookup(line)&.id == :parent_answers
129
+
130
+ @client_id
131
+ end
132
+
133
+ # @param choice [Symbol, nil] :cut or :queue when typed with that word
134
+ def send_prompt(text, delivery: nil, choice: nil)
135
+ persist_recent_history(text)
136
+ images = attach_images(text)
137
+ return own_prompt_failed if images.nil?
138
+
139
+ running = @running
140
+ # Off a live region (no echo seam) the divider comes here; after
141
+ # #gap_above_echo it marks none.
142
+ mark_activity
143
+ options = { prompt: text, client_id: client_id_for(text) }
144
+ options[:no_interrupt] = true if @no_interrupt
145
+ options[:images] = images unless images.empty?
146
+ options[:delivery] = delivery if delivery
147
+ reply = @client.post_turn(**options)
148
+ if reply.status == 202
149
+ enqueued_id = reply.json&.fetch("enqueued_id", nil)
150
+ @sent_ids << enqueued_id if enqueued_id
151
+ @open_ids << enqueued_id if enqueued_id && @wait_at_eof
152
+ @my_queued << enqueued_id if enqueued_id && reply.json&.fetch("delivery", nil) == Delivery::QUEUE
153
+ note = sent_note(reply.json, delivery: delivery, images: images, running: running, choice: choice)
154
+ @screen.commit(note) if note
155
+ return
156
+ end
157
+
158
+ own_prompt_failed
159
+
160
+ detail = reply.json&.fetch("error", nil)
161
+ # Read after its deadline and dropped (BridgeClient::DEADLINE_SHARE).
162
+ return @screen.commit("could not send the prompt (#{ExitLines::LATE})") if detail == "deadline_passed"
163
+ # A worker from 0.51 to 0.63 refuses a queue (nothing was sent).
164
+ return @screen.commit(queue_needs_newer_worker) if detail == "delivery_unavailable"
165
+
166
+ @screen.commit("could not send the prompt (#{[reply.status, detail].compact.join(" ")})")
167
+ rescue SystemCallError, IOError => e
168
+ own_prompt_failed
169
+ @screen.commit("could not send the prompt (#{worker_down(e)})")
170
+ end
171
+
172
+ # Whether the input's end must wait: the -p prompt isn't sent yet, a
173
+ # prompt this run sent hasn't had its turn, or its turn's after_turn
174
+ # hooks still run (their notices, source-links' sources:, come next).
175
+ def own_turns_pending? = !@first_prompt.to_s.strip.empty? || !@open_ids.empty? || @display_pending == true
176
+
177
+ # A turn ended: this run's prompt (and ours merged into it) had its turn.
178
+ def own_turn_ended(event)
179
+ return unless @wait_at_eof
180
+
181
+ id = origin_of(event[:origin]).enqueued_id
182
+ ours = @open_ids.include?(id) || !@merged_ids.empty?
183
+ @own_failed = true if ours && event[:type] == :turn_failed
184
+ @own_empty = true if ours && event[:type] == :turn_completed && event.dig(:turn_summary, :empty_answer)
185
+ # The worker sends answer_display once its after_turn hooks ran.
186
+ @display_pending = true if ours && event[:type] == :turn_completed && event[:display_pending]
187
+ @open_ids.delete(id)
188
+ @open_ids.subtract(@merged_ids)
189
+ @merged_ids.clear
190
+ end
191
+
192
+ # Ours merged into the running turn: they end with it.
193
+ def merged_own_prompts(event)
194
+ Array(event[:origins]).each do |value|
195
+ origin = origin_of(value)
196
+ id = origin.enqueued_id
197
+ @merged_ids << id if own?(origin.client_id) && @open_ids.include?(id)
198
+ end
199
+ nil
200
+ end
201
+
202
+ def own_prompt_failed
203
+ @own_failed = true if @wait_at_eof
204
+ nil
205
+ end
206
+
207
+ # The input ended and nothing of ours is left to wait for.
208
+ def end_of_input
209
+ detach("Detached; the session keeps running. Re-attach with: #{via.attach(@client.session_id)}") if @input_ended
210
+ return :turn_failed if @own_failed
211
+
212
+ @own_empty ? :empty_answer : :detached
213
+ end
214
+
215
+ # The prompt's `@path` images, stored where the worker reads them
216
+ # (AttachLink#ingest_images: the session's images/ here) and sent as
217
+ # refs: the Bridge never takes a path. nil (after a line saying why)
218
+ # when one can't be.
219
+ def attach_images(text)
220
+ paths = ImageInput.extract(text)
221
+ return [] if paths.empty?
222
+
223
+ @link.ingest_images(paths.map { |image| image[:path] })
224
+ rescue ImageStore::Error => e
225
+ @screen.commit("could not attach the image: #{e.message}")
226
+ nil
227
+ end
228
+
229
+ # --model (or --llm-context, --thinking) on a resumed session: run it in its
230
+ # worker before the first prompt goes; its :command_ran decides
231
+ # (#first_command_ran), and sends the next one.
232
+ # @return [Symbol, nil] :failed when the worker didn't take it
233
+ def send_first_command
234
+ line = @first_commands.shift
235
+ @first_command_line = line
236
+ reply = @client.post_command(line: line, client_id: @client_id)
237
+ if reply.status == 202
238
+ @first_command_id = reply.json&.fetch("command_id", nil)
239
+ return nil if @first_command_id
240
+ end
241
+
242
+ why =
243
+ if reply.status == 404 then stale_worker("run commands")
244
+ elsif (name = older_worker_command(reply, line)) then stale_worker("run #{name}")
245
+ elsif too_late?(reply) then ExitLines::LATE
246
+ else "the worker answered #{reply.status}"
247
+ end
248
+ @screen.commit("#{first_command_failed}: #{why}")
249
+ :failed
250
+ rescue SystemCallError, IOError => e
251
+ @screen.commit("#{first_command_failed}: #{worker_down(e)}")
252
+ :failed
253
+ end
254
+
255
+ # @return [Symbol, nil] :failed unless the command went through
256
+ def first_command_ran(event)
257
+ @first_command_id = nil
258
+ if event[:status] == "ok"
259
+ return send_first_command unless @first_commands.empty?
260
+
261
+ send_first_prompt
262
+ return nil
263
+ end
264
+
265
+ @screen.commit("#{first_command_failed}: #{event[:output]}")
266
+ :failed
267
+ end
268
+
269
+ def first_command_failed
270
+ flag = first_command_flag
271
+ flag == "--model" ? "could not switch to the --model" : "could not set the #{flag}"
272
+ end
273
+
274
+ # The launch flag the first command came from: "/model X" is --model,
275
+ # "/llm-context …" --llm-context, "/thinking X --yes" --thinking.
276
+ def first_command_flag
277
+ "--#{@first_command_line.to_s[%r{\A/([\w-]+)}, 1]}"
278
+ end
279
+
280
+ # After the join, so it lands below what the session already had. Our
281
+ # own prompts are never echoed from events (a typed one stays on screen),
282
+ # so it is shown here as if typed.
283
+ def send_first_prompt
284
+ text = @first_prompt.to_s.strip
285
+ @first_prompt = nil
286
+ return if text.empty?
287
+
288
+ @screen.commit("#{paint(PROMPT, 92)}#{text}")
289
+ # As if typed: a session command runs as the command; a typo of one
290
+ # gets the hint, not a turn.
291
+ if command_registry.command?(text)
292
+ send_command(text)
293
+ elsif (hint = command_registry.unknown_command_hint(text))
294
+ @screen.commit(hint)
295
+ else
296
+ send_prompt(text)
297
+ end
298
+ end
299
+ end
300
+ end
301
+ end
302
+ end