samagotchi 0.18.0 → 0.19.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +119 -1
  3. data/README.md +4 -3
  4. data/bin/chi +5 -2
  5. data/docs/architecture.md +15 -2
  6. data/docs/cli.md +41 -9
  7. data/docs/configuration.md +8 -4
  8. data/docs/guardrails.md +8 -0
  9. data/docs/memory.md +4 -0
  10. data/docs/sessions.md +6 -4
  11. data/lib/samagotchi/bridge.rb +25 -9
  12. data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
  13. data/lib/samagotchi/bridge_client.rb +9 -5
  14. data/lib/samagotchi/bundle_command.rb +2 -1
  15. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
  16. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  19. data/lib/samagotchi/client.rb +58 -10
  20. data/lib/samagotchi/engine.rb +35 -11
  21. data/lib/samagotchi/guardrail_wiring.rb +64 -5
  22. data/lib/samagotchi/guardrails/approval.rb +10 -4
  23. data/lib/samagotchi/guardrails/approvals.rb +60 -12
  24. data/lib/samagotchi/host_registry.rb +22 -4
  25. data/lib/samagotchi/idle_scheduler.rb +1 -1
  26. data/lib/samagotchi/installed_versions.rb +91 -0
  27. data/lib/samagotchi/live_versions.rb +5 -3
  28. data/lib/samagotchi/llm/http.rb +9 -3
  29. data/lib/samagotchi/memory_bundle/builder.rb +24 -1
  30. data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
  31. data/lib/samagotchi/memory_bundle/installer.rb +33 -0
  32. data/lib/samagotchi/memory_bundle/status.rb +5 -1
  33. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  34. data/lib/samagotchi/model_overlay.rb +39 -0
  35. data/lib/samagotchi/model_profile.rb +6 -2
  36. data/lib/samagotchi/output_formatter.rb +9 -4
  37. data/lib/samagotchi/relay_desk.rb +9 -0
  38. data/lib/samagotchi/served_model.rb +14 -4
  39. data/lib/samagotchi/session_manager.rb +166 -11
  40. data/lib/samagotchi/session_metrics.rb +36 -13
  41. data/lib/samagotchi/sessions_command.rb +43 -4
  42. data/lib/samagotchi/system_prompt.rb +10 -1
  43. data/lib/samagotchi/terminal_ui/attached_loop.rb +56 -6
  44. data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
  45. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
  46. data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
  47. data/lib/samagotchi/terminal_ui.rb +65 -16
  48. data/lib/samagotchi/tool_declarations.rb +2 -2
  49. data/lib/samagotchi/tools/builtins.rb +21 -5
  50. data/lib/samagotchi/tools/execute.rb +97 -19
  51. data/lib/samagotchi/turn_note.rb +3 -2
  52. data/lib/samagotchi/update_command.rb +33 -12
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +64 -11
  55. data/lib/samagotchi/web/public/app.js +102 -29
  56. data/lib/samagotchi/web/public/data.js +6 -0
  57. data/lib/samagotchi/web/public/format.js +4 -2
  58. data/lib/samagotchi/web/public/index.html +11 -0
  59. data/lib/samagotchi/web/public/question_card.js +12 -0
  60. data/lib/samagotchi/web/public/question_cards.js +17 -11
  61. data/lib/samagotchi/web/public/scope.js +15 -6
  62. data/lib/samagotchi/web/public/session_state.js +4 -0
  63. data/lib/samagotchi/web/public/turn_events.js +107 -3
  64. data/lib/samagotchi/web/public/update_notice.js +87 -14
  65. data/lib/samagotchi/web/server.rb +28 -1
  66. data/lib/samagotchi/web/session_hub.rb +30 -3
  67. data/lib/samagotchi/web/session_summary.rb +5 -0
  68. data/lib/samagotchi/worker.rb +25 -8
  69. data/lib/samagotchi/worker_idle_exit.rb +31 -2
  70. data/lib/samagotchi/worker_sidecar.rb +7 -5
  71. metadata +4 -1
@@ -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
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../installed_versions"
4
+ require_relative "../version"
5
+
6
+ module Samagotchi
7
+ class TerminalUI
8
+ # The line an attaching terminal shows when chi versions differ: the
9
+ # session's worker (its snapshot's chi_version), this terminal (VERSION)
10
+ # and the newest installed. Neither process can reload itself: the worker
11
+ # moves with `chi sessions restart`, the terminal by attaching again.
12
+ module VersionLines
13
+ module_function
14
+
15
+ # @param worker [String, nil] the worker's chi_version (nil: a worker
16
+ # from before it said so; nothing is said then)
17
+ # @param installed [String, nil] InstalledVersions#newest
18
+ # @return [String, nil] one "chi> ..." line, or nil when all is current
19
+ def at_attach(worker:, installed:, session_id:, terminal: VERSION)
20
+ return nil if worker.to_s.empty?
21
+
22
+ newest = [installed, terminal].select { |v| Gem::Version.correct?(v.to_s) && !v.to_s.empty? }
23
+ .max_by { |v| Gem::Version.new(v) }
24
+ worker_old = InstalledVersions.newer?(newest, worker)
25
+ terminal_old = InstalledVersions.newer?(newest, terminal)
26
+ return nil unless worker_old || terminal_old
27
+
28
+ id = session_id.to_s[0, 8]
29
+ restart = "chi sessions restart #{id}"
30
+ attach = "/detach, then chi --attach #{id}"
31
+ if worker_old && terminal_old
32
+ "chi> chi #{newest} is installed; this session's worker runs #{worker} and this terminal #{terminal}. " \
33
+ "#{restart} moves the worker; #{attach} for the terminal."
34
+ elsif worker_old
35
+ "chi> chi #{newest} is installed; this session's worker runs #{worker}. #{restart} moves it there " \
36
+ "(this terminal follows)."
37
+ else
38
+ "chi> chi #{newest} is installed; this terminal runs #{terminal}. #{attach} to use it."
39
+ end
40
+ end
41
+
42
+ # After the stream moved to a new worker (a restart).
43
+ def restarted(worker)
44
+ worker.to_s.empty? ? "chi> the session's worker restarted" : "chi> the session's worker restarted on chi #{worker}"
45
+ end
46
+ end
47
+ end
48
+ 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: {
@@ -22,6 +22,7 @@ require_relative "delegate"
22
22
  require_relative "delegate_result"
23
23
  require_relative "ask_user_question"
24
24
  require_relative "../guardrails/parent_approvals"
25
+ require_relative "../model_profile"
25
26
 
26
27
  module Samagotchi
27
28
  module Tools
@@ -73,7 +74,8 @@ module Samagotchi
73
74
  TaskCreate::NAME => lambda do |call, kctx|
74
75
  next Execute::NOT_RUN_ON_STOP if cancelled_proc(kctx).call
75
76
 
76
- TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env], marker_env: parent_env(kctx))
77
+ TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env],
78
+ marker_env: task_marker_env(kctx, call[:env]))
77
79
  end,
78
80
  TaskWait::NAME => lambda do |call, kctx|
79
81
  TaskWait.call(call[:content], timeout: call[:timeout], tail_lines: call[:tail_lines],
@@ -108,17 +110,31 @@ module Samagotchi
108
110
  # wrapper. The session's id, "chi" without one. And the model the
109
111
  # session runs on (SAMAGOTCHI_SESSION_MODEL, read live, so right after
110
112
  # /model), which chi self reports; unset when unknown, so a value
111
- # inherited from an outer session never passes for this one.
112
- def parent_env(kctx)
113
+ # inherited from an outer session never passes for this one. A
114
+ # worker's `chi --model X` default (ModelProfile::MODEL_FROM_CLI_ENV)
115
+ # is unset: it was that run's, not the default of a chi run here.
116
+ def parent_env(kctx, env: ENV)
113
117
  peers = kctx.peers
114
118
  id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
115
119
  model = peers.respond_to?(:model_ref) ? peers.model_ref.to_s : ""
116
- { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
117
- SESSION_MODEL_ENV => model.empty? ? nil : model }
120
+ vars = { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
121
+ SESSION_MODEL_ENV => model.empty? ? nil : model }
122
+ if env[ModelProfile::MODEL_FROM_CLI_ENV]
123
+ vars[ModelProfile::MODEL_ENV] = nil
124
+ vars[ModelProfile::MODEL_FROM_CLI_ENV] = nil
125
+ end
126
+ vars
118
127
  end
119
128
 
120
129
  SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
121
130
 
131
+ # task_create's parent_env, less the unsets of a variable the model
132
+ # set in its env (a SAMAGOTCHI_DEFAULT_MODEL it asked for stays).
133
+ def task_marker_env(kctx, env)
134
+ given = env.is_a?(Hash) ? env.keys.map(&:to_s) : []
135
+ parent_env(kctx).reject { |key, value| value.nil? && given.include?(key) }
136
+ end
137
+
122
138
  # Stop flips the turn's controller, seen through the Engine's PeerView
123
139
  # (as DelegateWait does); a bare kernel has no peers: never cancelled.
124
140
  def cancelled_proc(kctx)
@@ -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?
@@ -37,7 +37,8 @@ module Samagotchi
37
37
  # @param reason [Symbol, String, nil] the cancel reason (:ctrl_c, :user…)
38
38
  # @param seconds [Numeric, nil] how long the turn had run
39
39
  # @param shown [Boolean] visible text had streamed (an `[interrupted]`
40
- # model message precedes this note)
40
+ # model message precedes this note): an answer, or only narration
41
+ # before a tool call, so the note says "reply"
41
42
  # @param running_tasks [Array<Hash>] {id:, command:} of this session's
42
43
  # background tasks still running: a Stop doesn't end them
43
44
  # @param stopped_by [Hash, nil] {by:, reason:} of a hook that stopped
@@ -45,7 +46,7 @@ module Samagotchi
45
46
  def cancelled(reason, seconds: nil, shown: false, running_tasks: [], stopped_by: nil)
46
47
  why = reason.to_s.empty? ? "" : " (#{reason.to_s.tr("_", "-")}#{stopper(stopped_by)})"
47
48
  after = seconds ? " after #{seconds.round}s" : ""
48
- what = shown ? "the answer above ends where it was cut off." : "no answer had been shown."
49
+ what = shown ? "the reply above ends where it was cut off." : "no answer had been shown."
49
50
  message("the previous turn was cancelled#{why}#{after}; #{what}#{still_running(running_tasks)}")
50
51
  end
51
52
 
@@ -34,8 +34,9 @@ module Samagotchi
34
34
  --no-gem don't install a newer gem (config update.gem: false)
35
35
  --no-bundles leave the shipped bundles alone (config update.bundles: false)
36
36
  --no-desktop leave the desktop helper alone (config update.desktop: false)
37
- Running sessions keep their chi until they idle out (30 min) or
38
- chi sessions stop ID; a running chi web needs a restart.
37
+ Running sessions keep their chi until restarted (chi sessions
38
+ restart ID) or until they idle out with nothing attached; a running
39
+ chi web needs a restart (the sessions it starts run the new chi).
39
40
  TEXT
40
41
 
41
42
  # Help is a flag here: it doesn't stop the parse (an unknown option
@@ -238,22 +239,42 @@ module Samagotchi
238
239
  def live_rows
239
240
  rows = []
240
241
  stale = LiveVersions.stale_workers(VERSION, state_dir: @state_dir)
241
- unless stale.empty?
242
- versions = stale.map { |w| w.version || "an older chi" }.uniq.join(", ")
243
- ids = stale.map { |w| w.session_id[0, 8] }.join(" ")
244
- rows << Row.new(component: "workers",
245
- status: "#{stale.size} live on #{versions}: they move to #{VERSION} at idle exit (30 min) " \
246
- "or chi sessions stop #{ids}")
247
- end
242
+ rows << Row.new(component: "workers", status: workers_status(stale)) unless stale.empty?
248
243
  host, port = @web
249
- web = LiveVersions.web_version(host, port)
250
- if web && web != VERSION
244
+ info = LiveVersions.web_info(host, port)
245
+ web = info && info["version"].to_s
246
+ if web && !web.empty? && web != VERSION
247
+ # Since newest_workers, the sessions it starts run the newest chi.
248
+ spreads = !Array(info["features"]).include?("newest_workers")
251
249
  rows << Row.new(component: "chi web :#{port}", from: web,
252
- status: "running old code: new sessions it starts run #{web} too; restart it")
250
+ status: "running old code#{": new sessions it starts run #{web} too" if spreads}; restart it")
253
251
  end
254
252
  rows
255
253
  end
256
254
 
255
+ # A worker keeps its chi until restarted: `chi sessions restart` for
256
+ # one that can, a stop for an older one (the next prompt starts it on
257
+ # the newest). It idles out on its own only with nothing attached.
258
+ def workers_status(stale)
259
+ versions = stale.map { |w| w.version || "an older chi" }.uniq.join(", ")
260
+ restart, stop = stale.partition(&:restart).map { |ws| ws.map { |w| w.session_id[0, 8] }.join(" ") }
261
+ moves = []
262
+ moves << "chi sessions restart #{restart}" unless restart.empty?
263
+ moves << "chi sessions stop #{stop}" unless stop.empty?
264
+ "#{stale.size} live on #{versions}: they keep it until restarted (#{moves.join("; ")})#{idle_note}"
265
+ end
266
+
267
+ def idle_note
268
+ minutes = begin
269
+ Config.get("session.idle_exit_minutes").to_f
270
+ rescue StandardError
271
+ 30.0
272
+ end
273
+ return "; they never idle out (session.idle_exit_minutes: 0)" unless minutes.positive?
274
+
275
+ "; or they idle out after #{minutes.round(1).to_s.delete_suffix(".0")} min with nothing attached"
276
+ end
277
+
257
278
  def footers(options)
258
279
  return [] if off(options, "bundles")
259
280
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.18.0"
4
+ VERSION = "0.19.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end