samagotchi 0.62.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -1
  3. data/README.md +4 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +12 -2
  6. data/docs/cli.md +20 -9
  7. data/docs/configuration.md +16 -9
  8. data/docs/context.md +13 -3
  9. data/docs/hooks.md +6 -3
  10. data/docs/internals/engine.md +10 -3
  11. data/docs/memory.md +1 -1
  12. data/docs/plugins.md +96 -2
  13. data/docs/releasing.md +1 -1
  14. data/docs/sessions.md +11 -8
  15. data/docs/sub-agent.md +27 -4
  16. data/docs/testing.md +13 -4
  17. data/lib/samagotchi/answer_command.rb +5 -1
  18. data/lib/samagotchi/answer_refs.rb +18 -26
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  20. data/lib/samagotchi/bridge.rb +41 -5
  21. data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
  22. data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
  23. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
  25. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
  26. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
  29. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
  31. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  32. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  33. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  34. data/lib/samagotchi/child_reports.rb +7 -2
  35. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  36. data/lib/samagotchi/composer_route.rb +90 -0
  37. data/lib/samagotchi/config.rb +6 -3
  38. data/lib/samagotchi/context_absorber.rb +25 -6
  39. data/lib/samagotchi/continue_offer.rb +17 -3
  40. data/lib/samagotchi/delivery.rb +13 -8
  41. data/lib/samagotchi/engine.rb +2 -0
  42. data/lib/samagotchi/host_config.rb +6 -6
  43. data/lib/samagotchi/host_model.rb +2 -2
  44. data/lib/samagotchi/idle_recap.rb +2 -3
  45. data/lib/samagotchi/image_store.rb +8 -0
  46. data/lib/samagotchi/interrupted_turn.rb +7 -3
  47. data/lib/samagotchi/last_turn.rb +5 -2
  48. data/lib/samagotchi/pending_input_queue.rb +10 -3
  49. data/lib/samagotchi/plugin/attached_context.rb +7 -4
  50. data/lib/samagotchi/plugin/sessions.rb +27 -5
  51. data/lib/samagotchi/recap_setup.rb +3 -3
  52. data/lib/samagotchi/reply_wait.rb +171 -6
  53. data/lib/samagotchi/row_refs.rb +3 -4
  54. data/lib/samagotchi/send_command.rb +63 -19
  55. data/lib/samagotchi/session.rb +12 -0
  56. data/lib/samagotchi/session_commands.rb +1 -1
  57. data/lib/samagotchi/session_inbox.rb +63 -7
  58. data/lib/samagotchi/session_manager.rb +49 -20
  59. data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +73 -35
  61. data/lib/samagotchi/terminal_ui/formatting.rb +3 -19
  62. data/lib/samagotchi/terminal_ui.rb +103 -68
  63. data/lib/samagotchi/tool_activity.rb +5 -5
  64. data/lib/samagotchi/tools/delegate.rb +28 -1
  65. data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
  66. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  67. data/lib/samagotchi/tools/execute.rb +33 -8
  68. data/lib/samagotchi/turn_checkpoint.rb +9 -3
  69. data/lib/samagotchi/version.rb +1 -1
  70. data/lib/samagotchi/web/app.rb +6 -4
  71. data/lib/samagotchi/web/public/activity.js +2 -2
  72. data/lib/samagotchi/web/public/app.js +53 -91
  73. data/lib/samagotchi/web/public/card.js +73 -23
  74. data/lib/samagotchi/web/public/chains.js +6 -4
  75. data/lib/samagotchi/web/public/command_view.js +39 -35
  76. data/lib/samagotchi/web/public/commands.js +8 -8
  77. data/lib/samagotchi/web/public/composer.js +3 -3
  78. data/lib/samagotchi/web/public/copy.js +7 -15
  79. data/lib/samagotchi/web/public/diff_view.js +20 -23
  80. data/lib/samagotchi/web/public/format.js +0 -48
  81. data/lib/samagotchi/web/public/images.js +12 -16
  82. data/lib/samagotchi/web/public/question_cards.js +3 -2
  83. data/lib/samagotchi/web/public/read_view.js +10 -9
  84. data/lib/samagotchi/web/public/scope.js +23 -0
  85. data/lib/samagotchi/web/public/timing.js +2 -8
  86. data/lib/samagotchi/web/public/turn_html.js +119 -59
  87. data/lib/samagotchi/web/public/turn_model.js +13 -3
  88. data/lib/samagotchi/web/public/turn_view.js +73 -116
  89. data/lib/samagotchi/web/public/vendor/directive.js +8 -0
  90. data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
  91. data/lib/samagotchi/worker.rb +53 -35
  92. data/lib/samagotchi/worker_inbound.rb +12 -11
  93. metadata +9 -1
@@ -158,11 +158,39 @@ module Samagotchi
158
158
  nil
159
159
  end
160
160
 
161
- def self.write_output(session_dir, response)
161
+ # A turn's reply: output/<timestamp>.txt, or
162
+ # output/<timestamp>-<enqueued_id>.txt for a turn that ran an input
163
+ # with an id, so a wait for that input's turn (ReplyWait's turn:) finds
164
+ # its own reply. The names still sort by time ("-" sorts before ".",
165
+ # so an id'd name and a bare one of the same timestamp keep order);
166
+ # every reader globs *.txt.
167
+ # @param enqueued_id [String, nil] the turn's input id (LastTurn#enqueued_id)
168
+ def self.write_output(session_dir, response, enqueued_id: nil)
162
169
  output_dir = File.join(session_dir, OUTPUT_DIR)
163
170
  FileUtils.mkdir_p(output_dir)
164
171
  timestamp = Time.now.strftime("%Y%m%d%H%M%S%9N")
165
- AtomicFile.write(File.join(output_dir, "#{timestamp}.txt"), response.to_s)
172
+ AtomicFile.write(File.join(output_dir, "#{output_name(timestamp, enqueued_id)}.txt"), response.to_s)
173
+ end
174
+
175
+ # An id that is safe in a file name (a Bridge's or deliver_turn's
176
+ # uuid); anything else leaves the name bare.
177
+ ENQUEUED_ID_NAME = /\A[A-Za-z0-9_-]{1,64}\z/
178
+
179
+ def self.output_name(timestamp, enqueued_id)
180
+ id = enqueued_id.to_s
181
+ ENQUEUED_ID_NAME.match?(id) ? "#{timestamp}-#{id}" : timestamp
182
+ end
183
+ private_class_method :output_name
184
+
185
+ # @return [String, nil] the reply filename of the turn that ran the
186
+ # input +enqueued_id+, or nil while it has none
187
+ def self.reply_for(session_dir, enqueued_id)
188
+ dir = File.join(session_dir, OUTPUT_DIR)
189
+ id = enqueued_id.to_s
190
+ return nil if id.empty? || !Dir.exist?(dir)
191
+
192
+ suffix = "-#{id}.txt"
193
+ Dir.children(dir).select { |f| f.end_with?(suffix) }.max
166
194
  end
167
195
 
168
196
  # The output files' text, oldest first; with +since_time+ only files
@@ -218,12 +246,40 @@ module Samagotchi
218
246
  end
219
247
 
220
248
  # Whether a mid-turn drain leaves this unclaimed input file for a turn
221
- # of its own instead of merging it at a step boundary. Today that is an
222
- # image message (steering merges text only); the drain's file filter
223
- # reads this one predicate, so what a boundary refuses changes in one
224
- # place.
249
+ # of its own instead of merging it at a step boundary: a queued message
250
+ # (`delivery: queue`) or an image message (steering merges text only).
251
+ # The drain skips such a file without stopping at it, so a later text
252
+ # line still merges; the drain's file filter reads this one predicate,
253
+ # so what a boundary leaves changes in one place.
225
254
  def self.waits_for_turn_end?(input_file)
226
- input_has_images?(input_file)
255
+ data = JSON.parse(File.read(input_file))
256
+ data.is_a?(Hash) && (Array(data["images"]).any? || Delivery.queue?(data["delivery"]))
257
+ rescue JSON::ParserError, SystemCallError
258
+ false
259
+ end
260
+
261
+ # Whether an input file carrying +enqueued_id+ still waits unclaimed in
262
+ # input/ (no worker has taken it for a turn or a step yet).
263
+ def self.input_waiting?(session_dir, enqueued_id) = !waiting_input(session_dir, enqueued_id).nil?
264
+
265
+ # How the unclaimed input file carrying +enqueued_id+ waits: :turn_end
266
+ # (a queue or an image message: it runs as a turn of its own, never
267
+ # merged, #waits_for_turn_end?), :merges (a running turn's drain may
268
+ # take it as a steer), or nil when none waits.
269
+ # @return [Symbol, nil]
270
+ def self.waiting_input(session_dir, enqueued_id)
271
+ id = enqueued_id.to_s
272
+ return nil if id.empty?
273
+
274
+ find_new_input_files(session_dir).each do |file|
275
+ data = JSON.parse(File.read(file))
276
+ next unless data.is_a?(Hash) && data["enqueued_id"] == id
277
+
278
+ return Array(data["images"]).any? || Delivery.queue?(data["delivery"]) ? :turn_end : :merges
279
+ rescue JSON::ParserError, SystemCallError
280
+ next
281
+ end
282
+ nil
227
283
  end
228
284
 
229
285
  def self.claim_input_file(input_file)
@@ -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" } }
@@ -46,12 +46,15 @@ module Samagotchi
46
46
  # @param thinking [String, nil] --thinking as typed: a new session
47
47
  # starts with it as its own level; a resumed or attached one's
48
48
  # worker gets a /thinking, after those
49
+ # @param state_dir [String, nil] where the sessions are (the default
50
+ # state dir by default): the worker's, for the attached loop too
49
51
  # @return [Symbol] :detached, :closed when the worker went away,
50
52
  # :failed when the --model switch didn't go through, or (input from a
51
53
  # pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
52
54
  def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
53
- memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [])
54
- client = connect(attach: attach, shared: shared, resume: resume, model: model,
55
+ memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], state_dir: nil)
56
+ state_dir ||= Session.default_state_dir
57
+ client = connect(attach: attach, shared: shared, resume: resume, model: model, state_dir: state_dir,
55
58
  memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking,
56
59
  appended_prompt_files: appended_prompt_files)
57
60
  commands = []
@@ -65,7 +68,7 @@ module Samagotchi
65
68
  first_command: first_command, no_interrupt: no_interrupt,
66
69
  default_input: default_input && !prompt && !attach && !resume,
67
70
  wait_at_eof: !$stdin.tty?,
68
- parent_answers: Guardrails::ParentApprovals.parent_process?)
71
+ parent_answers: Guardrails::ParentApprovals.parent_process?, state_dir: state_dir)
69
72
  ended = begin
70
73
  attached.run
71
74
  ensure
@@ -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"
@@ -100,14 +101,18 @@ module Samagotchi
100
101
  # (the -p one too) have had their turns; if one failed, #run says so
101
102
  # @param children_counts [#call] session id -> its delegates'
102
103
  # ChildrenStatus::Counts, the status row's children segment
104
+ # @param state_dir [String] the state dir the worker was found in: its
105
+ # session folder (@path images, saved records) is under it
103
106
  def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
104
107
  default_input: false, wait_at_eof: false, parent_answers: false,
105
108
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
106
109
  delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
107
110
  archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) },
108
111
  installed_version: -> { InstalledVersions.new.newest },
109
- children_counts: ->(id) { ChildrenStatus.counts(id, state_dir: Session.default_state_dir) })
112
+ state_dir: Session.default_state_dir,
113
+ children_counts: ->(id) { ChildrenStatus.counts(id, state_dir: state_dir) })
110
114
  @client = client
115
+ @state_dir = state_dir
111
116
  @installed_version = installed_version
112
117
  @delete_session = delete_session
113
118
  @archive_session = archive_session
@@ -342,7 +347,7 @@ module Samagotchi
342
347
  id = @client.session_id
343
348
  return :gone if Session.stopped_marker?(id)
344
349
 
345
- BridgeClient.discover(id, session_dir: Session.session_dir(id), host: @client.host)
350
+ BridgeClient.discover(id, session_dir: Session.session_dir(id, state_dir: @state_dir), host: @client.host)
346
351
  end
347
352
 
348
353
  # The stream moved to a new worker (a restart): requests go there too.
@@ -383,61 +388,72 @@ module Samagotchi
383
388
  end
384
389
 
385
390
  # /cut TEXT and /queue TEXT are this UI's own deliveries, read before
386
- # every command check (SessionCommands registers them for Tab and
387
- # /help only). A plain line sends no delivery: it goes in at the
388
- # running turn's next step, and never cuts.
389
- if (choice = steer_choice(text))
390
- return submit_delivery(*choice)
391
- 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
392
396
 
393
397
  # The terminal's own commands (SessionCommands registers them, the
394
398
  # REPL reads the same words). Before the continue offer: the
395
399
  # leaving ones are no answer to it.
396
- local = command_registry.lookup_local(text)&.id
400
+ local = route.local
397
401
  return submit(nil) if local == :detach
398
402
  return SessionCommands.delete_on_exit?(text) ? exit_and_delete : exit_worker if local == :exit
399
403
  return archive_after_turn if local == :archive && (@running || !@queued_ids.empty?)
400
404
  return exit_and_archive if local == :archive
401
405
 
402
406
  if @continue_offer
403
- return answer_continue(text) unless local || command_registry.command?(text)
407
+ return answer_continue(text) unless local || route.kind == :command
404
408
 
405
409
  # Not an answer: the read left no echo, so show what ran.
406
410
  echo_answer(text)
407
411
  end
408
- return if text.empty?
412
+ return if route.kind == :empty
409
413
 
410
414
  if local == :stats
411
415
  show_stats
412
416
  elsif local == :recap
413
417
  show_recap
414
- elsif command_registry.command?(text)
418
+ elsif route.kind == :command
415
419
  # The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
416
420
  persist_recent_history(text) if PromptHistory.shell_line?(text)
417
421
  send_command(text)
418
- elsif (hint = command_registry.unknown_command_hint(text))
422
+ elsif route.kind == :typo
419
423
  # A typo of a command (/modle) is not a prompt: say so, send nothing.
420
- @screen.commit(hint)
424
+ @screen.commit(route.reply)
421
425
  else
422
426
  send_prompt(text)
423
427
  end
424
428
  nil
425
429
  end
426
430
 
427
- # /cut TEXT and /queue TEXT while a turn runs. Only /cut goes in P1:
428
- # the worker's drain doesn't skip queued files yet, so a queued
429
- # message would steer the running turn; /queue says so and sends
430
- # 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]
431
439
  # @return [nil]
432
- def submit_delivery(choice, text)
433
- return @screen.commit(STEER_CHOICE_USAGE) if text.empty?
434
- 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?
435
443
 
436
444
  # To an idle session it is an ordinary prompt: no delivery to send.
437
- send_prompt(text, delivery: @running ? Delivery::CUT : nil)
445
+ send_prompt(route.text, delivery: route.delivery)
438
446
  nil
439
447
  end
440
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
+
441
457
  # @return [Symbol] :detach
442
458
  def detach(line)
443
459
  @view.finish_thinking_spinner
@@ -597,9 +613,9 @@ module Samagotchi
597
613
  end
598
614
 
599
615
  QUEUED_NOTE = "(queued: runs after this turn)"
600
- # /queue needs a worker whose drain skips queued input files (P3):
601
- # until then a queued message would steer the running turn.
602
- 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)"
603
619
 
604
620
  # A command queued for after the running turn (command_queued with
605
621
  # waits): its line now (another client's) and a note; its command_ran
@@ -683,7 +699,11 @@ module Samagotchi
683
699
  end
684
700
  @continue_answer = nil
685
701
  @screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome && !asked
686
- sync_prompt
702
+ # Back to the > prompt: with the text a question set aside meanwhile
703
+ # (#close_question), else what the ? read holds.
704
+ set_aside = @set_aside
705
+ @set_aside = nil
706
+ set_aside ? sync_prompt(keep_text: false, prefill: set_aside) : sync_prompt
687
707
  end
688
708
 
689
709
  # The notes slot shows the offer's choices while it waits (a question
@@ -701,14 +721,20 @@ module Samagotchi
701
721
  # What a prompt's 202 says became of it, or nil when there is nothing
702
722
  # to say: the dim line both TUIs print for a delivery. A plain line
703
723
  # for a running turn goes in at its next step; a cut's outcome comes
704
- # 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).
705
726
  # An idle session's prompt simply runs, and a prompt with images runs
706
727
  # as its own next turn (never merging mid-turn), so nothing is said.
707
728
  # @param ack [Hash, nil] the Bridge's 202 body
708
729
  # @param delivery [String, nil] what was asked for
709
730
  # @return [String, nil]
710
731
  def sent_note(ack, delivery: nil, images: [], running: false)
711
- 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?
712
738
 
713
739
  return NEXT_STEP_NOTE unless delivery == Delivery::CUT
714
740
 
@@ -740,6 +766,8 @@ module Samagotchi
740
766
  detail = reply.json&.fetch("error", nil)
741
767
  # Read after its deadline and dropped (BridgeClient::DEADLINE_SHARE).
742
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"
743
771
 
744
772
  @screen.commit("could not send the prompt (#{[reply.status, detail].compact.join(" ")})")
745
773
  rescue SystemCallError, IOError => e
@@ -802,7 +830,7 @@ module Samagotchi
802
830
  paths = ImageInput.extract(text)
803
831
  return [] if paths.empty?
804
832
 
805
- session_dir = Session.session_dir(@client.session_id)
833
+ session_dir = Session.session_dir(@client.session_id, state_dir: @state_dir)
806
834
  paths.map do |image|
807
835
  ref = ImageStore.ingest(session_dir, path: image[:path])
808
836
  { file: ref[:file], name: ref[:name] }
@@ -865,7 +893,9 @@ module Samagotchi
865
893
 
866
894
  @view.finish_thinking_spinner
867
895
  # What was typed at the prompt waits for the question to close.
868
- @set_aside = @reader&.typed_text unless @question
896
+ # Not over text already set aside (a question that closed while a
897
+ # continue offer waits): the ? read holds an answer then, if anything.
898
+ @set_aside = @reader&.typed_text.to_s.then { |typed| typed.empty? ? nil : typed } unless @question || @set_aside
869
899
  @question = QuestionPrompt.new(pending)
870
900
  @question_pending = pending
871
901
  # The step-limit question stands for the continue offer's own slot
@@ -987,10 +1017,14 @@ module Samagotchi
987
1017
  @screen.clear_slot(:notes)
988
1018
  @screen.commit(@question.summary(outcome, paint: method(:paint))) if outcome
989
1019
  @question = nil
1020
+ # A continue offer still waiting keeps the ? prompt (the step-limit
1021
+ # question closes before its offer resolves): the text set aside
1022
+ # waits for the > prompt (#continue_resolved).
1023
+ return sync_continue_slot if @continue_offer
1024
+
990
1025
  set_aside = @set_aside
991
1026
  @set_aside = nil
992
1027
  sync_prompt(keep_text: false, prefill: set_aside)
993
- sync_continue_slot if @continue_offer
994
1028
  end
995
1029
 
996
1030
  # Restart the open read when its prompt no longer fits (a question or a
@@ -1195,7 +1229,10 @@ module Samagotchi
1195
1229
  return unless last_user
1196
1230
 
1197
1231
  @screen.commit(join_prompt_line(exchange[last_user]))
1198
- Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
1232
+ # An image-only turn's text names its images already.
1233
+ unless ImageRef.placeholder_only?(exchange[last_user][:content])
1234
+ Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
1235
+ end
1199
1236
  from = messages.index { |m| m.equal?(exchange[last_user]) }
1200
1237
  render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
1201
1238
  failed = join_failed_record(exchange[last_user])
@@ -1244,7 +1281,7 @@ module Samagotchi
1244
1281
  # machine's state dir, as #attach_images): none for a prompt with no
1245
1282
  # turn id (an older session) or a session with no analytics.
1246
1283
  def join_tool_records(prompt)
1247
- SessionMetrics.saved_tool_records(Session.session_dir(@client.session_id), prompt[:turn_id] || prompt["turn_id"])
1284
+ SessionMetrics.saved_tool_records(Session.session_dir(@client.session_id, state_dir: @state_dir), prompt[:turn_id] || prompt["turn_id"])
1248
1285
  rescue Session::InvalidId
1249
1286
  []
1250
1287
  end
@@ -1254,7 +1291,7 @@ module Samagotchi
1254
1291
  # canceled or older turn — or one saved before the failure field —
1255
1292
  # draws its answer as before.
1256
1293
  def join_failed_record(prompt)
1257
- record = SessionMetrics.saved_turn_record(Session.session_dir(@client.session_id),
1294
+ record = SessionMetrics.saved_turn_record(Session.session_dir(@client.session_id, state_dir: @state_dir),
1258
1295
  prompt[:turn_id] || prompt["turn_id"])
1259
1296
  return nil unless record && record["status"] == "failed" && record["failure"].is_a?(Hash)
1260
1297
 
@@ -1394,7 +1431,8 @@ module Samagotchi
1394
1431
  return if own?(event[:client_id])
1395
1432
 
1396
1433
  @shown_enqueued << event[:enqueued_id]
1397
- @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)
1398
1436
  end
1399
1437
 
1400
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 = "■"