samagotchi 0.18.0 → 0.18.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c7c0dfd8a859bb4808e10128560904cac788bc6bed8ec0b9d799425c6eee2fb
4
- data.tar.gz: 4263cf99ea0998c74d7ae0f4e37d3bdbed8acc0140400082eed696da58e14847
3
+ metadata.gz: 34f4c3e1960aa23902e27160bd1f325ba035731c283a93b2cd819676481c90a2
4
+ data.tar.gz: 708e58e5bfc26d10fe9f20bd1b696f6355e2a25749a36ca6503e82ca9077fa64
5
5
  SHA512:
6
- metadata.gz: 16b8b6cda0d263cb6540e052d207483396a5fd4588e3e4ddd776e02bcc107fb63ef73e19835b20b44e5c95e850d30b018bf985d898c8a2b07e6ae7a7a2984034
7
- data.tar.gz: ade20718b92e00ee24274d5b0cdce3ff102ae88487cb00af244168134f1f0c0d3259cb1e7f19ade71bf8fe442cba1a764bd6550c5d1911ef7cce558fabac044f
6
+ metadata.gz: 56c634e50c34f3fd027ca9ed7208d7edaab21a639050fe321c1d9fa5e8a0e818a31ae6c6351f8c21c0417984f3235a1a44b14aaa3d415b392a85d3bff2916aa1
7
+ data.tar.gz: 41862c98747027f791e4aa59b4c6d2eda092d818e3492212fbc79a24970c74734c3196c480511876aa97b95a8e1245c8bfd6fceccb73d47ade67d3f96ced89fa
data/CHANGELOG.md CHANGED
@@ -8,6 +8,39 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.18.1] - 2026-10-03
12
+
13
+ ### Fixed
14
+
15
+ - The `execute` tool no longer hangs when a command leaves a process in the background (`server &`, or
16
+ `cd DIR && nohup server > log 2>&1 &`) that keeps its output open: after a short grace it stops that process and
17
+ returns the output, with a note to start servers and long jobs with `task_create`. Stop and
18
+ `execute.timeout_sec` work during the wait too.
19
+ - A running session no longer denies every tool call after an installed bundle's rules are upgraded to a version
20
+ that needs a newer chi (`requires_chi`): it keeps the rules it loaded before and says once to restart the session
21
+ (`chi sessions stop ID`, then `chi --resume ID`).
22
+ - `chi -p … --non-interactive` keeps stdout for the answer alone: the `Session:` (or `Resumed session:`) line goes
23
+ to stderr, before the turn. A failed turn now says after its error that the session is kept with the prompt and
24
+ how to continue it (`chi --resume ID`).
25
+ - Ctrl-C in `chi -p … --non-interactive` keeps the session: the prompt and a cancel note are saved, so
26
+ `chi --resume ID` has them, and stderr says so instead of a Ruby backtrace. The exit status stays 130.
27
+ - `chi -p` shows the after_turn hooks' notices (source-links' `sources:`, any notifier), which it lost: with
28
+ `--non-interactive` on stderr after the answer; attached (input from a pipe) it waits for them and shows them
29
+ before it detaches.
30
+ - `chi -p … --non-interactive` prints the retry lines on stderr (`↻ empty answer, asking again (1/1)`, `retrying
31
+ (1/5 in 0.5s): …`), as the REPL shows them, instead of nothing.
32
+ - A native llama.cpp turn sends `GET /props` once, not twice.
33
+ - After a failed `/props` probe, a server that then answers is probed again at the next turn, instead of the
34
+ failure being reused for 30 s.
35
+ - A request to a model server is sent only as many times as chi's own retries say: Ruby's hidden extra retry is off.
36
+ - The served-model notice no longer treats `gpt-4` and `gpt-4o` as the same model: a served name counts as the
37
+ asked one only when what follows starts with `:`, `-`, `@` or `/` (`qwen3` = `qwen3:latest`).
38
+ - A Gemma thought channel (`<|channel>thought` … `<channel|>`) is stripped as a whole block, so the model's
39
+ reasoning no longer shows up in the answer.
40
+ - An unknown model profile name falls back to `qwen36`, the same default as everywhere else, instead of `gemma4`.
41
+
42
+ Update with `gem update samagotchi` (or `chi update`); no bundle changed.
43
+
11
44
  ## [0.18.0] - 2026-10-03
12
45
 
13
46
  ### Added
@@ -1002,7 +1035,8 @@ and long-lived sessions.
1002
1035
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
1003
1036
  hotkey panel that send selected text or the clipboard to your sessions.
1004
1037
 
1005
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.18.0...HEAD
1038
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.18.1...HEAD
1039
+ [0.18.1]: https://github.com/dm1try/samagotchi/compare/v0.18.0...v0.18.1
1006
1040
  [0.18.0]: https://github.com/dm1try/samagotchi/compare/v0.17.0...v0.18.0
1007
1041
  [0.17.0]: https://github.com/dm1try/samagotchi/compare/v0.16.0...v0.17.0
1008
1042
  [0.16.0]: https://github.com/dm1try/samagotchi/compare/v0.15.0...v0.16.0
data/bin/chi CHANGED
@@ -314,6 +314,7 @@ if launch == :attached
314
314
  exit Samagotchi::TerminalUI::AttachLauncher.exit_status(ended)
315
315
  end
316
316
 
317
+ require "samagotchi/cli/exit"
317
318
  begin
318
319
  ended = Samagotchi::TerminalUI.new(
319
320
  prompt: options[:prompt],
@@ -326,8 +327,10 @@ begin
326
327
  model_name: options[:model],
327
328
  scratch: scratch
328
329
  ).run
329
- # `chi -p X </dev/null`: the turn failed (nothing was sent again).
330
- exit 1 if ended == :turn_failed
330
+ # `chi -p X </dev/null`: the turn failed (nothing was sent again); -p
331
+ # --non-interactive: the turn ended with no answer, or Ctrl-C cancelled it.
332
+ exit 1 if %i[turn_failed empty_answer].include?(ended)
333
+ exit Samagotchi::CLI::Exit::INTERRUPTED if ended == :interrupted
331
334
  rescue Samagotchi::TerminalUI::SessionBusy, Samagotchi::TerminalUI::SessionNotFound, Samagotchi::ModelProfile::MissingModel => e
332
335
  warn "Error: #{e.message}"
333
336
  exit 1
data/docs/cli.md CHANGED
@@ -224,7 +224,13 @@ Notes:
224
224
  messages as turn context (a `-p` run on a resumed session never clobbers its
225
225
  conversation).
226
226
  - Non-interactive runs (`-p` with `--non-interactive`, or bare `--non-interactive`)
227
- print only the final result output — no spinner, status line, or REPL.
227
+ print only the final result output on stdout — no spinner, status line, or REPL.
228
+ Everything else goes to stderr: the `Session: ID` line first, before the turn;
229
+ retry lines and hooks' notices as they come, the after_turn hooks' ones
230
+ (source-links' `sources:`) after the answer; and the error of a failed turn,
231
+ then `chi: the session is kept with your prompt; continue it with: chi
232
+ --resume ID` (exit 1). Ctrl-C saves the session with the prompt and a
233
+ cancel note and exits 130.
228
234
 
229
235
  ### Scratch sessions
230
236
 
data/docs/guardrails.md CHANGED
@@ -295,6 +295,14 @@ restart. A rule that doesn't parse (an unknown key, a bad regex, no
295
295
  verdict, a `disable:` that isn't an id or a list of ids) makes chi **deny every tool call** and say why, rather than run
296
296
  without it.
297
297
 
298
+ A bundle whose `requires_chi` the running chi doesn't meet (installed by a
299
+ newer chi while the session's worker kept running) isn't read again: the
300
+ rules the session loaded from it before stay, and a `guardrails>` line says
301
+ once per bundle version to restart the session (`chi sessions stop ID`, then
302
+ `chi --resume ID`). A chi that never loaded that bundle's rules (it starts
303
+ older than the bundle) denies every tool call, as for a rule that doesn't
304
+ parse, until chi is updated or the bundle uninstalled.
305
+
298
306
  ## The guardrails bundle
299
307
 
300
308
  ```sh
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.18.0
3
+ version: 0.18.1
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:
@@ -31,12 +31,39 @@ module Samagotchi
31
31
  CONTEXT_WINDOW_PROBE_READ_TIMEOUT = 2
32
32
  # Seconds a probe that got no answer (refused, timed out) is remembered:
33
33
  # a down or hung server then costs one probe per window, not one per
34
- # generation, and a new turn doesn't ask again at once.
34
+ # generation, and a new turn doesn't ask again at once. A request to the
35
+ # host that succeeds drops the remembered failure (see
36
+ # #clear_props_failures!).
35
37
  PROPS_FAILURE_TTL = 30
36
38
 
37
39
  DEFAULT_TRANSPORT = :llama_cpp
38
40
  VALID_TRANSPORTS = %i[llama_cpp mlx omlx].freeze
39
41
 
42
+ # One /props cache per host per process. A host has more than one Client
43
+ # in a run (the registry's, a rebuilt HostRegistry's, the Engines of a
44
+ # TUI and a spec): with a cache each, every one of them asks /props for
45
+ # the same server. Keyed by the host's base URL (scheme://host:port), so
46
+ # two Clients of the same server share the answers their turns read.
47
+ # Entries live for the process: they are the same small hash per host
48
+ # (answers by model, failures by model with their timestamps).
49
+ @props_store = {}
50
+ @props_store_mutex = Mutex.new
51
+
52
+ class << self
53
+ # The shared props entry for a host's base URL:
54
+ # { answers: { model => ServerProps }, failures: { model => [props, at] },
55
+ # mutex: Mutex }. The caller may hold the entry's own mutex.
56
+ def props_entry(base_url)
57
+ @props_store_mutex.synchronize { @props_store[base_url] ||= { answers: {}, failures: {}, mutex: Mutex.new } }
58
+ end
59
+
60
+ # Forget every host's answers and failures. Specs call it per example
61
+ # (the store outlives one); nothing in a run does.
62
+ def reset_props_store!
63
+ @props_store_mutex.synchronize { @props_store.clear }
64
+ end
65
+ end
66
+
40
67
  # Wire-format strategy for one server transport. `Client` keeps the
41
68
  # transport-agnostic request/retry/stream loop; everything that differs
42
69
  # between llama.cpp's native API and the OpenAI-compatible servers
@@ -175,9 +202,13 @@ module Samagotchi
175
202
  @open_timeout = Samagotchi::Config.positive_seconds("server.open_timeout", open_timeout)
176
203
  @read_timeout = Samagotchi::Config.positive_seconds("server.read_timeout", read_timeout)
177
204
  @transport = build_transport(resolve_transport(transport))
178
- @props_cache = {}
179
- @props_failures = {}
180
- @props_mutex = Mutex.new
205
+ # This host's process-wide /props entry (see self.props_entry): its
206
+ # answers and failures are shared with every other Client of the same
207
+ # server.
208
+ @props_entry = self.class.props_entry("#{@scheme}://#{@host}:#{@port}")
209
+ @props_cache = @props_entry[:answers]
210
+ @props_failures = @props_entry[:failures]
211
+ @props_mutex = @props_entry[:mutex]
181
212
  @first_token_timeout = first_token_timeout
182
213
  @host_name = name
183
214
  @label = name.to_s.empty? ? @transport.label : name.to_s
@@ -270,6 +301,10 @@ module Samagotchi
270
301
  chunk[:finish_reason] = finish_reason if finish_reason
271
302
  on_chunk.call(**chunk)
272
303
  end
304
+ # The host answered: a /props probe that failed while it was down (or
305
+ # loading) is no longer true, so the next turn asks it again instead
306
+ # of reusing the failure for the rest of its PROPS_FAILURE_TTL.
307
+ clear_props_failures!
273
308
  result
274
309
  rescue LLM::BadRequest => e
275
310
  raise unless e.status == 404 && @transport.name == :llama_cpp
@@ -293,6 +328,7 @@ module Samagotchi
293
328
  def list_models
294
329
  uri = URI("#{@scheme}://#{@host}:#{@port}#{@transport.models_path}")
295
330
  response = @http.fetch(uri, Net::HTTP::Get.new(uri), log_fields: { purpose: "models" })
331
+ clear_props_failures!
296
332
  parsed = JSON.parse(response.body.to_s)
297
333
  parsed.fetch("data", parsed)
298
334
  rescue LLM::ProviderError
@@ -309,7 +345,9 @@ module Samagotchi
309
345
  # Whatever the server answers (a non-200 too) is cached per model; a
310
346
  # network failure for PROPS_FAILURE_TTL seconds, then the next call asks
311
347
  # again. A probe cut by this thread's Client.probe_cancel answers
312
- # :cancelled, uncached, and the turn's next request ends it.
348
+ # :cancelled, uncached, and the turn's next request ends it. A completion
349
+ # or model list this client gets through clears the failures: a server
350
+ # that just answered is not the down or hung one the window remembered.
313
351
  def server_props(model: nil)
314
352
  path = @transport.props_path
315
353
  return nil unless path
@@ -350,11 +388,12 @@ module Samagotchi
350
388
  nil
351
389
  end
352
390
 
353
- # Forget cached /props answers: the server may have restarted with
354
- # another -c, or a model switch may have loaded one with a different
355
- # window. A recent failure stays until its PROPS_FAILURE_TTL ends: it
356
- # holds no stale window, and asking a hung server again at every turn's
357
- # start is what it saves.
391
+ # Forget cached /props answers for this host: the server may have
392
+ # restarted with another -c, or a model switch may have loaded one with a
393
+ # different window. The host's entry is shared with every Client of the
394
+ # same server, so the clear reaches them all. A recent failure stays
395
+ # until its PROPS_FAILURE_TTL ends: it holds no stale window, and asking
396
+ # a hung server again at every turn's start is what it saves.
358
397
  def invalidate_context_window!
359
398
  @props_mutex.synchronize { @props_cache.clear }
360
399
  end
@@ -363,6 +402,15 @@ module Samagotchi
363
402
 
364
403
  def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
365
404
 
405
+ # The host answered a request: whatever a probe failed with (refused,
406
+ # timed out, 503 while it loaded) is stale, so the next probe asks it
407
+ # again rather than serving the failure for the rest of its
408
+ # PROPS_FAILURE_TTL. A cached answer stays: it was read from the same
409
+ # running server.
410
+ def clear_props_failures!
411
+ @props_mutex.synchronize { @props_failures.clear }
412
+ end
413
+
366
414
  def probe_props(path, model)
367
415
  query = model.empty? ? "" : "?#{URI.encode_www_form(model: model)}"
368
416
  uri = URI("#{@scheme}://#{@host}:#{@port}#{path}#{query}")
@@ -142,7 +142,8 @@ module Samagotchi
142
142
  model_key: -> { @model_key },
143
143
  model_name: -> { guardrail_model_name },
144
144
  cancelled: -> { !!active_cancel_controller&.cancelled? },
145
- ask: ->(fields) { open_question(fields) }
145
+ ask: ->(fields) { open_question(fields) },
146
+ notify: ->(message) { guardrail_notify(message) }
146
147
  )
147
148
  @guardrail_failures = @guardrail_wiring.failures
148
149
  # Plugins that failed to load: announced apart, as plugins (not
@@ -1194,6 +1195,16 @@ module Samagotchi
1194
1195
  end
1195
1196
  private :hook_notify
1196
1197
 
1198
+ # A guardrails line to the user after load (:guardrail_warning, as the
1199
+ # load failures show): a running turn's event, else announced.
1200
+ def guardrail_notify(message)
1201
+ event = { type: :guardrail_warning, message: message.to_s }
1202
+ in_turn, sink = @turn_state.in_turn_sink
1203
+ in_turn ? emit_event(sink, event) : announce_or_hold(event)
1204
+ nil
1205
+ end
1206
+ private :guardrail_notify
1207
+
1197
1208
  # A question through the question flow (REPL sync handler, attached TUI,
1198
1209
  # web), single-select, kind "hook". A --non-interactive run has no one
1199
1210
  # to ask: nil at once. Anything but an answer (a sync handler's text, no
@@ -1569,13 +1580,13 @@ module Samagotchi
1569
1580
  emit_event(turn.on_event, turn.tag(turn_started))
1570
1581
  raise image_error if image_error
1571
1582
 
1572
- # Ask the server for its window again each turn (one short /props GET,
1573
- # cached across the turn's generations): a restart with another -c
1574
- # between turns raises no error that would drop the cache. The
1575
- # profile may probe too (a first turn, a retry). Both run after
1576
- # run_turn's probe-cancel swap, so a Stop cuts them, and a failure
1577
- # ends the turn as turn_failed.
1578
- @client.invalidate_context_window!
1583
+ # The window and the profile probes run after run_turn's probe-cancel
1584
+ # swap, so a Stop cuts them; a failure ends the turn as turn_failed.
1585
+ # The cache itself was dropped at the previous turn's end, so this
1586
+ # turn's first probe asks the server (a restart with another -c
1587
+ # between turns is noticed), and the answer the session's prompt build
1588
+ # read for its profile (a first turn) is what this turn starts from:
1589
+ # one GET /props per turn, not two.
1579
1590
  refresh_profile!
1580
1591
 
1581
1592
  # Before anything of the turn is kept or a reminder is used up.
@@ -1828,6 +1839,13 @@ module Samagotchi
1828
1839
  # treats the just-finished turn as activity and re-arms its window.
1829
1840
  left = @turn_state.finish!
1830
1841
  Client.swap_probe_cancel(probe_cancel_before)
1842
+ # The next turn's window and served model are asked again: the cache
1843
+ # (this host's, process-wide) is dropped here, at the turn's end,
1844
+ # rather than at its start, so what this turn read (the profile's own
1845
+ # probe included) is not asked for a second time within the turn. A
1846
+ # restart with another -c between turns is still noticed: the next
1847
+ # turn's first probe goes to the server.
1848
+ @client.invalidate_context_window!
1831
1849
  log_dropped_steers(left, "turn_ended")
1832
1850
  record_activity
1833
1851
  # Clear hooks so they remain turn-scoped and never leak into the next turn.
@@ -5,6 +5,8 @@ require "yaml"
5
5
  require_relative "config"
6
6
  require_relative "log"
7
7
  require_relative "guardrails"
8
+ require_relative "memory_bundle/manifest"
9
+ require_relative "version"
8
10
  require_relative "hooks"
9
11
  require_relative "edit_preview"
10
12
  require_relative "tool_activity"
@@ -32,7 +34,10 @@ module Samagotchi
32
34
  # @param model_name [#call] → String, nil: the effective model's bare name
33
35
  # @param cancelled [#call] → Boolean, whether the running turn is cancelled
34
36
  # @param ask [#call] (fields) → the answer; the Engine's question flow
35
- def initialize(scratch:, hooks:, tools:, session:, model_key:, cancelled:, ask:, model_name: -> {})
37
+ # @param notify [#call] (message) → shows a guardrails line in every UI
38
+ # (a bundle's rules kept because it needs a newer chi)
39
+ def initialize(scratch:, hooks:, tools:, session:, model_key:, cancelled:, ask:, model_name: -> {},
40
+ notify: ->(_message) {})
36
41
  @scratch = scratch
37
42
  @hooks_lookup = hooks
38
43
  @tools_lookup = tools
@@ -41,6 +46,11 @@ module Samagotchi
41
46
  @model_name_lookup = model_name
42
47
  @cancelled_lookup = cancelled
43
48
  @ask = ask
49
+ @notify = notify
50
+ # Bundle name → the rules last loaded from it whole, kept while the
51
+ # installed version needs a newer chi; [name, version] said so once.
52
+ @loaded_bundle_rules = {}
53
+ @kept_noticed = {}
44
54
  @failures = Guardrails::LoadFailures.new
45
55
  @rules_mutex = Mutex.new
46
56
  @git = Guardrails::GitInfo.new
@@ -115,7 +125,9 @@ module Samagotchi
115
125
 
116
126
  # Installed bundles' guardrails/*.yml, by bundle name then file name.
117
127
  # A file that is missing, changed since install (sha256) or doesn't
118
- # parse is a required load failure.
128
+ # parse is a required load failure. A bundle whose requires_chi this
129
+ # chi doesn't meet isn't read (its rules may use keys this chi doesn't
130
+ # know): the rules last loaded from it stay (#too_new_bundle_rules).
119
131
  def bundle_rules
120
132
  require_relative "memory_bundle/provenance"
121
133
  rules = []
@@ -125,7 +137,13 @@ module Samagotchi
125
137
  @failures.add("rules (bundle #{bundle_name})", data[:error], required: true, group: :rules)
126
138
  next
127
139
  end
140
+ if (too_new = MemoryBundle::Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION))
141
+ rules.concat(too_new_bundle_rules(bundle_name, data, too_new))
142
+ next
143
+ end
128
144
  dir = MemoryBundle::Provenance.new(name: bundle_name).guardrails_dir
145
+ loaded = []
146
+ whole = true
129
147
  data[:guardrails].sort_by { |k, _| k.to_s }.each do |basename, meta|
130
148
  what = "rules #{basename} (bundle #{bundle_name})"
131
149
  path = File.join(dir, basename.to_s)
@@ -139,12 +157,15 @@ module Samagotchi
139
157
  doc = YAML.safe_load(File.read(path))
140
158
  raise Guardrails::Rules::ParseError, "expected a mapping with rules:" unless doc.is_a?(Hash)
141
159
 
142
- rules.concat(Guardrails::Rules.parse(doc["rules"], source: "bundle #{bundle_name}"))
160
+ loaded.concat(Guardrails::Rules.parse(doc["rules"], source: "bundle #{bundle_name}"))
143
161
  rescue Guardrails::Rules::ParseError, Psych::Exception => e
144
162
  Log.warn(:guardrails, "rules_file_invalid", echo: "[samagotchi:guardrails] #{what}: #{e.message}", bundle: bundle_name, file: basename.to_s)
145
163
  @failures.add(what, e.message, required: true, group: :rules)
164
+ whole = false
146
165
  end
147
166
  end
167
+ rules.concat(loaded)
168
+ @loaded_bundle_rules[bundle_name] = loaded if whole
148
169
  end
149
170
  rules
150
171
  rescue StandardError => e
@@ -192,6 +213,38 @@ module Samagotchi
192
213
 
193
214
  private
194
215
 
216
+ # The rules of a bundle that needs a newer chi: the ones last loaded
217
+ # from it, with one notice per bundle version (restart the session to
218
+ # load the new ones). Never loaded (a chi older than the bundle it
219
+ # starts with): none, and a required load failure, as the hook loader
220
+ # does for a guardrail hook, so the calls aren't quietly unguarded.
221
+ def too_new_bundle_rules(bundle_name, data, reason)
222
+ kept = @loaded_bundle_rules[bundle_name]
223
+ unless kept
224
+ Log.warn(:guardrails, "bundle_rules_requires_chi", echo: "[samagotchi:guardrails] bundle #{bundle_name}: its rules are not loaded: #{reason}",
225
+ bundle: bundle_name)
226
+ @failures.add("rules (bundle #{bundle_name})", "#{reason}; update chi (chi update) or uninstall the bundle",
227
+ required: true, group: :rules)
228
+ return []
229
+ end
230
+
231
+ key = [bundle_name, data[:version].to_s]
232
+ return kept if @kept_noticed[key]
233
+
234
+ @kept_noticed[key] = true
235
+ Log.warn(:guardrails, "bundle_rules_kept", bundle: bundle_name, version: data[:version].to_s,
236
+ requires_chi: data[:requires_chi].to_s)
237
+ @notify.call(kept_notice(bundle_name, data))
238
+ kept
239
+ end
240
+
241
+ def kept_notice(bundle_name, data)
242
+ id = @session_lookup.call&.id || "ID"
243
+ "bundle #{bundle_name} #{data[:version]} needs chi #{data[:requires_chi]} and this session runs chi #{Samagotchi::VERSION}: " \
244
+ "its new rules are not loaded, the ones loaded before still apply. Restart the session to load them " \
245
+ "(chi sessions stop #{id}, then chi --resume #{id})"
246
+ end
247
+
195
248
  # [path, mtime, size] of config.yml and every installed bundle's
196
249
  # manifest.json and guardrails/ file.
197
250
  def rules_stamp
@@ -168,7 +168,7 @@ module Samagotchi
168
168
 
169
169
  # Send +request+ and return the response with its body read.
170
170
  # @param retries [Boolean] false: one attempt, network errors raised as
171
- # is (Net::HTTP's own silent retry of an idempotent GET included)
171
+ # is; either way Net::HTTP's own silent retry is off (see #start)
172
172
  # @param check_status [Boolean] false: return an error response instead
173
173
  # of raising its ProviderError
174
174
  # @raise [RequestCancelled] when +cancel_controller+ cancels (with or
@@ -178,7 +178,7 @@ module Samagotchi
178
178
  identify(request)
179
179
  current = new_attempt_state(uri, request, log_fields, stream: false)
180
180
  attempt = lambda do |state|
181
- start(uri, open_timeout: open_timeout, read_timeout: read_timeout, max_retries: retries ? nil : 0) do |http|
181
+ start(uri, open_timeout: open_timeout, read_timeout: read_timeout, max_retries: 0) do |http|
182
182
  state[:http] = http
183
183
  http.request(request).tap do |response|
184
184
  state[:status] = response.code.to_i
@@ -210,7 +210,13 @@ module Samagotchi
210
210
 
211
211
  def start(uri, open_timeout: nil, read_timeout: nil, max_retries: nil, &block)
212
212
  options = { open_timeout: open_timeout || @open_timeout, read_timeout: read_timeout || @read_timeout }
213
- options[:max_retries] = max_retries if max_retries
213
+ # Net::HTTP retries an idempotent request once on its own (max_retries
214
+ # defaults to 1) when the connection drops before an answer: chi's
215
+ # RetryPolicy owns retries (and the log line says how many were sent),
216
+ # so every request here gets Net::HTTP's silent one off. Callers may
217
+ # still ask for it explicitly (a fetch whose retries: false passes 0).
218
+ retries = max_retries || 0
219
+ options[:max_retries] = retries
214
220
  options[:use_ssl] = true if uri.scheme == "https"
215
221
  Net::HTTP.start(uri.host, uri.port, **options, &block)
216
222
  end
@@ -112,7 +112,7 @@ module Samagotchi
112
112
  end
113
113
 
114
114
  def self.default
115
- gemma4
115
+ named(DEFAULT_NAME)
116
116
  end
117
117
 
118
118
  def self.normalize(value)
@@ -124,7 +124,7 @@ module Samagotchi
124
124
  when "gemma", "gemma4", "gemma4o"
125
125
  gemma4
126
126
  else
127
- gemma4
127
+ named(DEFAULT_NAME)
128
128
  end
129
129
  end
130
130
 
@@ -45,13 +45,17 @@ module Samagotchi
45
45
  )
46
46
  INDIVIDUAL_RE.freeze
47
47
 
48
- # Angle-bracketed literal tokens stripped wholesale (including the thought
49
- # channel's trailing word). Applied before INDIVIDUAL_RE so multi-part tokens
50
- # are removed as a unit.
51
48
  # Angle-bracketed literal tokens stripped wholesale (including the thought
52
49
  # channel's trailing word). Applied before INDIVIDUAL_RE so multi-part tokens
53
50
  # are removed as a unit. Pipes are escaped; angle brackets are literal.
54
- LITERALS_RE = /<\|"\|>|<\|\w*channel>thought|<end_of_turn>/.freeze
51
+ LITERALS_RE = /<\|"\|>|<end_of_turn>/.freeze
52
+
53
+ # Gemma 4's thought channel, as a whole block: `<|channel>thought` to its
54
+ # `<channel|>` close, or to the end of the text when the close is missing
55
+ # (ToolCallParser::Gemma strips the same pair). Removing only the marker
56
+ # would leave the model's private reasoning in the answer; the channel is
57
+ # the profile's thinking lane, not output.
58
+ GEMMA_THOUGHT_RE = /<\|\w*channel>thought.*?(?:<channel\|>|\z)/m.freeze
55
59
 
56
60
  # Qwen prompt-literal placeholders, e.g. </think>.
57
61
  PROMPT_LITERALS = /\[\[SAMAGOTCHI_LITERAL_[A-Z_]+\]\]/.freeze
@@ -88,6 +92,7 @@ module Samagotchi
88
92
  def remove_tokens(text)
89
93
  text.to_s
90
94
  .gsub(QWEN_THINK_RE, '')
95
+ .gsub(GEMMA_THOUGHT_RE, '')
91
96
  .gsub(QWEN_TOOL_CALL_RE, '')
92
97
  .gsub(LITERALS_RE, '')
93
98
  .gsub(INDIVIDUAL_RE, '')
@@ -17,16 +17,26 @@ module Samagotchi
17
17
  name.is_a?(String) && !name.strip.empty? ? name : nil
18
18
  end
19
19
 
20
+ # The separators a provider uses when it decorates a model name: a tag
21
+ # (`qwen3:latest`), a date or quant suffix (`gpt-4o-2024-08-06`), an
22
+ # owner (`@org/name`) or a path (`meta-llama/Llama-3`). Anything else
23
+ # after the name is a different model (`gpt-4` is not `gpt-4o`).
24
+ EXTENSION_SEPARATORS = [":", "-", "@", "/"].freeze
25
+
20
26
  # Whether +served+ is another model than +asked+. Names that are equal
21
- # (any case) or where one extends the other (a provider dropping
22
- # `:free`, or adding a date) are the same model; unknown names never
23
- # differ.
27
+ # (any case), or where one extends the other at a tag/date/owner
28
+ # separator (a provider dropping `:free`, or adding a date), are the
29
+ # same model; unknown names never differ.
24
30
  def differs?(asked, served)
25
31
  asked = asked.to_s.strip.downcase
26
32
  served = served.to_s.strip.downcase
27
33
  return false if asked.empty? || served.empty?
34
+ return false if asked == served
35
+
36
+ shorter, longer = asked.length <= served.length ? [asked, served] : [served, asked]
37
+ return true unless longer.start_with?(shorter)
28
38
 
29
- !(asked.start_with?(served) || served.start_with?(asked))
39
+ !longer[shorter.length..].start_with?(*EXTENSION_SEPARATORS)
30
40
  end
31
41
  end
32
42
  end
@@ -237,6 +237,8 @@ module Samagotchi
237
237
  end_turn
238
238
  own_turn_ended(event)
239
239
  when :prompt_restored then restore_prompt(event)
240
+ # The after_turn hooks are done (their notices came before it).
241
+ when :answer_display then @display_pending = false
240
242
  when :context_status, :used_memories_updated then @status.take_event(event)
241
243
  # Another client's anytime command: its line now, before the cards
242
244
  # it shows (its command_ran comes when it's done).
@@ -580,9 +582,10 @@ module Samagotchi
580
582
  @screen.commit("could not send the prompt (#{worker_down(e)})")
581
583
  end
582
584
 
583
- # Whether the input's end must wait: the -p prompt isn't sent yet, or
584
- # a prompt this run sent hasn't had its turn.
585
- def own_turns_pending? = !@first_prompt.to_s.strip.empty? || !@open_ids.empty?
585
+ # Whether the input's end must wait: the -p prompt isn't sent yet, a
586
+ # prompt this run sent hasn't had its turn, or its turn's after_turn
587
+ # hooks still run (their notices, source-links' sources:, come next).
588
+ def own_turns_pending? = !@first_prompt.to_s.strip.empty? || !@open_ids.empty? || @display_pending == true
586
589
 
587
590
  # A turn ended: this run's prompt (and ours merged into it) had its turn.
588
591
  def own_turn_ended(event)
@@ -592,6 +595,8 @@ module Samagotchi
592
595
  ours = @open_ids.include?(id) || !@merged_ids.empty?
593
596
  @own_failed = true if ours && event[:type] == :turn_failed
594
597
  @own_empty = true if ours && event[:type] == :turn_completed && event.dig(:turn_summary, :empty_answer)
598
+ # The worker sends answer_display once its after_turn hooks ran.
599
+ @display_pending = true if ours && event[:type] == :turn_completed && event[:display_pending]
595
600
  @open_ids.delete(id)
596
601
  @open_ids.subtract(@merged_ids)
597
602
  @merged_ids.clear
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "formatting"
4
+ require_relative "event_renderer"
5
+
6
+ module Samagotchi
7
+ class TerminalUI
8
+ # The turn sink of `chi -p … --non-interactive` (Engine#run_turn's
9
+ # on_event): the few lines a reader wants while the turn runs, on
10
+ # stderr. stdout is the answer's alone, printed after the turn: a
11
+ # script or a parent agent reads it.
12
+ #
13
+ # It prints the retry lines the REPL shows: the loop asking again after
14
+ # an empty answer (:empty_answer_retry), and a generation retried after
15
+ # a failed request (:generation_retrying). And the hooks' notices
16
+ # (:hook_notice): one during the turn as it comes, one the after_turn
17
+ # hooks give (after :turn_completed; source-links' `sources:`) kept for
18
+ # #flush, which the caller runs after it has printed the answer.
19
+ class OneShotSink
20
+ include Formatting
21
+
22
+ # @param err [IO, nil] nil writes to $stderr as it is at each write
23
+ def initialize(err: nil)
24
+ @err = err
25
+ @turn_over = false
26
+ @held = []
27
+ end
28
+
29
+ def call(event)
30
+ case event[:type]
31
+ when :empty_answer_retry then line(format_empty_retry_line(event))
32
+ when :generation_retrying then line(format_generation_retry_line(event))
33
+ when :turn_completed, :turn_canceled then @turn_over = true
34
+ when :hook_notice
35
+ text = EventRenderer.hook_notice_line(event)
36
+ @turn_over ? @held << text : line(text)
37
+ end
38
+ end
39
+
40
+ # Print the notices that came after the turn's end.
41
+ def flush
42
+ line(@held.shift) until @held.empty?
43
+ end
44
+
45
+ private
46
+
47
+ def line(text)
48
+ err.puts(text)
49
+ err.flush
50
+ end
51
+
52
+ def err = @err || $stderr
53
+
54
+ # Colour when stderr, where the lines go, is a colour terminal.
55
+ def color_output?
56
+ return false unless err.tty?
57
+ return false if ENV.key?("NO_COLOR")
58
+
59
+ ENV.fetch("TERM", "") != "dumb"
60
+ end
61
+ end
62
+ end
63
+ end
@@ -30,6 +30,7 @@ require_relative "terminal_ui/input_support"
30
30
  require_relative "terminal_ui/image_input"
31
31
  require_relative "terminal_ui/plain_surface"
32
32
  require_relative "terminal_ui/live_region"
33
+ require_relative "terminal_ui/one_shot_sink"
33
34
  require_relative "terminal_ui/question_prompt"
34
35
  require_relative "terminal_ui/repl_input"
35
36
  require_relative "terminal_ui/status_row"
@@ -210,7 +211,9 @@ module Samagotchi
210
211
  # follow-up REPL (--non-interactive) or drops into the REPL carrying the
211
212
  # post-turn conversation.
212
213
  # @return [Symbol, nil] :turn_failed when lines came from a pipe and a
213
- # turn failed (bin/chi exits 1)
214
+ # turn failed, :empty_answer when a -p --non-interactive turn ended
215
+ # with no answer (bin/chi exits 1 on both), :interrupted when Ctrl-C
216
+ # cancelled that turn (bin/chi exits 130)
214
217
  def run
215
218
  # --non-interactive with no --prompt is a harmless no-op exit: build
216
219
  # nothing and return (no transient session, no banner).
@@ -240,25 +243,36 @@ module Samagotchi
240
243
  if @prompt && @non_interactive
241
244
  # Headless / CI mode: run directly without TTY rendering.
242
245
  ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
243
- result = @engine.run_turn(
244
- session,
245
- @prompt,
246
- on_event: nil,
247
- max_iterations: IterationLimit.for(no_interrupt: true),
248
- cancel_controller: nil,
249
- images: ImageInput.extract(@prompt)
250
- )
246
+ # Retry lines and hook notices on stderr; stdout gets the answer below.
247
+ sink = OneShotSink.new
248
+ begin
249
+ result = @engine.run_turn(
250
+ session,
251
+ @prompt,
252
+ on_event: sink,
253
+ max_iterations: IterationLimit.for(no_interrupt: true),
254
+ cancel_controller: nil,
255
+ images: ImageInput.extract(@prompt)
256
+ )
257
+ rescue LLM::ProviderError, ImageStore::Error => e
258
+ return one_shot_failed(session, e)
259
+ rescue Interrupt
260
+ return one_shot_interrupted(session)
261
+ end
251
262
  # No answer (the empty-answer retries used up): no text, or the chat
252
263
  # loop's placeholder (LLM::ModelResult#empty_answer?).
253
264
  if result.empty_answer?
254
- # Nothing to print: say so on stderr and fail, so a script that
255
- # pipes the answer doesn't take the silence for one.
265
+ # Nothing to print: say so on stderr and fail (bin/chi exits 1),
266
+ # so a script that pipes the answer doesn't take the silence for one.
256
267
  session.save
257
268
  warn EMPTY_ANSWER_ERROR
258
- exit 1
269
+ sink.flush
270
+ return :empty_answer
259
271
  end
260
272
  @surface.commit(result.output)
261
273
  session.save
274
+ # The after_turn hooks' notices, after the answer as the REPL shows them.
275
+ sink.flush
262
276
  return
263
277
  end
264
278
 
@@ -286,6 +300,18 @@ module Samagotchi
286
300
 
287
301
  EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
288
302
 
303
+ # A -p --non-interactive turn that failed (bin/chi exits 1): the error,
304
+ # then how to go on. The Engine saved the failed turn with its prompt,
305
+ # so the session is kept and `chi --resume` takes it up again.
306
+ # @return [Symbol] :turn_failed
307
+ def one_shot_failed(session, error)
308
+ warn "Error: #{error.respond_to?(:summary) ? error.summary : error.message}"
309
+ if !@scratch && Session.exist?(session.id)
310
+ warn "chi: the session is kept with your prompt; continue it with: chi --resume #{session.id}"
311
+ end
312
+ :turn_failed
313
+ end
314
+
289
315
  # `chi -p "/models" --non-interactive`: the command's output; a refused
290
316
  # one fails the run (bin/chi exits 1). The session is saved only when
291
317
  # the command changed it.
@@ -302,6 +328,19 @@ module Samagotchi
302
328
  :turn_failed if result.status == :error
303
329
  end
304
330
 
331
+ # Ctrl-C in a -p --non-interactive turn: the Engine kept the prompt and
332
+ # a cancel note in the session (it saves only a failed turn), so save
333
+ # it here: `chi --resume` takes it up again. A scratch session goes in
334
+ # #run's ensure, so there is nothing to keep.
335
+ # @return [Symbol] :interrupted
336
+ def one_shot_interrupted(session)
337
+ return :interrupted if @scratch
338
+
339
+ session.save
340
+ warn "chi: canceled (Ctrl-C); the session is kept: continue it with chi --resume #{session.id}"
341
+ :interrupted
342
+ end
343
+
305
344
  # Delete the scratch session, the last thing the REPL does. Quiet after
306
345
  # a one-shot or on the way out of an error or a signal.
307
346
  def delete_scratch(session, quiet:)
@@ -400,19 +439,29 @@ module Samagotchi
400
439
  system_message = { role: "system", content: seed_system_prompt }
401
440
  if @resume_session
402
441
  messages = ContextNote.with_system_head(session.messages.dup, system_message)
403
- @surface.commit("Resumed session: #{session.id}")
442
+ banner("Resumed session: #{session.id}")
404
443
  saved = @engine.saved_recap
405
- @surface.commit(recap_block(saved[:text], turns_since: saved[:turns_since])) if saved
444
+ banner(recap_block(saved[:text], turns_since: saved[:turns_since])) if saved
406
445
  elsif @scratch
407
446
  messages = [system_message]
408
- @surface.commit("Scratch session: nothing is kept, it is deleted when you leave.")
447
+ banner("Scratch session: nothing is kept, it is deleted when you leave.")
409
448
  else
410
449
  messages = [system_message]
411
- @surface.commit("Session: #{session.id}")
450
+ banner("Session: #{session.id}")
412
451
  end
413
452
  messages
414
453
  end
415
454
 
455
+ # A line about the session, not the answer: on stderr in a
456
+ # --non-interactive run, whose stdout is the answer's alone (a script
457
+ # or a parent agent reads it), and before the turn, so it comes first.
458
+ def banner(text)
459
+ return @surface.commit(text) unless @non_interactive
460
+
461
+ $stderr.puts(text)
462
+ $stderr.flush
463
+ end
464
+
416
465
  # Interactive REPL loop. Session seed + messages are built by #run and
417
466
  # threaded in here (so --prompt / --resume share one code path). The
418
467
  # session's conversation is the single working copy: turns go through
@@ -18,7 +18,7 @@ module Samagotchi
18
18
  TOOL_SCHEMAS = [
19
19
  {
20
20
  name: "execute",
21
- description: "Run any shell command in a working directory (defaults to the project root) and see stdout, stderr, and exit code. Large output may be truncated to a head+tail preview with metadata.",
21
+ description: "Run any shell command in a working directory (defaults to the project root) and see stdout, stderr, and exit code. Large output may be truncated to a head+tail preview with metadata. Don't background with &; use task_create.",
22
22
  parameters: {
23
23
  type: "object",
24
24
  properties: {
@@ -153,7 +153,7 @@ module Samagotchi
153
153
  },
154
154
  {
155
155
  name: "task_create",
156
- description: "Start a background task for a long-running shell command. Returns task id and output path for later inspection.",
156
+ description: "Start a background task for a long-running shell command. Servers and anything meant to keep running belong here, not in execute with &. Returns task id and output path for later inspection.",
157
157
  parameters: {
158
158
  type: "object",
159
159
  properties: {
@@ -21,6 +21,15 @@ module Samagotchi
21
21
  WAIT_SLICE_SEC = 0.2
22
22
  NOT_RUN_ON_STOP = "Error: not run, the user stopped the turn"
23
23
  TIMEOUT_HINT = "(killed at the limit; for a long command use task_create, then task_wait)"
24
+ # How long the output may stay open once the shell has exited: a
25
+ # `server &` child keeps the pipes, and the read would never end.
26
+ BACKGROUND_GRACE_SEC = 1.5
27
+ BACKGROUND_HINT = "(a background process kept the output open and was stopped; " \
28
+ "to keep a server or long job running, start it with task_create)"
29
+ # It had left the process group (setsid), so it is still running.
30
+ BACKGROUND_DETACHED_HINT = "(a background process kept the output open and was left running; " \
31
+ "to keep a server or long job running, start it with task_create)"
32
+ READ_CHUNK_BYTES = 65_536
24
33
 
25
34
  def self.name = NAME
26
35
 
@@ -35,7 +44,7 @@ module Samagotchi
35
44
  return "Error: cwd not found: #{resolved_cwd}" unless resolved_cwd
36
45
 
37
46
  timeout_sec = timeout_seconds
38
- stdout, stderr, status = run_command(command, timeout_sec: timeout_sec, cwd: resolved_cwd, cancelled: cancelled,
47
+ stdout, stderr, status, held_open = run_command(command, timeout_sec: timeout_sec, cwd: resolved_cwd, cancelled: cancelled,
39
48
  env: env)
40
49
 
41
50
  stdout_block = output_block("stdout", stdout)
@@ -50,6 +59,7 @@ module Samagotchi
50
59
  # model as "done, something happened".
51
60
  silent = stdout_block.nil? && stderr_block.nil?
52
61
  parts << (silent ? "exit: #{status.exitstatus} (no output)" : "exit: #{status.exitstatus}")
62
+ parts << (held_open == :detached ? BACKGROUND_DETACHED_HINT : BACKGROUND_HINT) if held_open
53
63
  parts.join("\n")
54
64
  rescue CommandTimedOut => e
55
65
  # Keep the "Error:" first line (callers classify on it), say how to run
@@ -75,13 +85,14 @@ module Samagotchi
75
85
  status = nil
76
86
  timed_out = false
77
87
  was_cancelled = false
88
+ aborted = false
89
+ held_open = false
78
90
  started = monotonic_time
79
91
  deadline = started + timeout_sec
80
92
 
81
93
  Open3.popen3(env || {}, command, chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
82
94
  stdin.close
83
- stdout_reader = reader_thread_for(stdout)
84
- stderr_reader = reader_thread_for(stderr)
95
+ readers = [OutputReader.new(stdout), OutputReader.new(stderr)]
85
96
 
86
97
  begin
87
98
  until wait_thr.join(WAIT_SLICE_SEC)
@@ -100,36 +111,103 @@ module Samagotchi
100
111
  # An Interrupt (or anything else) mid-wait: the child is in its own
101
112
  # process group, so it never saw the SIGINT. Kill it, or the reads
102
113
  # below block until it closes stdout.
114
+ aborted = true
103
115
  terminate_process_tree(wait_thr.pid)
104
116
  raise
105
117
  ensure
106
- # Read the buffered output before closing the pipes. For a process
107
- # that exits almost instantly (e.g. `echo hello`), the background
108
- # reader thread may not have scheduled its `io.read` yet; joining
109
- # the reader after the pipe is closed would return "".
110
- stdout_text = stdout_reader.value
111
- stderr_text = stderr_reader.value
112
- close_quietly(stdout)
113
- close_quietly(stderr)
118
+ # The shell is gone, but a child it put in the background (`cmd &`)
119
+ # may still hold the pipes: wait for EOF a short grace only, then
120
+ # stop the command's process group and keep the output so far.
121
+ # Already stopped (timeout, Stop, an exception): no more checks.
122
+ stopping = timed_out || was_cancelled || aborted
123
+ outcome = await_readers(readers, until_time: monotonic_time + BACKGROUND_GRACE_SEC,
124
+ deadline: stopping ? nil : deadline,
125
+ cancelled: stopping ? -> { false } : cancelled)
126
+ unless outcome == :done
127
+ was_cancelled = true if outcome == :cancelled
128
+ timed_out = true if outcome == :timeout
129
+ stopped = stop_process_group(wait_thr.pid, readers)
130
+ held_open = (stopped ? :stopped : :detached) if outcome == :grace && !stopping
131
+ end
132
+ readers.each(&:finish)
133
+ stdout_text, stderr_text = readers.map(&:text)
114
134
  end
115
135
  end
116
136
 
117
137
  raise CommandTimedOut.new(stdout_text, stderr_text) if timed_out
118
138
  raise CommandCancelled.new(stdout_text, stderr_text, monotonic_time - started) if was_cancelled
119
139
 
120
- [stdout_text, stderr_text, status]
140
+ [stdout_text, stderr_text, status, held_open]
121
141
  end
122
142
  private_class_method :run_command
123
143
 
124
- def self.reader_thread_for(io)
125
- Thread.new do
126
- Thread.current.report_on_exception = false
127
- io.read.to_s
128
- rescue IOError, EOFError
129
- ""
144
+ # Waits for both readers to hit EOF until +until_time+, the overall
145
+ # +deadline+ or a Stop. Returns :done, :grace, :timeout or :cancelled.
146
+ def self.await_readers(readers, until_time:, deadline:, cancelled:)
147
+ loop do
148
+ return :done if readers.all?(&:done?)
149
+ return :cancelled if cancelled.call
150
+ return :timeout if deadline && monotonic_time > deadline
151
+ return :grace if monotonic_time > until_time
152
+
153
+ readers.find { |r| !r.done? }&.wait(STOP_POLL_INTERVAL_SEC)
154
+ end
155
+ end
156
+ private_class_method :await_readers
157
+
158
+ # The shell has exited (its pid is reaped) but its process group still
159
+ # has members holding the output: TERM the group, then KILL it. A process
160
+ # that left the group (setsid) is out of reach; the pipes get closed.
161
+ # True once the output was released, false if something still holds it.
162
+ def self.stop_process_group(pgid, readers)
163
+ signal_group(pgid, "TERM")
164
+ return true if await_readers(readers, until_time: monotonic_time + STOP_GRACE_SEC, deadline: nil,
165
+ cancelled: -> { false }) == :done
166
+
167
+ signal_group(pgid, "KILL")
168
+ await_readers(readers, until_time: monotonic_time + STOP_GRACE_SEC, deadline: nil, cancelled: -> { false }) == :done
169
+ end
170
+ private_class_method :stop_process_group
171
+
172
+ # Only the group: the leader's pid is reaped, so it may name another process.
173
+ def self.signal_group(pgid, signal)
174
+ Process.kill(signal, -pgid)
175
+ rescue Errno::ESRCH, Errno::EPERM
176
+ nil
177
+ end
178
+ private_class_method :signal_group
179
+
180
+ # Reads a pipe in chunks on its own thread, so the output read so far
181
+ # survives the pipe being closed under it.
182
+ class OutputReader
183
+ def initialize(io)
184
+ @io = io
185
+ @buffer = String.new(encoding: Encoding::BINARY)
186
+ @thread = Thread.new do
187
+ Thread.current.report_on_exception = false
188
+ loop { @buffer << io.readpartial(READ_CHUNK_BYTES) }
189
+ rescue EOFError, IOError
190
+ nil
191
+ end
192
+ end
193
+
194
+ def done? = !@thread.alive?
195
+
196
+ def wait(seconds) = @thread.join(seconds)
197
+
198
+ # Closes the pipe (a reader still blocked gets IOError) and ends the
199
+ # thread; the text is safe to read afterwards.
200
+ def finish
201
+ @thread.join(STOP_POLL_INTERVAL_SEC) unless done?
202
+ Execute.send(:close_quietly, @io)
203
+ @thread.kill unless @thread.join(STOP_GRACE_SEC)
204
+ end
205
+
206
+ def text
207
+ @buffer.dup.force_encoding(@io.external_encoding || Encoding.default_external)
130
208
  end
131
209
  end
132
- private_class_method :reader_thread_for
210
+ private_constant :OutputReader
133
211
 
134
212
  def self.close_quietly(io)
135
213
  io.close unless io.closed?
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.18.0"
4
+ VERSION = "0.18.1"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.18.0
4
+ version: 0.18.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -352,6 +352,7 @@ files:
352
352
  - lib/samagotchi/terminal_ui/input_support.rb
353
353
  - lib/samagotchi/terminal_ui/line_reader.rb
354
354
  - lib/samagotchi/terminal_ui/live_region.rb
355
+ - lib/samagotchi/terminal_ui/one_shot_sink.rb
355
356
  - lib/samagotchi/terminal_ui/plain_surface.rb
356
357
  - lib/samagotchi/terminal_ui/question_prompt.rb
357
358
  - lib/samagotchi/terminal_ui/question_slot.rb