samagotchi 0.20.0 → 0.21.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -1
  3. data/docs/architecture.md +2 -1
  4. data/docs/cli.md +2 -1
  5. data/docs/plugins.md +22 -8
  6. data/docs/sessions.md +2 -2
  7. data/lib/samagotchi/bridge/sse_writer.rb +32 -0
  8. data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
  9. data/lib/samagotchi/bridge.rb +16 -4
  10. data/lib/samagotchi/bridge_client.rb +33 -9
  11. data/lib/samagotchi/bundle_command.rb +6 -0
  12. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/engine.rb +15 -2
  16. data/lib/samagotchi/events.rb +1 -1
  17. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  18. data/lib/samagotchi/llm/openai_chat.rb +19 -5
  19. data/lib/samagotchi/log_subscriber.rb +4 -2
  20. data/lib/samagotchi/session_manager.rb +26 -9
  21. data/lib/samagotchi/session_metrics.rb +6 -4
  22. data/lib/samagotchi/steer.rb +1 -1
  23. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
  24. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
  25. data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
  26. data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
  27. data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
  28. data/lib/samagotchi/version.rb +1 -1
  29. data/lib/samagotchi/web/public/app.js +34 -7
  30. data/lib/samagotchi/web/public/index.html +7 -0
  31. data/lib/samagotchi/web/public/notifications.js +8 -3
  32. data/lib/samagotchi/web/public/notify.js +49 -7
  33. data/lib/samagotchi/web/public/timing.js +5 -3
  34. data/lib/samagotchi/web/public/turn_events.js +16 -0
  35. metadata +1 -1
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.20.0
3
+ version: 0.21.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -1637,12 +1637,25 @@ module Samagotchi
1637
1637
  def stopped_before_model(turn)
1638
1638
  reason = turn.controller.reason
1639
1639
  end_turn(turn, "canceled") do |seconds|
1640
- [nil, { type: :turn_canceled, cancellation_reason: reason, duration_ms: (seconds * 1000).round }]
1640
+ [nil, { type: :turn_canceled, cancellation_reason: reason, cancelled_by: cancelled_by(turn),
1641
+ duration_ms: (seconds * 1000).round }]
1641
1642
  end
1642
1643
  LLM::ModelResult.new(text: "", canceled: true, cancellation_reason: reason)
1643
1644
  end
1644
1645
  private :stopped_before_model
1645
1646
 
1647
+ # Who stopped a turn a hook stopped or cut (the controller's detail: the
1648
+ # bundle, or the hook's label), for the UIs' "stopped by loop-guard";
1649
+ # nil for any other cancel.
1650
+ def cancelled_by(turn)
1651
+ return nil unless turn.controller.reason == :hook
1652
+
1653
+ detail = turn.controller.detail
1654
+ by = detail[:by].to_s if detail.is_a?(Hash)
1655
+ by unless by.nil? || by.empty?
1656
+ end
1657
+ private :cancelled_by
1658
+
1646
1659
  # The turn's settings for the kernel (LLM::TurnSettings): vision,
1647
1660
  # sampling and thinking, the thinking resolved before the hooks run.
1648
1661
  # The model name is added in #generate, after the hooks that may switch
@@ -1744,7 +1757,7 @@ module Samagotchi
1744
1757
  end_turn(turn, canceled ? "canceled" : "completed", exhausted: !canceled && result.exhausted?) do |seconds|
1745
1758
  kept = kept_messages(turn, result, seconds)
1746
1759
  if canceled
1747
- [kept, { type: :turn_canceled, cancellation_reason: result.cancellation_reason,
1760
+ [kept, { type: :turn_canceled, cancellation_reason: result.cancellation_reason, cancelled_by: cancelled_by(turn),
1748
1761
  duration_ms: (seconds * 1000).round }]
1749
1762
  else
1750
1763
  # For a client that attaches later (session_state_snapshot).
@@ -13,7 +13,7 @@ module Samagotchi
13
13
  TURN_KEPT = %i[turn_completed turn_canceled].freeze
14
14
  # The kinds of a running turn's parts (Bridge::TurnAccumulator), which a
15
15
  # joining UI replays as live events.
16
- PART_KINDS = %w[thinking text tool input steer reminder notice].freeze
16
+ PART_KINDS = %w[generation thinking text tool input steer reminder notice].freeze
17
17
  # Types no Engine emits that a UI handles: the Bridge's stream frames
18
18
  # and the web's one synthetic replay event (a prompt merged into the
19
19
  # turn, turn_events.js snapshotEvents).
@@ -438,7 +438,7 @@ module Samagotchi
438
438
  record_context_status(response.usage, window)
439
439
  emit(type: :generation_completed, iteration: iteration, content_length: response.text.length,
440
440
  thinking_chars: response.reasoning.to_s.length, served_model: response.model,
441
- requested_model: @model_name, finish_reason: response.finish_reason)
441
+ served_provider: response.provider, requested_model: @model_name, finish_reason: response.finish_reason)
442
442
  dump_response(response, iteration)
443
443
  @loop.fire_hook(:after_generation, { type: :after_generation, iteration: iteration, response: response.text,
444
444
  messages: AnswerDisplay.strip_all(@conversation).map(&:dup).freeze })
@@ -35,10 +35,12 @@ module Samagotchi
35
35
  # ([] when none), usage (never nil) and the finish reason.
36
36
  # +model+ is the model the server says answered (the body's `model`),
37
37
  # which can differ from the one asked for; nil when it says nothing.
38
+ # +provider+ is the provider that served it (the body's `provider`,
39
+ # OpenRouter's routing name); nil when the server says nothing.
38
40
  # +cut+ is set on the empty response the chat loop makes for a
39
41
  # generation a plugin cut (stop_generation): {by:, reason:}.
40
- ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model, :cut) do
41
- def initialize(model: nil, cut: nil, **fields) = super
42
+ ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model, :provider, :cut) do
43
+ def initialize(model: nil, provider: nil, cut: nil, **fields) = super
42
44
  end
43
45
 
44
46
  # The OpenAI Chat Completions API (llama.cpp's /v1, and any compatible
@@ -248,7 +250,8 @@ module Samagotchi
248
250
  ChatResponse.new(text: OpenAIChat.drop_leading_newlines(message["content"].to_s),
249
251
  reasoning: (message["reasoning_content"] || message["reasoning"]).to_s,
250
252
  tool_calls: calls, usage: Usage.from_payload(body) || Usage.none,
251
- finish_reason: body.dig("choices", 0, "finish_reason"), model: served_model(body))
253
+ finish_reason: body.dig("choices", 0, "finish_reason"), model: served_model(body),
254
+ provider: self.class.served_provider(body))
252
255
  end
253
256
 
254
257
  # Workaround for Splash 1.0.2/1.1.0, which keeps the "\n\n" the model
@@ -265,6 +268,15 @@ module Samagotchi
265
268
 
266
269
  def served_model(payload) = self.class.served_model(payload)
267
270
 
271
+ # OpenRouter names the provider that served a request (Baseten,
272
+ # Fireworks, …) in the body's `provider`; other servers send none.
273
+ def self.served_provider(payload)
274
+ provider = payload.is_a?(Hash) ? payload["provider"] : nil
275
+ provider.is_a?(String) && !provider.strip.empty? ? provider : nil
276
+ end
277
+
278
+ def served_provider(payload) = self.class.served_provider(payload)
279
+
268
280
  # The parsed payload of a `data:` line; nil for blank lines, comments
269
281
  # and [DONE]. Error events raise their ProviderError.
270
282
  # A 200 whose body is a plain JSON error instead of a stream (OpenRouter
@@ -352,7 +364,7 @@ module Samagotchi
352
364
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
353
365
 
354
366
  # Streamed deltas put together: text, reasoning, tool calls by index,
355
- # the finish reason, the usage chunk and the served model.
367
+ # the finish reason, the usage chunk, the served model and provider.
356
368
  class Assembly
357
369
  # The chunk carries part of a tool call.
358
370
  def self.tool_call_delta?(payload)
@@ -367,12 +379,14 @@ module Samagotchi
367
379
  @finish_reason = nil
368
380
  @usage = nil
369
381
  @model = nil
382
+ @provider = nil
370
383
  end
371
384
 
372
385
  # @return [Array(String, String)] this chunk's content and reasoning
373
386
  def add(payload)
374
387
  @usage = Usage.from_payload(payload) || @usage if payload.key?("usage")
375
388
  @model = OpenAIChat.served_model(payload) || @model
389
+ @provider = OpenAIChat.served_provider(payload) || @provider
376
390
  choice = payload["choices"].is_a?(Array) ? payload["choices"].first : nil
377
391
  return ["", ""] unless choice.is_a?(Hash)
378
392
 
@@ -392,7 +406,7 @@ module Samagotchi
392
406
  ToolCall.new(id: call[:id], name: call[:name].to_s, arguments: OpenAIChat.parse_arguments(call[:arguments]))
393
407
  end
394
408
  ChatResponse.new(text: @text, reasoning: @reasoning, tool_calls: calls, usage: @usage || Usage.none,
395
- finish_reason: @finish_reason, model: @model)
409
+ finish_reason: @finish_reason, model: @model, provider: @provider)
396
410
  end
397
411
 
398
412
  private
@@ -82,7 +82,8 @@ module Samagotchi
82
82
  end
83
83
 
84
84
  def on_turn_canceled(event)
85
- log(:info, :turn_canceled, ms: since(@turn_started_at), reason: event[:cancellation_reason], **origin(event))
85
+ log(:info, :turn_canceled, ms: since(@turn_started_at), reason: event[:cancellation_reason], by: event[:cancelled_by],
86
+ **origin(event))
86
87
  end
87
88
 
88
89
  def on_turn_failed(event)
@@ -100,7 +101,8 @@ module Samagotchi
100
101
  def on_generation_completed(event)
101
102
  ms = since(@generation_started_at.delete(event[:iteration]))
102
103
  log(:info, :generation_completed, iteration: event[:iteration], ms: ms,
103
- served_model: event[:served_model], requested_model: event[:requested_model],
104
+ served_model: event[:served_model], provider: event[:served_provider],
105
+ requested_model: event[:requested_model],
104
106
  content_length: event[:content_length],
105
107
  thinking_chars: event[:thinking_chars], finish_reason: event[:finish_reason],
106
108
  stopped_by: event[:stopped_by])
@@ -24,6 +24,7 @@ require_relative "installed_gem"
24
24
  require_relative "installed_versions"
25
25
  require_relative "recap_store"
26
26
  require_relative "archive_store"
27
+ require_relative "continue_offer"
27
28
  require_relative "image_store"
28
29
  require_relative "plugin_session_state"
29
30
  require_relative "terminal_ui"
@@ -105,6 +106,10 @@ module Samagotchi
105
106
  case @reason
106
107
  when :scratch then "a scratch session is deleted when you leave; nothing to archive"
107
108
  when :busy_child then "its delegate #{@busy_id[0, 8]} is running a turn; wait for it or stop it first"
109
+ when :queued then "a prompt is queued, which a resume would run; let it run first"
110
+ when :queued_child then "its delegate #{@busy_id[0, 8]} has a prompt queued, which a resume would run; let it run first"
111
+ when :offer then "the step-limit question waits for an answer; answer Continue or Stop first"
112
+ when :offer_child then "its delegate #{@busy_id[0, 8]} waits for an answer to its step-limit question; answer it first"
108
113
  else "a turn is running; wait for it or cancel it first"
109
114
  end
110
115
  end
@@ -309,8 +314,9 @@ module Samagotchi
309
314
  # discarded: empty sessions their stopping worker deleted
310
315
  # @raise [ArgumentError] unknown id (Session::AmbiguousId for a prefix of several)
311
316
  # @raise [OwnedByTUI] a chi REPL owns it or one of its children
312
- # @raise [ArchiveRefused] a turn runs in it or in a child, or it is a
313
- # scratch session
317
+ # @raise [ArchiveRefused] a turn runs in it or in a child, a prompt is
318
+ # queued in one (a later resume would run it), a live one offers to
319
+ # continue past the step limit, or it is a scratch session
314
320
  def self.archive_session(id_or_prefix, state_dir: nil, wait: 5)
315
321
  sd = state_dir || Session.default_state_dir
316
322
  id = archive_target(id_or_prefix, sd)
@@ -319,13 +325,8 @@ module Samagotchi
319
325
  tree = [id, *descendant_ids(id, sd)]
320
326
  owners = tree.to_h { |sid| [sid, session_owner(sid, state_dir: sd)] }
321
327
  tree.each do |sid|
322
- owner = owners[sid]
323
- next unless owner
324
-
325
- raise OwnedByTUI, sid if owner.tui?
326
- next unless Session.load(sid, state_dir: sd).status == Session::STATUS_RUNNING
327
-
328
- raise ArchiveRefused.new(id, sid == id ? :busy : :busy_child, busy_id: sid)
328
+ refusal = archive_refusal(sid, owners[sid], sd)
329
+ raise ArchiveRefused.new(id, sid == id ? refusal : :"#{refusal}_child", busy_id: sid) if refusal
329
330
  end
330
331
 
331
332
  stopped = tree.select { |sid| owners[sid] }
@@ -334,6 +335,22 @@ module Samagotchi
334
335
  { id: id, archived: archived, stopped: stopped, discarded: discarded }
335
336
  end
336
337
 
338
+ # What keeps +sid+ (in an archive's tree) from being archived: :busy (a
339
+ # turn runs), :queued (input/ holds a prompt, which outlives the stop and
340
+ # runs on a resume, live worker or not), :offer (a live worker's continue
341
+ # offer, which the stop would drop), or nil.
342
+ # @raise [OwnedByTUI] a chi REPL owns it
343
+ def self.archive_refusal(sid, owner, state_dir)
344
+ raise OwnedByTUI, sid if owner&.tui?
345
+
346
+ session = owner && Session.load(sid, state_dir: state_dir)
347
+ return :busy if session&.status == Session::STATUS_RUNNING
348
+ return :queued unless SessionInbox.find_new_input_files(Session.session_dir(sid, state_dir: state_dir)).empty?
349
+
350
+ :offer if session&.waiting_question(live: true)&.dig(:kind) == ContinueOffer::KIND
351
+ end
352
+ private_class_method :archive_refusal
353
+
337
354
  # Unarchive a session and its delegated children.
338
355
  # @return [Hash] {id:, unarchived: [ids that were archived]}
339
356
  # @raise [ArgumentError] unknown id
@@ -277,7 +277,7 @@ module Samagotchi
277
277
  when :turn_completed
278
278
  end_turn(status: "completed")
279
279
  when :turn_canceled
280
- end_turn(status: "canceled", reason: event[:cancellation_reason])
280
+ end_turn(status: "canceled", reason: event[:cancellation_reason], by: event[:cancelled_by])
281
281
  when :turn_failed
282
282
  end_turn(status: "failed")
283
283
  end
@@ -457,9 +457,10 @@ module Samagotchi
457
457
  end
458
458
  end
459
459
 
460
- # A canceled turn's record keeps why (+reason+: "user", "ctrl_c", ...), so
461
- # a reloaded web history can show the cancel line the live one did.
462
- def end_turn(status: "completed", reason: nil)
460
+ # A canceled turn's record keeps why (+reason+: "user", "ctrl_c", ...)
461
+ # and who stopped it (+by+: a hook's bundle), so a reloaded web history
462
+ # can show the cancel line the live one did.
463
+ def end_turn(status: "completed", reason: nil, by: nil)
463
464
  @mutex.synchronize do
464
465
  if @turn
465
466
  # A turn that failed mid-stream never saw its generation end:
@@ -478,6 +479,7 @@ module Samagotchi
478
479
  duration_ms: elapsed_ms(@turn.started_monotonic)
479
480
  }
480
481
  record[:cancellation_reason] = reason.to_s unless reason.nil? || reason.to_s.empty?
482
+ record[:cancelled_by] = by.to_s unless by.nil? || by.to_s.empty?
481
483
  record.merge!(turn_token_fields(@turn))
482
484
  @turn_records << record
483
485
  count_turn(record)
@@ -80,7 +80,7 @@ module Samagotchi
80
80
  # :pending_input_merged. After a cancel the input stays queued: it runs
81
81
  # as the next turn instead of dying with this one. +answer+ (a String,
82
82
  # or a Proc called only on a merge) is the answer the merge follows, for
83
- # the UIs; given, the drain is told it is the after-answer site (plugin
83
+ # the TUI's line (the web has it as the step's streamed text); given, the drain is told it is the after-answer site (plugin
84
84
  # steers are dropped there). A blank answer is none.
85
85
  # @return [Boolean] whether anything was injected
86
86
  def inject!(conversation, pending_input, iteration:, emit:, cancel_controller:, answer: nil)
@@ -47,6 +47,10 @@ module Samagotchi
47
47
  DETACH_HINT = "Ctrl-D to detach, /exit stops the worker"
48
48
  # Why a request the Bridge read after its deadline didn't go through.
49
49
  LATE = "worker not answering in time"
50
+ # An exit the worker agreed to in an empty session. Said before it has
51
+ # left: it checks again once it lets go, and a note or prompt arriving
52
+ # first keeps the session (SessionManager.discard_left_session).
53
+ EMPTY_EXIT = "Detached; the session is empty: it will be discarded if nothing arrives before its worker stops."
50
54
  # Why the worker stayed up after /exit, by the reason it gave.
51
55
  HELD_REASONS = {
52
56
  "turn_running" => "a turn is running", "input_queued" => "prompts are queued",
@@ -420,7 +424,9 @@ module Samagotchi
420
424
  return detach("#{line} Not deleted.")
421
425
  end
422
426
  # An empty session: the worker deletes it as it leaves.
423
- @delete_session.call(id) unless discards?(reply)
427
+ return detach(EMPTY_EXIT) if discards?(reply)
428
+
429
+ @delete_session.call(id)
424
430
  detach("Detached; deleted session #{id}.")
425
431
  rescue SystemCallError, IOError => e
426
432
  detach("#{exit_failed_line(e.message)} Not deleted.")
@@ -429,14 +435,15 @@ module Samagotchi
429
435
  end
430
436
 
431
437
  # /archive: the worker is asked to exit as for /exit, then the session
432
- # is archived. A worker staying up for another UI (or reminders, a
433
- # queued prompt) is stopped by the archive, as the web's archive
434
- # stops it; a turn running refuses it. An empty session: the worker
438
+ # is archived. A worker staying up for another UI (or reminders) is
439
+ # stopped by the archive, as the web's archive stops it; a turn
440
+ # running, a queued prompt or an open continue offer refuses it
441
+ # (SessionManager.archive_session). An empty session: the worker
435
442
  # deletes it as it leaves.
436
443
  def exit_and_archive
437
444
  reply = @client.request_exit(client_id: @client_id)
438
445
  id = @client.session_id
439
- return detach("Detached; the session was empty, so it is discarded.") if reply.status == 200 && discards?(reply)
446
+ return detach(EMPTY_EXIT) if reply.status == 200 && discards?(reply)
440
447
  return detach("#{exit_line(reply)} Not archived.") unless [200, 409].include?(reply.status)
441
448
 
442
449
  result = @archive_session.call(id)
@@ -454,15 +461,14 @@ module Samagotchi
454
461
  error = reply.json&.fetch("error", nil)
455
462
  case reply.status
456
463
  when 200
457
- # Said as the worker agreed to leave; a note coming in before it
458
- # does keeps the session after all (rare, left as is).
459
- return "Detached; the session was empty, so it is discarded." if discards?(reply)
464
+ return EMPTY_EXIT if discards?(reply)
460
465
 
461
466
  "Detached; the session's worker is stopping. Resume with: chi --resume #{id}"
462
467
  when 409
463
468
  reason = reply.json&.fetch("reason", nil).to_s
464
469
  "Detached; the session keeps running (#{HELD_REASONS.fetch(reason, reason)}). Re-attach with: chi --attach #{id}"
465
470
  when 0 then exit_failed_line("no reply")
471
+ when 408 then exit_failed_line(LATE)
466
472
  else
467
473
  # A worker from before the route answers the Bridge's plain 404.
468
474
  return "Detached; this worker runs an older chi and can't be stopped from here: chi sessions stop #{id}" if error == "not_found"
@@ -1029,7 +1035,8 @@ module Samagotchi
1029
1035
  exchange = messages.select { |m| %w[user model assistant].include?(m[:role].to_s) }
1030
1036
  # The id stays findable: the detach line and chi sessions list show it.
1031
1037
  Log.session_id = @client.session_id
1032
- Log.info(:attached, "joined", session: @client.session_id, messages: exchange.size)
1038
+ Log.info(:attached, "joined", session: @client.session_id, messages: exchange.size,
1039
+ notes: messages.count { |m| ContextNote.note?(m) })
1033
1040
  last_user = exchange.rindex { |m| Steer.prompt?(m) }
1034
1041
  render_join_notes(messages)
1035
1042
  return unless last_user
@@ -1166,6 +1173,8 @@ module Samagotchi
1166
1173
  running_tool = nil
1167
1174
  Bridge::TurnAccumulator.replay_events(turn).each do |event|
1168
1175
  case event[:type]
1176
+ # A new step starts its line over, as live (a join during a hold).
1177
+ when :generation_started then tail, lane = nil, :writing
1169
1178
  when :generation_chunk
1170
1179
  tail, lane = event[:thinking].to_s.empty? ? [event[:text], :writing] : [event[:thinking], :thinking]
1171
1180
  when :tool_call_started
@@ -84,7 +84,7 @@ module Samagotchi
84
84
  render_turn_summary(event[:turn_summary]) if event[:turn_summary]
85
85
  when :turn_canceled
86
86
  @view.finish_thinking_spinner
87
- @view.print_line(@view.turn_canceled_line(event[:cancellation_reason], event[:duration_ms]))
87
+ @view.print_line(@view.turn_canceled_line(event[:cancellation_reason], event[:duration_ms], by: event[:cancelled_by]))
88
88
  @view.print_line(@view.turn_end_hint(Formatting::ROLLBACK_HINT)) unless @turn_continues
89
89
  when :turn_failed
90
90
  # A provider error's one-line summary, else the message (an image
@@ -203,8 +203,13 @@ module Samagotchi
203
203
  # started): its partial progress stays in the conversation.
204
204
  ROLLBACK_HINT = "partial progress kept; !rollback restores the pre-turn state"
205
205
 
206
- # "✕ turn canceled (Ctrl-C) · 3.1s"
207
- def turn_canceled_line(reason, duration_ms)
206
+ # "✕ turn canceled (Ctrl-C) · 3.1s"; a hook's stop that names who
207
+ # (+by+, turn_canceled's cancelled_by): "✕ turn stopped by loop-guard"
208
+ def turn_canceled_line(reason, duration_ms, by: nil)
209
+ if reason.to_s == "hook" && !by.to_s.empty?
210
+ return "#{paint("✕", 33)} turn stopped by #{by}#{turn_duration_suffix(duration_ms)}"
211
+ end
212
+
208
213
  label = reason.to_s.empty? ? nil : CANCEL_REASONS.fetch(reason.to_s, reason.to_s)
209
214
  "#{paint("✕", 33)} turn canceled#{" (#{label})" if label}#{turn_duration_suffix(duration_ms)}"
210
215
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "io/console"
3
4
  require_relative "screen"
4
5
  require_relative "reline_seam"
5
6
 
@@ -22,10 +23,48 @@ module Samagotchi
22
23
  screen
23
24
  end
24
25
 
25
- # Hand Reline its own drawing back, then close the surface.
26
- def close(surface)
26
+ # How long #drain_input waits for input still on its way.
27
+ DRAIN_WINDOW = 0.05
28
+ # How long Reline waits for the reply to its cursor query (Reline::ANSI#cursor_pos).
29
+ CURSOR_REPLY_WAIT = 0.5
30
+ CURSOR_REPLY = /\e\[\d+;\d+R/
31
+
32
+ # Hand Reline its own drawing back, close the surface, then drop the
33
+ # input nobody read (#drain_input).
34
+ def close(surface, input: $stdin)
27
35
  RelineSeam.detach(surface)
28
36
  surface.close
37
+ query_at = RelineSeam.unanswered_query_at
38
+ RelineSeam.unanswered_query_at = nil
39
+ drain_input(input, query_at: query_at) if surface.is_a?(Screen)
40
+ end
41
+
42
+ # Reline asks the terminal for the cursor row (ESC[6n) as each read
43
+ # starts; the reply to one cut short at exit, read by nobody, would
44
+ # land at the shell's prompt. What came, or comes within DRAIN_WINDOW,
45
+ # is read and dropped (keys typed in that moment too), in raw mode, so
46
+ # a reply with no newline is readable. With a query left unanswered
47
+ # (RelineSeam.unanswered_query_at), until its reply, up to as long as
48
+ # Reline would have waited for it.
49
+ def drain_input(input, query_at: nil)
50
+ return unless input.is_a?(IO) && input.tty?
51
+
52
+ now = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
53
+ deadline = now.call + DRAIN_WINDOW
54
+ deadline = [deadline, query_at + CURSOR_REPLY_WAIT].max if query_at
55
+ dropped = +""
56
+ input.raw do
57
+ while (left = deadline - now.call).positive? && input.wait_readable(left)
58
+ read = input.read_nonblock(1024, exception: false)
59
+ break if read.nil?
60
+
61
+ dropped << read if read.is_a?(String)
62
+ # The reply came: no need to wait out the rest.
63
+ deadline = [now.call + DRAIN_WINDOW, deadline].min if query_at && dropped.match?(CURSOR_REPLY)
64
+ end
65
+ end
66
+ rescue IOError, SystemCallError
67
+ nil
29
68
  end
30
69
 
31
70
  def available?(out:, input:, env:)
@@ -66,6 +66,12 @@ module Samagotchi
66
66
  # @api private
67
67
  attr_writer :reading
68
68
 
69
+ # Monotonic time a read's cursor query went out (#reset) when the read
70
+ # was stopped before Reline took the reply: the reply is still on its
71
+ # way, and LiveRegion.close waits for it. nil with none.
72
+ # @return [Float, nil]
73
+ attr_accessor :unanswered_query_at
74
+
69
75
  # @return [Boolean] this Reline has every method the seam relies on
70
76
  def supported?
71
77
  defined?(Reline::LineEditor::RenderedScreen) &&
@@ -107,7 +113,11 @@ module Samagotchi
107
113
  end
108
114
 
109
115
  def reset(...)
116
+ # Reline's reset asks the terminal for the cursor row and waits for
117
+ # the reply: a Stop raised meanwhile leaves this set.
118
+ RelineSeam.unanswered_query_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
110
119
  super
120
+ RelineSeam.unanswered_query_at = nil
111
121
  RelineSeam.reading = true
112
122
  @rendered_screen.base_y = 0 if RelineSeam.screen
113
123
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.20.0"
4
+ VERSION = "0.21.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -45,7 +45,7 @@ import {
45
45
  appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, turnTimingText,
46
46
  } from "./timing.js";
47
47
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
48
- import { clientLabel, commandView, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, retryStatusLine, snapshotEvents, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
48
+ import { clientLabel, commandView, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
49
49
 
50
50
  const $ = (s) => document.querySelector(s);
51
51
  const topStripEl = $("#topStrip");
@@ -134,6 +134,9 @@ const questionCards = createQuestionCards({
134
134
  // was rendered at (null: no live worker then), where a stream opened later
135
135
  // has to start.
136
136
  let selectSeq = null;
137
+ // The stream was opened from 0 (a wake with no cursor to go on) and no
138
+ // new turn has started on it yet: see followWokenWorker.
139
+ let streamFromGuess = false;
137
140
  // The last input_merged had an origin with no bubble: its merged text
138
141
  // (pending_input_merged) gets one.
139
142
  let unmatchedMerge = false;
@@ -165,6 +168,7 @@ let scopeError = null;
165
168
  async function refresh() {
166
169
  try {
167
170
  allSessions = await listSessions("updated_at", "desc", { dir: scope.dir });
171
+ notifications.noticeList(allSessions);
168
172
  } catch (e) {
169
173
  if (e.status !== 400 || !scope.dir) throw e;
170
174
  scopeError = e.message;
@@ -1341,7 +1345,7 @@ function onCardAction(e) {
1341
1345
  sendCommand(selected, button.dataset.command, { clientId, card: true })
1342
1346
  .then(() => {
1343
1347
  // The command woke the worker: follow its stream if none is open.
1344
- if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
1348
+ followWokenWorker();
1345
1349
  })
1346
1350
  .catch((error) => {
1347
1351
  err.textContent = error.message;
@@ -1416,6 +1420,7 @@ function updateInfoBar() {
1416
1420
  archiveBtn.style.display = selected ? "" : "none";
1417
1421
  const archived = selectedArchived();
1418
1422
  archiveBtn.textContent = archived ? "unarchive" : "archive";
1423
+ archiveBtn.classList.toggle("archived", archived);
1419
1424
  archiveBtn.title = archived ? "Unarchive: back to the lists" : "Archive: hide from the lists, keep for good";
1420
1425
  }
1421
1426
  if (!selected) {
@@ -1593,7 +1598,18 @@ function setUsedMemories(names) {
1593
1598
  }
1594
1599
  }
1595
1600
 
1601
+ // A turn or command just woke the session's worker: follow its stream if
1602
+ // none is open, from the render's cursor. A render with none (no worker
1603
+ // then) streams from 0, the start of a new worker's events; should that
1604
+ // worker have been up already, turn_started says so (replaysDrawnTurn).
1605
+ function followWokenWorker() {
1606
+ if (streamControl?.live) return;
1607
+ startStream(selected, selectSeq ?? 0);
1608
+ streamFromGuess = selectSeq === null;
1609
+ }
1610
+
1596
1611
  function startStream(id, fromSeq) {
1612
+ streamFromGuess = false;
1597
1613
  if (streamControl) {
1598
1614
  streamControl.close();
1599
1615
  streamControl = null;
@@ -1675,7 +1691,8 @@ const streamHandlers = {
1675
1691
  // Prompts sent while a turn ran merge into it at an iteration boundary:
1676
1692
  // input_merged names them (their bubbles turn "steered"), then
1677
1693
  // pending_input_merged carries the merged text, shown only for prompts
1678
- // this tab has no bubble for.
1694
+ // this tab has no bubble for. Its `answer` (the answer the merge
1695
+ // superseded) is the TUI's line: here it streamed as that step's text.
1679
1696
  input_merged: (data) => applyPromptOps(data),
1680
1697
  pending_input_merged: (data) => {
1681
1698
  applyPromptOps(data);
@@ -1683,6 +1700,16 @@ const streamHandlers = {
1683
1700
  },
1684
1701
  turn_enqueued: (data) => applyPromptOps(data),
1685
1702
  turn_started: (data) => keepFollowing(historyEl, () => {
1703
+ if (streamFromGuess) {
1704
+ streamFromGuess = false;
1705
+ // A replay of a turn the render drew: re-read and stream from there.
1706
+ if (replaysDrawnTurn(data, current.timing.turnRecords)) {
1707
+ streamControl?.close();
1708
+ streamControl = null;
1709
+ resync();
1710
+ return;
1711
+ }
1712
+ }
1686
1713
  // A finished turn still in the stage goes first: the start op looks
1687
1714
  // for a queued prompt among its rows.
1688
1715
  turnDom.handOff?.({ immediate: true });
@@ -1729,7 +1756,7 @@ const streamHandlers = {
1729
1756
  }
1730
1757
  },
1731
1758
  turn_canceled: (data) => {
1732
- endTurnView("canceled", cancelLineText(data.cancellation_reason || ""));
1759
+ endTurnView("canceled", cancelLineText(data.cancellation_reason || "", data.cancelled_by));
1733
1760
  setLiveStatus("idle");
1734
1761
  refreshTimingFromSession();
1735
1762
  },
@@ -1965,7 +1992,7 @@ async function handleSendTurn() {
1965
1992
  echo.remove();
1966
1993
  promptEl.value = "";
1967
1994
  fitPrompt();
1968
- if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
1995
+ followWokenWorker();
1969
1996
  return;
1970
1997
  }
1971
1998
  if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
@@ -1987,7 +2014,7 @@ async function handleSendTurn() {
1987
2014
  // events in between. Without one, POST /turn just woke a worker: its
1988
2015
  // events start after the seq the session was rendered at (0 for a new
1989
2016
  // worker process).
1990
- if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
2017
+ followWokenWorker();
1991
2018
  } catch (e) {
1992
2019
  echo.remove();
1993
2020
  // The turn never reached the worker's queue: an early restore waiting
@@ -2028,7 +2055,7 @@ async function handleSendCommand(line) {
2028
2055
  fitPrompt();
2029
2056
  refreshHistory();
2030
2057
  // The command woke the worker: follow its stream if none is open.
2031
- if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
2058
+ followWokenWorker();
2032
2059
  } catch (e) {
2033
2060
  if (/owned_by_tui|409/.test(e.message)) {
2034
2061
  current.owner = "tui";
@@ -606,6 +606,13 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
606
606
  #infoBar .meta .mem-chip{display:inline;order:-1}
607
607
  #infoBar .meta .dir{word-break:normal}
608
608
  #infoBar button{padding:4px 8px}
609
+ /* Icon-only buttons (the words stay as the accessible name, the title says more): the meta gets the width. */
610
+ #infoBar>button{font-size:0;line-height:0;padding:5px 7px}
611
+ #infoBar>button::before{content:"";display:block;width:14px;height:14px;background:currentColor;-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat}
612
+ #infoBar #infoArchiveBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='3.5' rx='1'/%3E%3Cpath d='M3.25 6.25v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1v-6M6.5 9h3'/%3E%3C/svg%3E")}
613
+ #infoBar #infoArchiveBtn.archived{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='3.5' rx='1'/%3E%3Cpath d='M3.25 6.25v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1v-6M8 11.5V8.25M6.5 9.75 8 8.25l1.5 1.5'/%3E%3C/svg%3E")}
614
+ #infoBar #infoStopBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 2v5.5M4.6 4.4a5 5 0 1 0 6.8 0'/%3E%3C/svg%3E")}
615
+ #infoBar #infoDeleteBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.5 4.5h11M6 4.5V2.75h4V4.5M4 4.5l.7 8.6a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9l.7-8.6M6.75 7.25v4.5M9.25 7.25v4.5'/%3E%3C/svg%3E")}
609
616
  }
610
617
  #empty{position:absolute;inset:0;display:grid;place-items:center;color:var(--text-muted);padding:24px;text-align:center;background:color-mix(in srgb,var(--bg) 35%,transparent);z-index:1}
611
618
  #empty.hidden{display:none !important}
@@ -35,7 +35,7 @@ function defaultChannel() {
35
35
  // @param storage () => localStorage (it can throw)
36
36
  // @param channel the BroadcastChannel to the other chi tabs, or null
37
37
  // @param gateOptions passed to createNotifyGate (specs: schedule, now)
38
- // @return {noticeSessions(type, data), setBaseTitle(title), clearBadge()}
38
+ // @return {noticeSessions(type, data), noticeList(sessions), setBaseTitle(title), clearBadge()}
39
39
  export function createNotifications({
40
40
  button,
41
41
  doc = globalThis.document,
@@ -125,11 +125,12 @@ export function createNotifications({
125
125
  notifyGate.seenInFront(next.attentions.map((a) => a.key));
126
126
  } else {
127
127
  for (const a of next.attentions) {
128
- notifyGate.offer(a, () => {
128
+ // Every tab behind badges; one of them notifies (notify.js).
129
+ notifyGate.offer(a, (notify) => {
129
130
  if (tabInFront()) return;
130
131
  badgeKeys.add(a.key);
131
132
  setTitle();
132
- showNotification(attention.byId[a.sessionId], a);
133
+ if (notify) showNotification(attention.byId[a.sessionId], a);
133
134
  });
134
135
  }
135
136
  }
@@ -159,6 +160,10 @@ export function createNotifications({
159
160
 
160
161
  return {
161
162
  noticeSessions,
163
+ // The list fetched without a hub (app.js refresh): a snapshot.
164
+ noticeList(sessions) {
165
+ noticeSessions("snapshot", { sessions });
166
+ },
162
167
  // The title without the badge ("Chi · <project>").
163
168
  setBaseTitle(title) {
164
169
  baseTitle = title;