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
@@ -297,7 +297,7 @@ class Plugin
297
297
  def register(chi)
298
298
  chi.on(:before_turn) { |_event| reset }
299
299
  chi.on(:before_tool_call) { |event, ctx| before(event, ctx) }
300
- chi.on(:after_tool_call) { |event| after(event) }
300
+ chi.on(:after_tool_call) { |event, ctx| after(event, ctx) }
301
301
  return unless @thinking["watch"]
302
302
 
303
303
  chi.on(:before_generation) do |_event|
@@ -377,6 +377,7 @@ class Plugin
377
377
  @warned = {} # key => true once notified this turn
378
378
  @denials = 0
379
379
  @pending = nil
380
+ @stop_card = nil # the stop's card, posted after the denied call
380
381
  @thinking_loops = 0
381
382
  @good_steps = 0
382
383
  @watch = nil
@@ -412,7 +413,8 @@ class Plugin
412
413
  # A call's result, paired with the before_tool_call that keyed it. A call
413
414
  # that was denied (by us or another voter) ran nothing: no result, or the
414
415
  # deny text would become the key's result and the next call would pass.
415
- def after(event)
416
+ def after(event, ctx)
417
+ post_stop_card(ctx)
416
418
  pending = @pending
417
419
  @pending = nil
418
420
  return unless pending && event[:tool].to_s == pending[:key].first
@@ -426,16 +428,25 @@ class Plugin
426
428
  @previews[key] = preview(output)
427
429
  end
428
430
 
431
+ # The turn stops now; its card waits for the denied call's
432
+ # after_tool_call (#post_stop_card), so a UI that prints events in order
433
+ # shows it under that call's row, not before it.
429
434
  def stop(ctx)
430
435
  ctx.stop_turn("the model kept repeating the same calls")
431
436
  repeated = @attempts.select { |key, n| n > 1 && @last_result[key] && @results[[key, @last_result[key]]] >= @deny_after }
432
437
  lines = repeated.map do |key, n|
433
438
  "- `#{short_key(key)}`: #{n} times, the same result each time (#{@previews[key]})"
434
439
  end
435
- ctx.card(title: "loop-guard stopped the turn",
436
- body: "The model kept repeating these calls:\n\n#{lines.join("\n")}\n\n" \
437
- "Tell it what to try instead, or where to look.",
438
- level: :warn)
440
+ @stop_card = { title: "loop-guard stopped the turn",
441
+ body: "The model kept repeating these calls:\n\n#{lines.join("\n")}\n\n" \
442
+ "Tell it what to try instead, or where to look.",
443
+ level: :warn }
444
+ end
445
+
446
+ def post_stop_card(ctx)
447
+ card = @stop_card
448
+ @stop_card = nil
449
+ ctx.card(**card) if card
439
450
  end
440
451
 
441
452
  def warn(ctx, key, what)
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: slow-commands
3
- version: 0.2.1
3
+ version: 0.2.2
4
4
  scope: system
5
5
  description: "Remembers commands that ran past the execute limit in a project; the next identical execute is denied once a turn with advice to run it with task_create and task_wait; /slow-commands lists and forgets them"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:cc8b9829ae77565d8d47969377e91124cf4c785164e417d3d323daeec1295269
9
+ sha256: sha256:6bf5da2501a6404cda01c95975b005fce179f756c56222771c5ee857d7ba4b98
10
10
  # Execute::HANDED_OVER, read when the plugin loads (0.59.0); after_tool_call's
11
11
  # call:, elapsed_ms: and result: (0.57.0)
12
12
  requires_chi: ">= 0.59.0"
@@ -75,9 +75,11 @@ class Plugin
75
75
  TIMED_OUT = /\A#{Regexp.escape(Samagotchi::Tools::Execute::TIMEOUT_PREFIX)} (\d+)s/
76
76
  # Still running at the limit, handed over as a task (chi >= 0.59.0).
77
77
  HANDED_OVER = /\A#{Regexp.escape(Samagotchi::Tools::Execute::HANDED_OVER)} (\d+)s limit/
78
- # A stopped command's first words: the user's Stop, or a signal to chi
79
- # (chi >= 0.62.0; an older chi has no such text).
80
- STOPPED = %i[STOPPED_BY_USER NOT_RUN_ON_STOP STOPPED_BY_SIGNAL NOT_RUN_ON_SIGNAL].filter_map do |name|
78
+ # A stopped command's first words: the user's Stop, a signal to chi
79
+ # (chi >= 0.62.0) or a session stop (chi >= 0.64.0); an older chi has no
80
+ # such text.
81
+ STOPPED = %i[STOPPED_BY_USER NOT_RUN_ON_STOP STOPPED_BY_SIGNAL NOT_RUN_ON_SIGNAL
82
+ STOPPED_BY_SESSION_STOP NOT_RUN_ON_SESSION_STOP].filter_map do |name|
81
83
  Samagotchi::Tools::Execute.const_get(name) if Samagotchi::Tools::Execute.const_defined?(name)
82
84
  end.freeze
83
85
  LEX = Samagotchi::Guardrails::ShellLex
@@ -57,7 +57,7 @@ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_D
57
57
  **Excluded maps** (YAML-only, not part of the flat registry; skipped by scalar loader):
58
58
 
59
59
  - `model_aliases:` map of alias → model id (`ModelRef.parse`, through `HostRegistry#resolve`). Keys lowercased on write (`ConfigFile.write_model_alias!`). Values may be bare `model` or qualified `host:model` (hybrid; only `:` names a host, `org/model` is an id). Aliases apply once: never point an alias at another alias.
60
- - `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled, models}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; `models:` (a map of model ids, or a plain list) declares ids the host serves whatever its `/v1/models` lists: no "doesn't list" warning, and a bare id routes there as if listed (`HostModel`); an entry may hold `price: {input, output, cache_read?, cache_write?}` in USD per 1M tokens (`ModelPrice`; an unset cache rate is the input rate), which estimates a cost the provider doesn't report or reports as 0 (`cost_estimate`, shown as `~$`), and `served: [ids] | any`, the models a gateway may answer that id with (no served-model warning for them); model settings stay in the root `models:`; workers inherit via `SAMAGOTCHI_HOSTS_JSON` (`hosts_json_for_env`, `session_manager.rb`).
60
+ - `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled, models}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; `models:` (a map of model ids, or a plain list) declares ids the host serves whatever its `/v1/models` lists: no "doesn't list" warning, and a bare id routes there as if listed (`HostModel`); an entry may hold `price: {input, output, cache_read?, cache_write?}` in USD per 1M tokens (`ModelPrice`; an unset cache rate is the input rate), which estimates a cost the provider doesn't report or reports as 0 (`cost_estimate`, shown as `~$`), and `served: [ids] | any`, the models a gateway may answer that id with (no served-model warning for them); model settings stay in the root `models:`; a worker reads `hosts:` from the file as it starts (`SAMAGOTCHI_HOSTS_JSON` replaces it for a process it is set on; `SessionManager.spawn_options` unsets it for workers).
61
61
  - `models:` map of model id or alias → `{profile, vision, sampling, thinking, reasoning_history}` (`ConfigFile.model_settings`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: the session's own level (`/thinking LEVEL`, saved in the session; `chi --thinking` on a session run, `chi send --new --thinking`) > `chi web --thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `reasoning_history:` (here or on a host) is `drop|turn|all`, which of its reasoning a chat host's model gets back. Precedence: the session's own level (`/reasoning-history LEVEL`, saved in the session) > `SAMAGOTCHI_REASONING_HISTORY` > `models:` > `hosts.<name>.reasoning_history` > `turn`; `docs/configuration.md` "Reasoning history". `/thinking` and `/reasoning-history` ask before a change that restarts the prompt cache; `--yes` applies it (`chi send` commands apply directly). `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
62
62
  - `hooks:` map of `hooks_dir` + per-event lists `{path, on_error}` (`Hooks::Loader.load`). `hooks_dir` may start with `~`.
63
63
  - `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; config.yml only, like every `guardrails` setting: no env variable), `parent_approvals` (`off`, the default: a parent agent's `chi answer` can only deny an approval; `once`: it may give "Allow once" too; a convention for an honest parent, not a security boundary, and the user's choice: set it only when the user asks), `rules:` (list), `disable:` (a rule id or a list of them, `id` or `bundle:id`, switching off a bundle's or config rule without editing it), `small_models` (`auto`, the default: small = 32B or less read from the name; a glob or list of globs; `[]` for none). A rule takes only `id`, `tool`, `command`, `path`, `git`, `models`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`: outside the session's repo, tmp folders and memory files excepted), `git` (only `outside_repo`: a shell call runs commit/add/reset/… in another checkout); `models` (optional: `small`, a model-name glob, or a list; without it the rule is for every model); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules are read again on the next tool call after config.yml or a bundle's rules change: no restart needed. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.62.0
3
+ version: 0.64.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:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
9
- config_modification_protocol.md: sha256:16403da5dd97a335e19d9d15bb1ba4390237bf9f4869e35d63c172a8828b5ae0
10
- memory_guide.md: sha256:d16919387c42d229498f470fc4078245a15faee872480ffbeffe2e27897624e4
9
+ config_modification_protocol.md: sha256:99ad8a596cc0f5129caf82cc00314d597f0ec309be96e2d48ca4fa936b4ae343
10
+ memory_guide.md: sha256:b0a1ba42c5334d4117b5733c8c26d7477364baca9169b0c0a711de6ac7fc7f3c
11
11
  delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
@@ -94,7 +94,7 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
94
94
  - Integrity: each file's sha256 is recorded at install; a hook/plugin/rule file changed afterwards is not loaded (rules: every call denied) until reinstalled. It is an integrity check, not proof of authorship.
95
95
  - Installing = trusting its Ruby code (hooks, plugin), like a gem. Install only copies; the code runs at the next session start (`Engine.new`), so a running worker needs a restart to pick it up.
96
96
  - A bundle without `*.md` (btw, mcp, loop-guard) adds no line to the prompt's memory index.
97
- - Profiles are shipped bundles with only `includes:` (bundle names): `core` (loop-guard, check-in, guardrails, slow-commands; `chi bootstrap` installs it) and `dev` (known-names, mcp, btw, skills, source-links, github-pr, coordinator). `chi bundle install core` installs the ones not installed and records them; a bundle the user uninstalled stays out on a later install, upgrade or `chi update`, which install only bundles new to the profile. `chi bundle uninstall core` removes its recorded bundles, then core (`docs/memory.md` "Bundle profiles").
97
+ - Profiles are shipped bundles with only `includes:` (bundle names): `core` (loop-guard, check-in, guardrails, slow-commands; `chi bootstrap` installs it) and `dev` (known-names, mcp, btw, skills, source-links, github-pr, coordinator, claude-peer). `chi bundle install core` installs the ones not installed and records them; a bundle the user uninstalled stays out on a later install, upgrade or `chi update`, which install only bundles new to the profile. `chi bundle uninstall core` removes its recorded bundles, then core (`docs/memory.md` "Bundle profiles").
98
98
 
99
99
  ### CLI — `chi bundle`
100
100
 
@@ -231,11 +231,16 @@ module Samagotchi
231
231
  return nil unless child.parent_id == @session_id
232
232
 
233
233
  before = @taken[child_id]&.after || Tools::DelegateCursors.get(@session_id, child_id, state_dir: @state_dir)
234
+ # A follow-up's turn (DelegateCursor#turn) is reported by its own
235
+ # reply, as delegate_result takes it, not a newer queued turn's.
234
236
  wait = ReplyWait.call(child_id, state_dir: @state_dir, cursor: before.reply_file, baseline: before.baseline,
235
- timeout: 0)
237
+ timeout: 0, turn: before.turn)
236
238
  return nil unless reportable?(wait)
237
239
 
238
- after = before.with(reply_file: wait.status == :done ? wait.file : before.reply_file)
240
+ # A reported end is the follow-up turn's too (DelegateCursor#turn):
241
+ # a later wait on it would repeat its reply.
242
+ after = before.with(reply_file: wait.status == :done ? wait.file : before.reply_file,
243
+ turn: wait.status == :waiting_for_answer ? before.turn : nil)
239
244
  .with_baseline(ReplyWait.baseline_of(wait.session))
240
245
  text = Tools::DelegateWait.finish(wait, child_id, timeout: 0, report: true)
241
246
  Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings)
@@ -57,10 +57,17 @@ module Samagotchi
57
57
  # report it. Ctrl-C leaves the turn running (130).
58
58
  # @param owner_grace [Numeric, nil] seconds with no live worker before
59
59
  # it counts as gone; nil waits for whatever wakes one
60
+ # @param turn [String, nil] the enqueued_id of the turn waited for
61
+ # (ReplyWait's turn:): a queued message's, or the turn an answer or
62
+ # a --wait with no message is about (ReplyWait.own_turn)
63
+ # @param queued [Boolean] +turn+ is a queued message's (ReplyWait's
64
+ # queued:)
60
65
  # @return [Integer] the exit status
61
- def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: self.class::WORKER_GONE_AFTER)
66
+ def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: self.class::WORKER_GONE_AFTER, turn: nil,
67
+ queued: false)
62
68
  result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
63
- owner_grace: owner_grace, poll_interval: self.class::POLL_INTERVAL)
69
+ owner_grace: owner_grace, poll_interval: self.class::POLL_INTERVAL, turn: turn,
70
+ queued: queued)
64
71
  result.text = result.text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
65
72
  @reported = true
66
73
  ParentReport.report(result, session_id: id, stdout: @stdout, stderr: @stderr, command: command_name,
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "delivery"
4
+
5
+ module Samagotchi
6
+ # Where a line typed at a terminal's prompt goes, for both terminals (the
7
+ # REPL's TerminalUI#steer_line / #run_input_line, the attached
8
+ # AttachedLoop#submit). It only classifies; each UI keeps what it does
9
+ # with the answer (the REPL's mid-turn command policy and image lines, the
10
+ # attached TUI's continue offer and question). The web's composer.js
11
+ # routeComposerLine decides the same; spec/fixtures/composer_routes.json
12
+ # holds the cases both answer (docs/testing.md).
13
+ #
14
+ # In order:
15
+ # 1. /cut TEXT, /queue TEXT (.steer_choice), before every command check:
16
+ # the bare word is :usage. While a turn runs TEXT is a :deliver with
17
+ # its delivery (Delivery::CUT, Delivery::QUEUE); whether the worker
18
+ # can queue is the UI's to check. With none running TEXT is routed
19
+ # on as a line (step 2), its own /cut not read again.
20
+ # 2. A line: :empty; a terminal's own word (:local, the registry's
21
+ # lookup_local id: :exit, :archive, :detach, :stats, :recap…); a
22
+ # session command (:command); a typo of one (:typo, the hint in
23
+ # +reply+); else :prompt. A line with images (+images+) is a message:
24
+ # no command or typo check.
25
+ module ComposerRoute
26
+ CUT_COMMAND = "/cut"
27
+ QUEUE_COMMAND = "/queue"
28
+ # The word, then any whitespace and the text (as the registry's
29
+ # entries match them, so a line Tab or /help knows is never a prompt).
30
+ STEER_CHOICE = /\A(#{CUT_COMMAND}|#{QUEUE_COMMAND})(?:\s+(.*))?\z/m
31
+
32
+ KINDS = %i[empty usage deliver local command typo prompt].freeze
33
+
34
+ # @!attribute kind [Symbol] one of KINDS
35
+ # @!attribute text [String, nil] the line stripped: a /cut's or
36
+ # /queue's own text, not the word
37
+ # @!attribute delivery [String, nil] a :deliver's Delivery value
38
+ # @!attribute reply [String, nil] a :typo's hint
39
+ # @!attribute local [Symbol, nil] a :local's registry id
40
+ # @!attribute choice [Symbol, nil] :cut or :queue when the line was
41
+ # typed with that word (an idle one's text routed on keeps it)
42
+ Route = Data.define(:kind, :text, :delivery, :reply, :local, :choice) do
43
+ def initialize(kind:, text: nil, delivery: nil, reply: nil, local: nil, choice: nil)
44
+ super
45
+ end
46
+ end
47
+
48
+ # @param line [String, nil] as typed
49
+ # @param running [Boolean] a turn runs
50
+ # @param registry [Commands::Registry] the session's commands
51
+ # @param images [Integer] the attachments not in the text (the web's
52
+ # chips); a terminal's @path images are its own to handle (0)
53
+ # @return [Route]
54
+ def self.call(line, running:, registry:, images: 0)
55
+ text = line.to_s.strip
56
+ choice, message = steer_choice(text)
57
+ return line_route(text, registry: registry, images: images) unless choice
58
+ return Route.new(kind: :usage, choice: choice) if message.empty?
59
+ return line_route(message, registry: registry, images: images).with(choice: choice) unless running
60
+
61
+ Route.new(kind: :deliver, text: message, delivery: choice == :cut ? Delivery::CUT : Delivery::QUEUE, choice: choice)
62
+ end
63
+
64
+ # The choice a line carries, as [:cut, "TEXT"] or [:queue, "TEXT"]; nil
65
+ # for any other line. The text is "" when the line named the choice
66
+ # without one.
67
+ # @param line [String, nil]
68
+ # @return [Array(Symbol, String), nil]
69
+ def self.steer_choice(line)
70
+ match = STEER_CHOICE.match(line.to_s.strip) or return nil
71
+
72
+ [match[1] == CUT_COMMAND ? :cut : :queue, match[2].to_s.strip]
73
+ end
74
+
75
+ def self.line_route(text, registry:, images:)
76
+ return Route.new(kind: :empty, text: text) if text.empty?
77
+
78
+ if images.zero?
79
+ local = registry.lookup_local(text)&.id
80
+ return Route.new(kind: :local, text: text, local: local) if local
81
+ return Route.new(kind: :command, text: text) if registry.command?(text)
82
+
83
+ hint = registry.unknown_command_hint(text)
84
+ return Route.new(kind: :typo, text: text, reply: hint) if hint
85
+ end
86
+ Route.new(kind: :prompt, text: text)
87
+ end
88
+ private_class_method :line_route
89
+ end
90
+ end
@@ -857,7 +857,7 @@ module Samagotchi
857
857
  HOST_NAME_RE = /\A[a-z0-9][a-z0-9._-]*\z/i
858
858
  ENV_NAME_RE = /\A[A-Za-z_][A-Za-z0-9_]*\z/
859
859
 
860
- # config.yml's hosts: (or a worker's SAMAGOTCHI_HOSTS_JSON) by lowercased
860
+ # config.yml's hosts: (or a SAMAGOTCHI_HOSTS_JSON override) by lowercased
861
861
  # name; an entry that is disabled or invalid (warned) is left out. No
862
862
  # entry left: "default", from server.host/port/transport.
863
863
  # @return [Hash{String => HostConfig}]
@@ -914,7 +914,7 @@ module Samagotchi
914
914
 
915
915
  # The names (lowercased) of the hosts entries with enabled: false, which
916
916
  # hosts_config leaves out. Read from the same source: config.yml's
917
- # hosts:, or SAMAGOTCHI_HOSTS_JSON in a worker.
917
+ # hosts:, or a SAMAGOTCHI_HOSTS_JSON override.
918
918
  # @return [Array<String>]
919
919
  def disabled_host_names(env: ENV, path: global_path(env: env))
920
920
  raw = hosts_source(read_yaml(env: env, path: path), env)
@@ -926,7 +926,8 @@ module Samagotchi
926
926
  end
927
927
 
928
928
  # config.yml's hosts: as written, or the SAMAGOTCHI_HOSTS_JSON override
929
- # a worker inherits from its parent.
929
+ # set on this process (specs; SessionManager.spawn_options unsets it for
930
+ # a worker, which reads the file).
930
931
  def hosts_source(data, env)
931
932
  raw = data[HOSTS_KEY] if data.is_a?(Hash)
932
933
  env_json = env["SAMAGOTCHI_HOSTS_JSON"].to_s.strip
@@ -1041,6 +1042,8 @@ module Samagotchi
1041
1042
  hosts.keys.map { |k| k.to_s.downcase }.include?(prefix) ? nil : prefix
1042
1043
  end
1043
1044
 
1045
+ # hosts_config as a SAMAGOTCHI_HOSTS_JSON override (workers no longer
1046
+ # get one: they read config.yml).
1044
1047
  def hosts_json_for_env(env: ENV, path: global_path(env: env))
1045
1048
  hosts = hosts_config(env: env, path: path)
1046
1049
  # Only serialize if non-default or explicitly configured hosts:
@@ -16,8 +16,9 @@ module Samagotchi
16
16
  class ContextAbsorber
17
17
  # +note+: Engine#add_context_note's hash; +subscription+: the session's
18
18
  # Subscription after it (nil: dropped); +wake_note+: the same note with
19
- # the wake line, for an update whose source asked to wake (the worker
20
- # may start a turn with it), else nil.
19
+ # the wake line, for an update whose source asked to wake or a session
20
+ # push source's first text that asked (the worker may start a turn with
21
+ # it), else nil.
21
22
  Delivery = Data.define(:name, :note, :subscription, :wake_note) do
22
23
  def initialize(name:, note:, subscription:, wake_note: nil) = super
23
24
  end
@@ -93,9 +94,12 @@ module Samagotchi
93
94
  "(third-party text, not your user's words):\n> #{quoted}\n#{last_line}"
94
95
  end
95
96
 
96
- def self.attached_text(name:, hint:, why:, summary:)
97
+ # The attached note's text; a wake turn's (+wake+) ends with the wake
98
+ # line in place of the background line.
99
+ def self.attached_text(name:, hint:, why:, summary:, wake: false)
97
100
  why_part = why ? " Why: #{why}#{"." unless why.match?(/[.!?]\z/)}" : ""
98
- "Attached: #{name}#{" (#{hint})" if hint}.#{why_part}\nSummary: #{summary}\n#{BACKGROUND_LINE} #{read_line(name)}"
101
+ last_line = wake ? WAKE_LAST_LINE : "#{BACKGROUND_LINE} #{read_line(name)}"
102
+ "Attached: #{name}#{" (#{hint})" if hint}.#{why_part}\nSummary: #{summary}\n#{last_line}"
99
103
  end
100
104
 
101
105
  def self.read_line(name) = "Read it with context_read(name: \"#{name}\") when your user's request is about it."
@@ -105,6 +109,8 @@ module Samagotchi
105
109
  # A source the session sees: attached when it has text and was never
106
110
  # noted; updated for a new revision; an error once per run of failures
107
111
  # after a success. A source with no text yet waits (nothing to read).
112
+ # An update that asked to wake carries a wake note, and so does a first
113
+ # text that asked when #first_text_wakes?.
108
114
  def delivery_for(attached, sub, now)
109
115
  snapshot = attached.snapshot
110
116
  return nil unless snapshot.text?
@@ -112,8 +118,11 @@ module Samagotchi
112
118
  name = attached.name
113
119
  hint = snapshot.hint || attached.source.hint
114
120
  if sub.seen.nil?
115
- text = self.class.attached_text(name: name, hint: hint, why: attached.source.why, summary: snapshot.summary)
116
- return delivery(name, text_note_id(name, snapshot), text, now, seen(sub, snapshot))
121
+ args = { name: name, hint: hint, why: attached.source.why, summary: snapshot.summary }
122
+ found = delivery(name, text_note_id(name, snapshot), self.class.attached_text(**args), now, seen(sub, snapshot))
123
+ return found unless first_text_wakes?(attached, sub)
124
+
125
+ return found.with(wake_note: found.note.merge(text: self.class.attached_text(**args, wake: true)))
117
126
  end
118
127
  if sub.seen != snapshot.revision
119
128
  args = { name: name, hint: hint, summary: snapshot.summary, changes: snapshot.serial - sub.seen_serial }
@@ -131,6 +140,16 @@ module Samagotchi
131
140
  sub.with(error_seen: snapshot.error_since))
132
141
  end
133
142
 
143
+ # A first text may wake only for a session's pushed source (`chi context
144
+ # add NAME --push`, attached empty) that asked. Not for a command source
145
+ # (a URL/provider source has a cmd too): its first fetch runs as it is
146
+ # attached, while the user is likely looking at the session. Not for a
147
+ # project source: its first text may predate this session. A blank
148
+ # Subscription has seen_serial 0, so any revision that asked counts.
149
+ def first_text_wakes?(attached, sub)
150
+ attached.source.push? && attached.location.session? && attached.snapshot.wake_serial.to_i > sub.seen_serial
151
+ end
152
+
134
153
  # A subscription whose source is gone: detached when the session was
135
154
  # told of it, dropped quietly otherwise.
136
155
  def detached(sub, now)
@@ -85,12 +85,17 @@ module Samagotchi
85
85
  # Called with the event log held, before the command's command_ran. A
86
86
  # Stop runs no turn: last_turn says so (not_continued), so a wait on
87
87
  # it ends (chi answer --option Stop).
88
+ # It carries the offer turn's enqueued_id, so a wait on that turn
89
+ # (ReplyWait's turn:) ends on it too.
88
90
  def announce_resolved(result, command)
89
91
  @engine.announce(type: :continue_resolved, decision: result.decision.to_s, client_id: command.client_id)
90
92
  return if result.resume
91
93
 
92
94
  session = @engine.session
93
- session.last_turn = LastTurn.new(outcome: NOT_CONTINUED, ended_at: Time.now.iso8601(3)) if session
95
+ return unless session
96
+
97
+ session.last_turn = LastTurn.new(outcome: NOT_CONTINUED, ended_at: Time.now.iso8601(3),
98
+ enqueued_id: offer_turn_id)
94
99
  end
95
100
 
96
101
  # After a command's command_ran and save: a /continue that answered the
@@ -117,9 +122,13 @@ module Samagotchi
117
122
  # asks again, one before the turn began too (logged, no :turn_failed).
118
123
  # The answer's text is carried into the turn as it begins; a turn that
119
124
  # never began leaves it to no other.
125
+ # The continue turn is the offer turn's: its origin carries that turn's
126
+ # enqueued_id, so its last_turn, its reply file and its checkpoint mark
127
+ # do too, and a wait on that turn (chi answer) gets its reply.
120
128
  def run_continue_turn(command)
121
129
  offer = @turn_flow.offer
122
130
  resume_from = @turn_flow.resume_from
131
+ enqueued_id = offer_turn_id
123
132
  close("answered")
124
133
  steer = @continue_steer
125
134
  @continue_steer = nil
@@ -131,8 +140,8 @@ module Samagotchi
131
140
  Log.warn(:worker, "turn_not_begun", continue: true, error: e.class.name, msg: e.message)
132
141
  return announce_offer(offer)
133
142
  end
134
- @run_turn.call(nil, continue: true, origin: { client_id: command.client_id }.compact, resumed: true,
135
- resume_from: resume_from,
143
+ origin = { client_id: command.client_id, enqueued_id: enqueued_id }.compact
144
+ @run_turn.call(nil, continue: true, origin: origin, resumed: true, resume_from: resume_from,
136
145
  max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
137
146
  if error
138
147
  announce_offer(offer)
@@ -156,6 +165,11 @@ module Samagotchi
156
165
 
157
166
  private
158
167
 
168
+ # The enqueued_id of the turn the offer stands for: the exhausted
169
+ # turn's last_turn (a canceled continue turn's carries the same id);
170
+ # nil when it ran no input (a reminder turn, a delegate's first).
171
+ def offer_turn_id = @engine.session&.last_turn&.enqueued_id
172
+
159
173
  def announce_offer(offer = @turn_flow.offer)
160
174
  @engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
161
175
  ask
@@ -23,25 +23,30 @@ module Samagotchi
23
23
  # Why a request body's delivery field can't be taken, as {error:,
24
24
  # detail:} for a 400, or nil when it can. The Bridge and the web's
25
25
  # POST /turn both ask this, so they refuse the same values. A missing
26
- # or unknown string is not refused: it is the default (#parse). QUEUE
27
- # is refused until a worker runs it (no worker reads it yet, so it
28
- # would merge at the next step while the ack said queue).
26
+ # or unknown string is not refused: it is the default (#parse).
29
27
  # @param raw [Object] the wire value
30
28
  # @return [Hash, nil]
31
29
  def self.refusal(raw)
32
30
  return nil if raw.nil?
33
31
  return { error: "bad_delivery", detail: "delivery must be a string" } unless raw.is_a?(String)
34
- return QUEUE_UNAVAILABLE if raw == QUEUE
35
32
 
36
33
  nil
37
34
  end
38
35
 
36
+ # The worker sidecar feature (Bridge::FEATURES) of a worker that runs
37
+ # all three values. One without it (before 0.64) ignores QUEUE, or
38
+ # refuses it with a 400 delivery_unavailable, so a sender checks it
39
+ # before sending a queue (SessionManager.deliver_turn, the attached TUI).
40
+ QUEUE_FEATURE = "delivery"
41
+
42
+ # The refusal for a queue a worker without QUEUE_FEATURE would steer
43
+ # (SessionManager.deliver_turn answers it as a 409). P0-P2 workers
44
+ # answer the same error code for a queue, with their own detail.
39
45
  QUEUE_UNAVAILABLE = {
40
- error: "delivery_unavailable",
41
- detail: "delivery queue isn't available yet; without it the message goes in at the running turn's next step " \
42
- "(or runs as a turn when none runs)"
46
+ "error" => "delivery_unavailable",
47
+ "detail" => "this session's worker predates queueing, so the message would steer the running turn; " \
48
+ "restart it (chi sessions restart ID, or the web's update badge) and send again"
43
49
  }.freeze
44
- private_constant :QUEUE_UNAVAILABLE
45
50
 
46
51
  # Whether it is the one every sender defaulting to a step boundary uses.
47
52
  def self.next_step?(value) = parse(value) == NEXT_STEP
@@ -1433,8 +1433,10 @@ module Samagotchi
1433
1433
  elsif client_id == ClientId::REMINDER then "reminder"
1434
1434
  else "client"
1435
1435
  end
1436
+ enqueued_id = origin.is_a?(Hash) ? origin[:enqueued_id] : nil
1436
1437
  session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
1437
1438
  origin: source, exhausted: limit ? true : nil, limit: limit,
1439
+ enqueued_id: enqueued_id.to_s.empty? ? nil : enqueued_id.to_s,
1438
1440
  **stop_facts(event))
1439
1441
  end
1440
1442
 
@@ -9,9 +9,9 @@ require_relative "reasoning_history"
9
9
  module Samagotchi
10
10
  # One config.yml hosts: entry, parsed and checked (ConfigFile.hosts_config
11
11
  # holds them by lowercased name; HostRegistry builds its HostEntry from
12
- # one). A worker gets its parent's hosts back through
13
- # SAMAGOTCHI_HOSTS_JSON: #to_env_h writes every field, and .parse reads
14
- # it back to an equal HostConfig.
12
+ # one). #to_env_h writes every field in the shape of the
13
+ # SAMAGOTCHI_HOSTS_JSON override (ConfigFile.hosts_json_for_env), and
14
+ # .parse reads it back to an equal HostConfig.
15
15
  #
16
16
  # name: the lowercased entry name; host, port, scheme: where it is (from
17
17
  # url: when given); url: the configured url, nil when host/port; transport,
@@ -26,7 +26,7 @@ module Samagotchi
26
26
  super(**members.to_h { |m| [m, nil] }, models: {}, **fields)
27
27
  end
28
28
 
29
- # A hosts: entry as written (YAML, or a worker's SAMAGOTCHI_HOSTS_JSON),
29
+ # A hosts: entry as written (YAML, or a SAMAGOTCHI_HOSTS_JSON override),
30
30
  # or nil when it is disabled (enabled: false) or invalid. An invalid
31
31
  # one warns once, naming the entry, the way each reader of a setting
32
32
  # does for a bad value (that value is then unset).
@@ -160,10 +160,10 @@ module Samagotchi
160
160
  end
161
161
  private_class_method :invalid
162
162
 
163
- # The entry as a worker's SAMAGOTCHI_HOSTS_JSON carries it (string keys,
163
+ # The entry as a SAMAGOTCHI_HOSTS_JSON override carries it (string keys,
164
164
  # unset fields left out); .parse(name, to_env_h) gives it back. A url
165
165
  # entry travels as its url (host/port come from it); the API key stays
166
- # in the environment, which workers inherit.
166
+ # in the environment.
167
167
  # @return [Hash{String => Object}]
168
168
  def to_env_h
169
169
  location = url ? { "url" => url } : { "host" => host, "port" => port }
@@ -104,8 +104,8 @@ module Samagotchi
104
104
  end
105
105
  end
106
106
 
107
- # The entry as written back to config (SAMAGOTCHI_HOSTS_JSON for
108
- # workers): nil for a bare id.
107
+ # The entry as written back to config (HostConfig#to_env_h): nil for a
108
+ # bare id.
109
109
  def to_config
110
110
  config = { "price" => price&.to_config, "served" => served }.compact
111
111
  config unless config.empty?
@@ -7,6 +7,7 @@ require "time"
7
7
 
8
8
  require_relative "idle_client"
9
9
  require_relative "idle_target"
10
+ require_relative "image_store"
10
11
  require_relative "output_formatter"
11
12
  require_relative "steer"
12
13
  require_relative "recap_store"
@@ -53,8 +54,6 @@ module Samagotchi
53
54
  # separator, while the chat loop writes one message per call.
54
55
  TOOL_OUTPUT_SEPARATOR = "\n\n---\n\n"
55
56
  TOOL_OUTPUT_HEADER_RE = /\A\[([\w.:-]+)\]/
56
- # The text the web sends for a turn of images alone (images.js turnText).
57
- IMAGE_ONLY_RE = /\A\s*(?:\[image: [^\]\n]*\]\s*)+\z/
58
57
  DELEGATE_REPORT_LINE = "(a delegate session's report, delivered by chi; not the user's words)"
59
58
 
60
59
  module_function
@@ -76,7 +75,7 @@ module Samagotchi
76
75
  # A delegate child's report (ChildReports) is chi's news, not
77
76
  # what the user said.
78
77
  content = "#{DELEGATE_REPORT_LINE}\n#{content}" if message["source"].to_s == Steer::DELEGATE_REPORT
79
- images = IMAGE_ONLY_RE.match?(content) ? [] : image_lines(message["images"])
78
+ images = ImageRef.placeholder_only?(content) ? [] : image_lines(message["images"])
80
79
  [content, *images].reject(&:empty?).join("\n")
81
80
  when "model", "assistant"
82
81
  strip_thought(message["content"].to_s.gsub(TOOL_CALL_RE, ""))
@@ -159,6 +159,14 @@ module Samagotchi
159
159
  # One-line texts about an image ref ({file:, mime:, width:, height:,
160
160
  # bytes:, name:, source:}).
161
161
  module ImageRef
162
+ # The text the web sends for a turn of images alone (images.js
163
+ # turnText): "[image: name]" per image, nothing else.
164
+ PLACEHOLDER_ONLY_RE = /\A\s*(?:\[image: [^\]\n]*\]\s*)+\z/
165
+
166
+ # Whether +text+ is only that placeholder: the images' own lines would
167
+ # name them a second time.
168
+ def self.placeholder_only?(text) = PLACEHOLDER_ONLY_RE.match?(text.to_s)
169
+
162
170
  # Claude's rule of thumb (28×28 px per token). About 30% over on the
163
171
  # local Qwen, close enough for a label and the context estimate.
164
172
  def self.estimated_tokens(ref)
@@ -9,9 +9,13 @@ module Samagotchi
9
9
  #
10
10
  # id: the turn's id (nil when unknown); started_at / at: iso8601, when the
11
11
  # turn began and when this checkpoint was taken; iteration: the loop
12
- # boundaries it had passed (Integer).
13
- InterruptedTurn = Data.define(:id, :started_at, :at, :iteration) do
14
- def initialize(id: nil, started_at: nil, at: nil, iteration: nil) = super
12
+ # boundaries it had passed (Integer); enqueued_id: the input the turn
13
+ # runs (its ack's id, LastTurn#enqueued_id once it ends; a Continue turn
14
+ # carries its offer turn's), so any process that loads the session can
15
+ # tell which turn is running (ReplyWait.own_turn); nil when it ran none,
16
+ # or a worker from before it wrote one.
17
+ InterruptedTurn = Data.define(:id, :started_at, :at, :iteration, :enqueued_id) do
18
+ def initialize(id: nil, started_at: nil, at: nil, iteration: nil, enqueued_id: nil) = super
15
19
 
16
20
  # A saved record (string or symbol keys), an InterruptedTurn as it is;
17
21
  # nil for anything else.
@@ -12,9 +12,12 @@ module Samagotchi
12
12
  # children's reports) or "context" (one an attached context source's
13
13
  # change started); exhausted/limit: the turn ran out of iterations at
14
14
  # limit. The rest are its stop facts (LastTurn::STOP_FACTS), only what is
15
- # known. A field may be nil (unset; left out of the file).
15
+ # known. enqueued_id: the input's id when the turn ran one (a POST /turn
16
+ # ack's, an input file's), so a wait for a queued message (`chi send
17
+ # --queue --wait`, ReplyWait's turn:) tells its own turn's end from
18
+ # another's. A field may be nil (unset; left out of the file).
16
19
  LastTurn = Data.define(:outcome, :ended_at, :seconds, :origin, :exhausted, :limit,
17
- :error_kind, :retryable, :kept_steps, :cancel_reason, :stopped_by) do
20
+ :error_kind, :retryable, :kept_steps, :cancel_reason, :stopped_by, :enqueued_id) do
18
21
  def initialize(**fields)
19
22
  super(**members.to_h { |m| [m, nil] }, **fields)
20
23
  end
@@ -19,17 +19,24 @@ module Samagotchi
19
19
  end
20
20
 
21
21
  # Append a message to the queue. Nil/empty strings are ignored.
22
- def push(text)
22
+ # +seq+ is the sender's own order number for it (the REPL numbers every
23
+ # line it takes during a turn, so its leftovers keep send order across
24
+ # this queue and its after-turn list: #drain_entries).
25
+ def push(text, seq: nil)
23
26
  text = text.to_s
24
27
  return if text.empty?
25
28
 
26
- @mutex.synchronize { @messages << text }
29
+ @mutex.synchronize { @messages << [seq, text] }
27
30
  nil
28
31
  end
29
32
 
30
33
  # Remove and return all queued messages in FIFO order. Non-blocking.
31
34
  # @return [Array<String>]
32
- def drain
35
+ def drain = drain_entries.map(&:last)
36
+
37
+ # #drain with each message's +seq+ (nil when pushed without one).
38
+ # @return [Array(Integer|nil, String)]
39
+ def drain_entries
33
40
  @mutex.synchronize do
34
41
  drained = @messages
35
42
  @messages = []