samagotchi 0.57.0 → 0.59.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/docs/architecture.md +3 -3
  4. data/docs/cli.md +5 -4
  5. data/docs/configuration.md +40 -23
  6. data/docs/hooks.md +2 -2
  7. data/docs/internals/background-tasks.md +7 -0
  8. data/docs/internals/engine.md +5 -5
  9. data/docs/internals/prompt-caching.md +2 -2
  10. data/docs/internals/tool-guardrails.md +2 -1
  11. data/docs/plugins.md +26 -6
  12. data/docs/sessions.md +3 -3
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  14. data/lib/samagotchi/bridge.rb +6 -2
  15. data/lib/samagotchi/bundles/slow-commands/manifest.yml +5 -4
  16. data/lib/samagotchi/bundles/slow-commands/plugin.rb +169 -16
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  18. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  19. data/lib/samagotchi/config.rb +11 -5
  20. data/lib/samagotchi/engine.rb +28 -3
  21. data/lib/samagotchi/host_config.rb +3 -1
  22. data/lib/samagotchi/host_registry.rb +2 -1
  23. data/lib/samagotchi/model_profile.rb +14 -3
  24. data/lib/samagotchi/model_settings.rb +4 -2
  25. data/lib/samagotchi/process_group.rb +76 -3
  26. data/lib/samagotchi/redacted_inspect.rb +21 -6
  27. data/lib/samagotchi/send_command.rb +1 -1
  28. data/lib/samagotchi/session_commands.rb +19 -6
  29. data/lib/samagotchi/steer_cut.rb +12 -8
  30. data/lib/samagotchi/steer_cut_after.rb +69 -0
  31. data/lib/samagotchi/system_prompt.rb +5 -5
  32. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -4
  33. data/lib/samagotchi/terminal_ui/formatting.rb +9 -5
  34. data/lib/samagotchi/terminal_ui.rb +15 -6
  35. data/lib/samagotchi/tool_activity.rb +18 -1
  36. data/lib/samagotchi/tool_declarations.rb +1 -1
  37. data/lib/samagotchi/tools/execute.rb +318 -70
  38. data/lib/samagotchi/tools/task_get.rb +2 -0
  39. data/lib/samagotchi/tools/task_runtime.rb +96 -13
  40. data/lib/samagotchi/tools/task_wait.rb +23 -5
  41. data/lib/samagotchi/utf8_default.rb +1 -1
  42. data/lib/samagotchi/version.rb +1 -1
  43. data/lib/samagotchi/web/app.rb +8 -2
  44. data/lib/samagotchi/web/public/app.js +7 -6
  45. data/lib/samagotchi/web/public/index.html +20 -7
  46. data/lib/samagotchi/web/public/read_view.js +3 -1
  47. data/lib/samagotchi/web/public/ref_menu.js +188 -0
  48. data/lib/samagotchi/web/public/ref_scan.js +11 -1
  49. data/lib/samagotchi/web/public/refs.js +271 -51
  50. data/lib/samagotchi/web/public/stage_view.js +21 -0
  51. data/lib/samagotchi/web/public/task_stop.js +25 -7
  52. data/lib/samagotchi/worker.rb +2 -2
  53. metadata +3 -1
@@ -2,21 +2,28 @@
2
2
 
3
3
  module Samagotchi
4
4
  # A single redacted #inspect for the classes that hold the process
5
- # environment (@env, every API key) or the web token (@token): instead of
6
- # dumping every instance variable (which would print the keys), it shows the
7
- # rest with each value's own inspect and prints the others as [redacted]. A
8
- # failing spec, a debugger or an error message that inspects one of them used
9
- # to leak every key.
5
+ # environment (@env, every API key) or the web token (@token, or a :token
6
+ # key inside a Hash ivar, as Web::App's @lan): instead of dumping every
7
+ # instance variable (which would print the keys), it shows the rest with
8
+ # each value's own inspect and prints the others as [redacted]. A failing
9
+ # spec, a debugger or an error message that inspects one of them used to
10
+ # leak every key.
10
11
  module RedactedInspect
11
12
  # The instance variables never printed.
12
13
  REDACTED = %i[@env @token].freeze
14
+ # The keys of a Hash ivar whose values are never printed; the rest of the
15
+ # Hash is, so Web::App's @lan still shows its :ip.
16
+ REDACTED_KEYS = %i[token].freeze
17
+
18
+ # Stands in for a redacted Hash value: inspects as [redacted], unquoted.
19
+ HIDDEN = Object.new.tap { |hidden| hidden.define_singleton_method(:inspect) { "[redacted]" } }.freeze
13
20
 
14
21
  def inspect
15
22
  body = instance_variables.map do |ivar|
16
23
  if REDACTED.include?(ivar)
17
24
  "#{ivar}=[redacted]"
18
25
  else
19
- "#{ivar}=#{instance_variable_get(ivar).inspect}"
26
+ "#{ivar}=#{redacted_value(instance_variable_get(ivar)).inspect}"
20
27
  end
21
28
  end.join(", ")
22
29
  "#<#{self.class.name} #{body}>"
@@ -24,5 +31,13 @@ module Samagotchi
24
31
 
25
32
  # pp otherwise ignores inspect.
26
33
  def pretty_print(printer) = printer.text(inspect)
34
+
35
+ private
36
+
37
+ def redacted_value(value)
38
+ return value unless value.is_a?(Hash) && REDACTED_KEYS.any? { |key| value.key?(key) }
39
+
40
+ value.to_h { |key, item| [key, REDACTED_KEYS.include?(key) ? HIDDEN : item] }
41
+ end
27
42
  end
28
43
  end
@@ -586,7 +586,7 @@ module Samagotchi
586
586
  outcome = Delivery.cut?(delivery) ? ack["cut"].to_s : ""
587
587
  return " (goes in at the running turn's next step)" unless CUT_OUTCOMES.include?(outcome)
588
588
 
589
- " #{TerminalUI::Formatting.steer_cut_note(outcome.to_sym)}"
589
+ " #{TerminalUI::Formatting.steer_cut_note(outcome.to_sym, ack["cut_after"])}"
590
590
  end
591
591
 
592
592
  # More keys for --format json, with --cut only (the line is unchanged
@@ -188,12 +188,12 @@ module Samagotchi
188
188
  # @return [String, nil]
189
189
  def self.builtin_name(line) = builtin_registry.lookup(line)&.name
190
190
 
191
- # @return [String] the model /model clear goes back to
191
+ # @return [String, nil] the model /model clear goes back to; nil when none
192
192
  attr_reader :default_model
193
193
 
194
- # @param default_model [String] the config default, which /model clear
194
+ # @param default_model [String, nil] the config default, which /model clear
195
195
  # restores and /model names (the Engine may have started elsewhere, e.g.
196
- # on a resumed session's model)
196
+ # on a resumed session's model); nil when the config names no default
197
197
  # @param save [#call] saves a session (a worker passes its state dir)
198
198
  # @param registry [Commands::Registry] the commands #run looks lines up in
199
199
  def initialize(engine:, turn_flow:, default_model:, save: ->(session) { session.save },
@@ -328,7 +328,9 @@ module Samagotchi
328
328
  command = text.delete_prefix(SHELL_BANG_PREFIX).strip
329
329
  return reply("!: please provide a shell command after '!'", status: :error) if command.empty?
330
330
 
331
- output = Samagotchi::Tools::Execute.call(command, env: Samagotchi::Tools::Builtins.cli_model_unset)
331
+ # Killed at the limit, never handed over: the output goes in a user
332
+ # message, which no one reads a task id from (created_ids_in).
333
+ output = Samagotchi::Tools::Execute.call(command, env: Samagotchi::Tools::Builtins.cli_model_unset, on_timeout: :kill)
332
334
  @engine.append_messages([{ role: "user", content: "!(#{command})\n#{output}" }])
333
335
  # Rolling back past this would silently drop the command output.
334
336
  @turn_flow.note_conversation_changed
@@ -484,9 +486,9 @@ module Samagotchi
484
486
  def model_command(input)
485
487
  suffix = input.delete_prefix(MODEL_COMMAND).strip
486
488
  if suffix.empty?
487
- return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false] if model_name == @engine.model_ref_for(@default_model)
489
+ return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{steer_cut_after_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false] if @default_model.nil? || model_name == @engine.model_ref_for(@default_model)
488
490
 
489
- return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}#{sampling_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false]
491
+ return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}#{sampling_note}#{steer_cut_after_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false]
490
492
  end
491
493
 
492
494
  # Parse flags: --default and --alias <name> / --alias=<name> (tolerant order)
@@ -554,6 +556,7 @@ module Samagotchi
554
556
 
555
557
  if ConfigFile::RESERVED_MODEL_ALIASES.include?(arg.downcase)
556
558
  return ["--default cannot be combined with clear/default/none/off", false] if persist_default
559
+ raise ModelProfile::MissingModel, "no default model is configured (set default.model in config.yml, or /model <name> --default)" if @default_model.nil?
557
560
 
558
561
  switch_model(@default_model)
559
562
  return ["runtime model reset to #{model_name}#{model_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", true]
@@ -605,6 +608,16 @@ module Samagotchi
605
608
  ""
606
609
  end
607
610
 
611
+ # "; steer cut: after 60 s (models: qwen)", or "; steer cut: never
612
+ # (hosts.splash)" for 0 or less, when the model has one configured;
613
+ # "" for the default.
614
+ def steer_cut_after_note
615
+ summary = @engine.steer_cut_summary
616
+ summary ? "; steer cut: #{summary}" : ""
617
+ rescue StandardError
618
+ ""
619
+ end
620
+
608
621
  # "; notes: model_notes_deepseek (system, 612 chars)": the model notes
609
622
  # the session's prompt carries (after a switch, the ones the new
610
623
  # model's prompt loads); "" without any.
@@ -30,19 +30,23 @@ module Samagotchi
30
30
  end
31
31
 
32
32
  # +clock+: monotonic seconds; +controller+: the running turn's cancel
33
- # controller (nil between turns), read only when a cut is due.
34
- def initialize(clock:, controller:)
33
+ # controller (nil between turns), read only when a cut is due;
34
+ # +cut_after+: seconds a thinking-only generation may stream before a
35
+ # cut, called on every check (the Engine passes the model's own
36
+ # SteerCutAfter; the global steer.cut_after here).
37
+ def initialize(clock:, controller:, cut_after: -> { self.class.cut_after })
35
38
  # What the running generation streams: whether a steer may cut it.
36
39
  @phase = GenerationPhase.new(clock: clock)
37
40
  # A message that may cut it but came too early: it cuts later.
38
41
  @waiting = WaitingSteer.new
42
+ @cut_after = cut_after
39
43
  @controller = controller
40
44
  end
41
45
 
42
46
  # A message for the running turn from +source+ (Steer.source_for_client:
43
47
  # nil for the user, "chi_send", "parent_agent") cuts the streaming
44
- # generation when it has streamed only thinking for steer.cut_after
45
- # seconds (GenerationPhase): the loops' cut path (CutPolicy) then starts
48
+ # generation when it has streamed only thinking for cut_after seconds
49
+ # (GenerationPhase): the loops' cut path (CutPolicy) then starts
46
50
  # the step again with the message, no nudge, no retry spent. Too early,
47
51
  # the message waits (WaitingSteer) and cuts once the thinking passes it
48
52
  # (#recheck, on each thinking chunk), unless a boundary hands it to the
@@ -52,13 +56,13 @@ module Samagotchi
52
56
  # before queueing it. Outside any lock; a cut that lands just after the
53
57
  # generation ended is harmless (the loop re-asks).
54
58
  # @return [Symbol] :now (a generation was cut now), :waits (the message
55
- # waits for the thinking to pass steer.cut_after), or :off (cutting
59
+ # waits for the thinking to pass cut_after), or :off (cutting
56
60
  # off for this source: a plugin, no turn, cut_after 0, a generation
57
61
  # that isn't thinking-only, or a drain already took the message)
58
62
  def cut_for_steer(source, epoch: nil)
59
63
  return :off unless Steer.cuts?(source)
60
64
 
61
- after = self.class.cut_after
65
+ after = @cut_after.call.to_i
62
66
  return :off unless after.positive?
63
67
 
64
68
  ctrl = @controller.call
@@ -107,11 +111,11 @@ module Samagotchi
107
111
  private
108
112
 
109
113
  # A thinking chunk streamed: a waiting message cuts once the thinking
110
- # passes steer.cut_after.
114
+ # passes cut_after.
111
115
  def recheck
112
116
  return unless @waiting.waiting?
113
117
 
114
- after = self.class.cut_after
118
+ after = @cut_after.call.to_i
115
119
  return unless after.positive? && @phase.cuttable?(after)
116
120
 
117
121
  source = @waiting.take
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+
5
+ module Samagotchi
6
+ # How long a message sent as a cut waits for the generation it came during:
7
+ # the seconds a generation must have streamed only thinking before a cut may
8
+ # land (SteerCut), one per model, host or run, like Thinking for the level.
9
+ #
10
+ # Order (#resolve): the env (SAMAGOTCHI_STEER_CUT_AFTER), then
11
+ # models.<key>.steer_cut_after, hosts.<name>.steer_cut_after,
12
+ # steer.cut_after in config.yml, then 20. 0 = never cut (a slow local
13
+ # thinker is left alone); it is a value, not an unset.
14
+ module SteerCutAfter
15
+ KEY = "steer_cut_after"
16
+ ENV_KEY = "SAMAGOTCHI_STEER_CUT_AFTER"
17
+ DEFAULT = 20
18
+
19
+ module_function
20
+
21
+ # A `steer_cut_after:` setting (hosts: entry or models: entry): whole
22
+ # seconds ≥ 0 (0 = never), nil when unset; anything else warns once,
23
+ # naming +where+, and counts as unset.
24
+ # @return [Integer, nil]
25
+ def seconds(value, where)
26
+ return nil if value.nil?
27
+
28
+ text = value.to_s.strip
29
+ return text.to_i if text.match?(/\A\d+\z/)
30
+
31
+ ConfigFile.warn_once "Warning: #{where}: steer_cut_after must be whole seconds (0 = never); ignored"
32
+ nil
33
+ end
34
+
35
+ # The seconds for +target+ and where they came from
36
+ # ("SAMAGOTCHI_STEER_CUT_AFTER", "models: qwen", "hosts.splash",
37
+ # "steer.cut_after"; nil for the default).
38
+ # @param target [HostRegistry::ModelTarget]
39
+ # @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
40
+ # @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
41
+ # @return [Array(Integer, String|nil)]
42
+ def resolve(target, names:, models: nil)
43
+ value, origin = Config.get_with_origin("steer.cut_after")
44
+ return [value.to_i, ENV_KEY] if origin == :env
45
+
46
+ models ||= begin
47
+ ConfigFile.model_settings
48
+ rescue StandardError
49
+ {}
50
+ end
51
+ setting = ConfigFile.model_setting(names, :steer_cut_after, models: models)
52
+ return [setting.value, "models: #{setting.key}"] if setting
53
+
54
+ host = target.entry.steer_cut_after
55
+ return [host, "hosts.#{target.entry.name}"] unless host.nil?
56
+
57
+ return [value.to_i, "steer.cut_after"] if origin == :file
58
+
59
+ [DEFAULT, nil]
60
+ end
61
+
62
+ # "after 60 s (models: qwen)"; 0 or less is "never (hosts.splash)".
63
+ # No source: just the seconds.
64
+ def label(seconds, source)
65
+ text = seconds.to_i <= 0 ? "never" : "after #{seconds} s"
66
+ source ? "#{text} (#{source})" : text
67
+ end
68
+ end
69
+ end
@@ -16,7 +16,7 @@ module Samagotchi
16
16
  # The system prompt an Engine gives its model: the base prompt (tool
17
17
  # declarations and call syntax for the raw-prompt loop, none for the chat
18
18
  # loop, the shared guidance) and around it the thinking token, rg
19
- # guidance, the identity memory, the model notes, the preloaded memories, AGENT.md, the
19
+ # guidance, the identity memory, the model notes, the preloaded memories, AGENTS.md, the
20
20
  # memory indexes and, last, the model, where the session runs and the
21
21
  # session (#system_prompt_with_index has the order and why).
22
22
  #
@@ -24,7 +24,7 @@ module Samagotchi
24
24
  # tools a plugin replaces, the attached session, the thinking level) is
25
25
  # read at build time through the lookups; the memory lists are fixed.
26
26
  class SystemPrompt
27
- AGENT_DESCRIPTION_FILE = "AGENT.md"
27
+ AGENT_DESCRIPTION_FILE = "AGENTS.md"
28
28
 
29
29
  # The model a session runs on, as the prompt names it: the resolved
30
30
  # ref, its host ("name, host:port" or url), the memory overlay key, and
@@ -282,7 +282,7 @@ module Samagotchi
282
282
  # Ordered for the servers' prompt caches (they reuse only an exact token
283
283
  # prefix): what every session shares first, what changes least before
284
284
  # what changes more, and the per-session lines last. The global parts
285
- # (identity, preloads) come before the project's AGENT.md, the memory
285
+ # (identity, preloads) come before the project's AGENTS.md, the memory
286
286
  # indexes (a memory write changes them) after both; the model, the
287
287
  # location and the session close the prompt (Gemma's tool declarations
288
288
  # still follow them).
@@ -464,7 +464,7 @@ module Samagotchi
464
464
 
465
465
  # ── Project / rg helpers ───────────────────────────────────────────────────
466
466
 
467
- # AGENT.md in the cwd, else at the top of its git work tree.
467
+ # AGENTS.md in the cwd, else at the top of its git work tree.
468
468
  def project_specific_description
469
469
  return nil if skip_agent_description?
470
470
 
@@ -566,7 +566,7 @@ module Samagotchi
566
566
  end
567
567
 
568
568
  def skip_agent_description?
569
- Config.get("skip_agent_md") == true
569
+ Config.get("skip_agents_md") == true
570
570
  end
571
571
 
572
572
  def rg_available?
@@ -712,7 +712,7 @@ module Samagotchi
712
712
 
713
713
  return NEXT_STEP_NOTE unless delivery == Delivery::CUT
714
714
 
715
- steer_cut_note(ack["cut"].to_s.to_sym)
715
+ steer_cut_note(ack["cut"].to_s.to_sym, ack["cut_after"])
716
716
  end
717
717
 
718
718
  def send_prompt(text, delivery: nil)
@@ -1456,9 +1456,7 @@ module Samagotchi
1456
1456
 
1457
1457
  # The config's default model, as the worker's /model names it.
1458
1458
  def default_model_name
1459
- @default_model_name ||= ModelProfile.required_model_name(nil)
1460
- rescue StandardError
1461
- nil
1459
+ @default_model_name ||= ModelProfile.default_model_name
1462
1460
  end
1463
1461
 
1464
1462
  def reminder_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::SYSTEM_PREFIX)
@@ -99,8 +99,9 @@ module Samagotchi
99
99
  # What a line submitted at the open prompt says became of it: a plain
100
100
  # line goes in at the running turn's next step boundary (nothing cuts
101
101
  # unless the message asked for it), /cut's cutting now or once the
102
- # thinking passes steer.cut_after, and /queue's waiting for the end.
103
- # Both TUIs and chi send --cut print the same words
102
+ # thinking passes the model's cut wait (the run's cut_after; the global
103
+ # steer.cut_after when the worker didn't say), and /queue's waiting for
104
+ # the end. Both TUIs and chi send --cut print the same words
104
105
  # (SteerCut#cut_for_steer's answer).
105
106
  NEXT_STEP_NOTE = "(goes in at the next step)"
106
107
  CUT_NOW_NOTE = "(cut in now)"
@@ -116,17 +117,20 @@ module Samagotchi
116
117
  STEER_CHOICE = /\A(#{CUT_COMMAND}|#{QUEUE_COMMAND})(?:\s+(.*))?\z/m
117
118
 
118
119
  # @param outcome [Symbol] SteerCut#cut_for_steer's answer: :now, :waits or :off
120
+ # @param after [Integer, nil] the run's cut wait (the model's own
121
+ # SteerCutAfter): the seconds a :waits waits for; the global
122
+ # steer.cut_after when not given.
119
123
  # @return [String] the dim line to show for a /cut
120
- def self.steer_cut_note(outcome)
124
+ def self.steer_cut_note(outcome, after = nil)
121
125
  case outcome
122
126
  when :now then CUT_NOW_NOTE
123
- when :waits then format(CUT_WAITS_NOTE, SteerCut.cut_after)
127
+ when :waits then format(CUT_WAITS_NOTE, after || SteerCut.cut_after)
124
128
  else CUT_OFF_NOTE
125
129
  end
126
130
  end
127
131
 
128
132
  # The same words, for a UI that includes the module.
129
- def steer_cut_note(outcome) = Formatting.steer_cut_note(outcome)
133
+ def steer_cut_note(outcome, after = nil) = Formatting.steer_cut_note(outcome, after)
130
134
 
131
135
  # The choice a mid-turn line carries, as [:cut, "TEXT"] or
132
136
  # [:queue, "TEXT"]; nil for any other line. The text is "" when the
@@ -98,7 +98,7 @@ module Samagotchi
98
98
  @thinking_word = thinking
99
99
  @prompt = prompt
100
100
  @llm_context_words = llm_context
101
- @default_model_name = ModelProfile.required_model_name(nil)
101
+ @default_model_name = ModelProfile.default_model_name
102
102
  # As typed: the Engine applies an alias where it resolves the model.
103
103
  flag_model_name = model_name.to_s.strip.empty? ? nil : ModelProfile.required_model_name(model_name)
104
104
  @effective_model_name = flag_model_name || @default_model_name
@@ -127,6 +127,12 @@ module Samagotchi
127
127
  # Re-resolve client after resume may change effective model
128
128
  @client = @host_registry.resolve(@effective_model_name).client
129
129
  end
130
+ # No --model, no session model and no default: nothing to run.
131
+ raise ModelProfile::MissingModel, ModelProfile.missing_model_message if @effective_model_name.to_s.strip.empty?
132
+
133
+ # With no config default the Engine starts on the model it runs.
134
+ no_default = @default_model_name.nil?
135
+
130
136
  # The model it starts on names a configured host (a bad default.model
131
137
  # doesn't matter when --model or the session picks another).
132
138
  ModelProfile.check_host!(@effective_model_name, hosts: @host_registry.entries)
@@ -166,8 +172,10 @@ module Samagotchi
166
172
  session_id: session_id,
167
173
  no_interrupt: no_interrupt,
168
174
  # nil: the config default, unchecked here (the model the REPL starts
169
- # on is checked above, and --model may make the default moot).
170
- model_name: nil,
175
+ # on is checked above, and --model may make the default moot). With
176
+ # no default, the model it runs (the switch below is then skipped).
177
+ model_name: no_default ? @effective_model_name : nil,
178
+ model_typed: no_default ? @resumed_model_typed : nil,
171
179
  memories: @requested_memories,
172
180
  muted_memories: @muted_memory_names,
173
181
  recap: scratch ? nil : recap_config,
@@ -199,8 +207,9 @@ module Samagotchi
199
207
  @commands = SessionCommands.new(engine: @engine, turn_flow: @turn_flow, default_model: @default_model_name,
200
208
  registry: @engine.command_registry)
201
209
  # Runtime --model flag or resumed session: switch the Engine (client,
202
- # kernel profile) without persisting the default.
203
- @engine.switch_model!(@effective_model_name, typed: @resumed_model_typed) if @effective_model_name != @default_model_name
210
+ # kernel profile) without persisting the default. Skipped when there's
211
+ # no config default (the Engine was built on the session model).
212
+ @engine.switch_model!(@effective_model_name, typed: @resumed_model_typed) if !no_default && @effective_model_name != @default_model_name
204
213
  # Render an idle session-recap via the cursor-safe background writer; the
205
214
  # detector itself is Engine-owned (see Engine#recap) and opt-in.
206
215
  @recap_handle = @engine.subscribe(observer: ->(event) { @between_turns.take_recap(event) })
@@ -1190,7 +1199,7 @@ module Samagotchi
1190
1199
  @pending_input_queue.push(text)
1191
1200
  # With no session running the cut is :off; the text still waits for
1192
1201
  # the running turn's next step.
1193
- @surface.commit(steer_cut_note(@engine.cut_for_steer(nil, epoch: epoch)))
1202
+ @surface.commit(steer_cut_note(@engine.cut_for_steer(nil, epoch: epoch), @engine.steer_cut_after))
1194
1203
  true
1195
1204
  end
1196
1205
 
@@ -46,7 +46,11 @@ module Samagotchi
46
46
  status: tool_activity_status(result, tool_name, command: call.is_a?(Hash) ? call[:content] : nil)
47
47
  }
48
48
  # A grep that found nothing: ok, and the web row says "no match".
49
- event[:no_match] = true if event[:status] == "ok" && tool_name == Tools::Execute::NAME && execute_failed?(result.to_s)
49
+ # Not a hand-over: an "exit: 1" in its output tail is the command's.
50
+ if event[:status] == "ok" && tool_name == Tools::Execute::NAME && !handed_over?(tool_name, result) &&
51
+ execute_failed?(result.to_s)
52
+ event[:no_match] = true
53
+ end
50
54
  title = tool_title(tool_name, call, cwd: cwd)
51
55
  event[:title] = title if title
52
56
  # The name the model used when an alias ran a built-in (called_as: "bash"
@@ -214,6 +218,8 @@ module Samagotchi
214
218
  return "stopped" if text == Tools::Execute::NOT_RUN_ON_STOP
215
219
  return "stopped" if tool_name == Tools::Execute::NAME && text.start_with?(Tools::Execute::STOPPED_BY_USER)
216
220
  return "error" if text.start_with?("Error:")
221
+ # Still running, as a task: its output tail may hold any "exit: N".
222
+ return "ok" if handed_over?(tool_name, text)
217
223
  if tool_name == Tools::Execute::NAME && execute_failed?(text)
218
224
  return no_match?(text, command) ? "ok" : "error"
219
225
  end
@@ -222,6 +228,17 @@ module Samagotchi
222
228
  "ok"
223
229
  end
224
230
 
231
+ # An execute still running at its limit, handed over as a task
232
+ # (Tools::Execute::HANDED_OVER).
233
+ def handed_over?(tool_name, text)
234
+ return false unless tool_name == Tools::Execute::NAME && text.to_s.start_with?(Tools::Execute::HANDED_OVER)
235
+
236
+ # The first line only, scrubbed: a regexp raises on bytes that aren't UTF-8.
237
+ text.to_s.each_line.first.to_s.scrub.match?(HANDED_OVER_START)
238
+ end
239
+
240
+ HANDED_OVER_START = /\A#{Regexp.escape(Tools::Execute::HANDED_OVER)} \d+s limit:/
241
+
225
242
  def execute_failed?(text)
226
243
  code = execute_exit_code(text)
227
244
  !code.nil? && code != "0"
@@ -20,7 +20,7 @@ module Samagotchi
20
20
  TOOL_SCHEMAS = [
21
21
  {
22
22
  name: "execute",
23
- 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.",
23
+ 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. A command still running at the time limit keeps running as a background task (by default): the result gives its task_id, and task_wait waits for it. Don't background with &; use task_create.",
24
24
  parameters: {
25
25
  type: "object",
26
26
  properties: {