samagotchi 0.121.1 → 0.122.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/bin/chi +4 -1
  4. data/docs/architecture.md +2 -1
  5. data/docs/cli.md +1 -0
  6. data/docs/configuration.md +29 -4
  7. data/docs/guardrails.md +15 -0
  8. data/docs/internals/engine.md +2 -0
  9. data/docs/internals/participants.md +13 -7
  10. data/docs/memory.md +33 -0
  11. data/docs/plugins.md +3 -2
  12. data/docs/sessions.md +8 -2
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  14. data/lib/samagotchi/bridge.rb +4 -3
  15. data/lib/samagotchi/bridge_client/event_stream.rb +5 -1
  16. data/lib/samagotchi/bridge_client/stream_open.rb +8 -2
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
  19. data/lib/samagotchi/config.rb +5 -0
  20. data/lib/samagotchi/engine.rb +107 -1
  21. data/lib/samagotchi/event_fanout.rb +0 -1
  22. data/lib/samagotchi/guardrail_wiring.rb +5 -4
  23. data/lib/samagotchi/log_line.rb +1 -1
  24. data/lib/samagotchi/memory_orphans.rb +11 -0
  25. data/lib/samagotchi/participant_mode.rb +11 -0
  26. data/lib/samagotchi/plugin/api.rb +7 -4
  27. data/lib/samagotchi/process_group.rb +13 -0
  28. data/lib/samagotchi/review_start.rb +6 -2
  29. data/lib/samagotchi/run_options.rb +1 -0
  30. data/lib/samagotchi/session.rb +14 -1
  31. data/lib/samagotchi/session_access/contract.rb +1 -1
  32. data/lib/samagotchi/session_access/remote.rb +32 -12
  33. data/lib/samagotchi/session_access/web_bridge_client.rb +9 -2
  34. data/lib/samagotchi/session_manager.rb +21 -7
  35. data/lib/samagotchi/session_setup.rb +8 -4
  36. data/lib/samagotchi/sessions_command.rb +1 -1
  37. data/lib/samagotchi/steer.rb +7 -6
  38. data/lib/samagotchi/system_one/client.rb +6 -5
  39. data/lib/samagotchi/system_one/log.rb +25 -5
  40. data/lib/samagotchi/terminal_ui.rb +2 -1
  41. data/lib/samagotchi/tool_runner.rb +2 -1
  42. data/lib/samagotchi/toolkit.rb +27 -8
  43. data/lib/samagotchi/toolkit_binding.rb +115 -7
  44. data/lib/samagotchi/toolkit_command.rb +15 -3
  45. data/lib/samagotchi/toolkit_report_command.rb +318 -0
  46. data/lib/samagotchi/toolkit_suggest.rb +211 -0
  47. data/lib/samagotchi/tools/memory.rb +1 -1
  48. data/lib/samagotchi/version.rb +1 -1
  49. data/lib/samagotchi/web/app.rb +5 -5
  50. data/lib/samagotchi/web/participant_access.rb +4 -1
  51. data/lib/samagotchi/web/participant_review.rb +6 -4
  52. data/lib/samagotchi/web/principal.rb +3 -4
  53. data/lib/samagotchi/web/public/bubbles.js +3 -2
  54. data/lib/samagotchi/web/public/participant.js +2 -1
  55. data/lib/samagotchi/web/public/turn_events.js +2 -1
  56. data/lib/samagotchi/web/public/turn_html.js +1 -1
  57. data/lib/samagotchi/web/server.rb +2 -2
  58. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  59. data/lib/samagotchi/web/session_control.rb +4 -2
  60. data/lib/samagotchi/web/start_dir.rb +10 -5
  61. data/lib/samagotchi/worker.rb +16 -2
  62. data/lib/samagotchi/worker_link.rb +1 -0
  63. metadata +4 -1
@@ -41,6 +41,8 @@ require_relative "llm/openai_chat"
41
41
  require_relative "llm/turn_settings"
42
42
  require_relative "session"
43
43
  require_relative "toolkit_binding"
44
+ require_relative "participant_mode"
45
+ require_relative "toolkit_suggest"
44
46
  require_relative "tool_loads"
45
47
  require_relative "answer_tail"
46
48
  require_relative "archive_store"
@@ -156,6 +158,7 @@ module Samagotchi
156
158
  @no_interrupt = no_interrupt
157
159
  @chat_backend = nil
158
160
  @chat_backend_mutex = Mutex.new
161
+ @toolkit_audit_thread = nil
159
162
  @host_registry = host_registry || HostRegistry.new
160
163
  # A model given here (a worker's session model) is the one it runs,
161
164
  # so its host is checked; the config default is only checked once it
@@ -1082,6 +1085,10 @@ module Samagotchi
1082
1085
 
1083
1086
  # How long #shutdown waits for the running anytime commands, in all.
1084
1087
  SHUTDOWN_JOIN_SECONDS = 3.0
1088
+ # How long #shutdown waits for a running toolkit audit ask (a one-shot
1089
+ # -p --non-interactive exits right after its turn); a slower ask goes
1090
+ # unlogged.
1091
+ TOOLKIT_AUDIT_JOIN_SECONDS = 1.0
1085
1092
 
1086
1093
  # The REPL or the session's worker is leaving (/exit, an idle exit, a
1087
1094
  # crash, TERM): stop the idle jobs, give the running anytime commands
@@ -1107,6 +1114,7 @@ module Samagotchi
1107
1114
 
1108
1115
  thread.join([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max)
1109
1116
  end
1117
+ @toolkit_audit_thread&.join(TOOLKIT_AUDIT_JOIN_SECONDS)
1110
1118
  left = threads.count(&:alive?)
1111
1119
  Log.warn(:plugins, "anytime_commands_left", count: left) if left.positive?
1112
1120
  @plugin_tasks.kill_leftovers
@@ -1516,6 +1524,13 @@ module Samagotchi
1516
1524
  Steer.author_fields(author, @participants.name_for(author, session: session))
1517
1525
  end
1518
1526
 
1527
+ # A participant's opening message keeps the rules it was delivered under
1528
+ # (ParticipantMode), stamped before any request carries it, so its header
1529
+ # never changes when the session's mode does.
1530
+ def participant_mode_fields(author, session: @session)
1531
+ Steer.participant_author?(author) ? { participant_mode: ParticipantMode.of(session).to_s } : {}
1532
+ end
1533
+
1519
1534
  # Why a turn stopped (TurnEndings#stop_facts).
1520
1535
  def stop_facts(event) = @turn_endings.stop_facts(event)
1521
1536
  private :stop_facts
@@ -2128,6 +2143,8 @@ module Samagotchi
2128
2143
  emit_event(turn.on_event, turn.tag(turn_started))
2129
2144
  raise image_error if image_error
2130
2145
 
2146
+ start_toolkit_suggestion_audit(turn)
2147
+
2131
2148
  # The window and the profile probes run after run_turn's probe-cancel
2132
2149
  # swap, so a Stop cuts them; a failure ends the turn as turn_failed.
2133
2150
  # The cache itself was dropped at the previous turn's end, so this
@@ -2289,7 +2306,7 @@ module Samagotchi
2289
2306
 
2290
2307
  unless turn.continue
2291
2308
  user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin.author),
2292
- MessageTime::KEY => turn.at }
2309
+ **participant_mode_fields(turn.origin.author, session: session), MessageTime::KEY => turn.at }
2293
2310
  user_message[:images] = image_refs unless image_refs.empty?
2294
2311
  turn.messages << user_message
2295
2312
  session.last_prompt = turn.prompt
@@ -2712,6 +2729,95 @@ module Samagotchi
2712
2729
  def apply_toolkit!(session, on_event = nil) = @toolkit_binding.apply!(session, on_event)
2713
2730
  private :apply_toolkit!
2714
2731
 
2732
+ # Start an audit-only classifier after :turn_started. All session and
2733
+ # tool-registry reads happen here; the thread receives only this snapshot.
2734
+ def start_toolkit_suggestion_audit(turn)
2735
+ mode = Config.get("toolkit.suggest.mode").to_s
2736
+ return if mode == "off"
2737
+
2738
+ session = turn.session
2739
+ default = Toolkit.of(session.toolkit)
2740
+ path = ToolkitSuggest.log_path(session_state_dir)
2741
+ log = SystemOne::Log.new(dir: File.dirname(path), file: File.basename(path),
2742
+ warn: lambda { |reason|
2743
+ Log.warn(:toolkit, "toolkit_suggest_log_failed",
2744
+ echo: "[samagotchi:toolkit] #{reason}")
2745
+ })
2746
+ # The predicate checks the session first; the kits (memory kits are
2747
+ # read from disk) and the log (read whole) only when it gets there.
2748
+ kits = nil
2749
+ candidates = nil
2750
+ return unless ToolkitSuggest.eligible?(
2751
+ session: session, prompt: turn.prompt, author: turn.origin.author,
2752
+ candidate_count: lambda {
2753
+ kits = @toolkit_binding.kits
2754
+ candidates = ToolkitSuggest.candidates(kits, default_name: default.name)
2755
+ candidates.size
2756
+ },
2757
+ last_suggestion: -> { ToolkitSuggest.last_suggestion(log, session.id) },
2758
+ defer_turns: Config.get("toolkit.suggest.defer_turns"),
2759
+ defer_tokens: Config.get("toolkit.suggest.defer_tokens")
2760
+ )
2761
+
2762
+ # One ask at a time: an eligible turn while the last one still runs
2763
+ # goes unasked, and says so.
2764
+ if @toolkit_audit_thread&.alive?
2765
+ Log.info(:toolkit, "toolkit_suggest_in_flight_skipped", session_id: session.id)
2766
+ return
2767
+ end
2768
+
2769
+ client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit)
2770
+ return unless client
2771
+
2772
+ if toolkit_judge_shares_local_host?(client.host)
2773
+ Log.info(:toolkit, "toolkit_suggest_shared_host_skipped", session_id: session.id, host: client.host)
2774
+ return
2775
+ end
2776
+
2777
+ # Rules, not tool lists: this runs before await_init_tasks and
2778
+ # apply_staged_tools!, so a first turn's plugin/MCP tools aren't
2779
+ # registered yet; the report expands the rules (ToolkitSuggest.rules).
2780
+ snapshots = candidates.map do |kit|
2781
+ ToolkitSuggest::Candidate.new(name: kit.name.dup.freeze, description: kit.description.dup.freeze,
2782
+ rules: ToolkitSuggest.rules(Toolkit.resolve_kit(kit.name, kits)))
2783
+ end.freeze
2784
+ default_snapshot = ToolkitSuggest::Candidate.new(name: default.name.dup.freeze, description: nil,
2785
+ rules: ToolkitSuggest.rules(default.resolve(kits)))
2786
+ core_tools = @tools.entries(toolkit: nil).select(&:core?).map { |entry| entry.name.dup.freeze }.freeze
2787
+ state = ToolkitSuggest.question(
2788
+ default: default, kits: kits, project: File.basename(session.project_root.to_s),
2789
+ prompt: turn.prompt
2790
+ ).last
2791
+ snapshot = ToolkitSuggest::Snapshot.new(
2792
+ session_id: session.id.dup.freeze, turn_number: ToolkitSuggest.turn_number(session.messages),
2793
+ default: default_snapshot, candidates: snapshots, core_tools: core_tools,
2794
+ state: { project: state[:project].dup.freeze, prompt: state[:prompt].dup.freeze }.freeze
2795
+ )
2796
+ threshold = Config.get("toolkit.suggest.threshold")
2797
+ @toolkit_audit_thread = Thread.new do
2798
+ ToolkitSuggest.ask(client: client, log: log, snapshot: snapshot, threshold: threshold)
2799
+ rescue StandardError => e
2800
+ ToolkitSuggest.warn(e)
2801
+ end
2802
+ rescue StandardError => e
2803
+ ToolkitSuggest.warn(e)
2804
+ nil
2805
+ end
2806
+ private :start_toolkit_suggestion_audit
2807
+
2808
+ # D7: System 1's host is the session's model server, and a local one
2809
+ # (a single-slot Splash or llama.cpp): the same name, or two hosts:
2810
+ # entries for one server root (scheme, host, port).
2811
+ def toolkit_judge_shares_local_host?(judge_name)
2812
+ model_host, = @host_registry.host_for_model(effective_model_name)
2813
+ return false if model_host.nil? || model_host.remote?
2814
+ return true if judge_name.to_s.strip.casecmp?(model_host.name.to_s)
2815
+
2816
+ judge = @host_registry.find_entry(judge_name)
2817
+ !judge.nil? && judge.root_url.downcase == model_host.root_url.downcase
2818
+ end
2819
+ private :toolkit_judge_shares_local_host?
2820
+
2715
2821
  # The session's toolkit (see #initialize for when).
2716
2822
  def build_toolkit_binding
2717
2823
  ToolkitBinding.new(
@@ -56,7 +56,6 @@ module Samagotchi
56
56
  @announce_or_hold = announce_or_hold
57
57
  @working_set_emitted = nil
58
58
  end
59
- # rubocop:enable Metrics/ParameterLists
60
59
 
61
60
  # Always return a handler so raw kernel loop events reach the persistent
62
61
  # SessionObserver (and thus the analytics collector) even when there is no
@@ -15,6 +15,7 @@ require_relative "edit_preview"
15
15
  require_relative "system_one"
16
16
  require_relative "tool_activity"
17
17
  require_relative "turn_origin"
18
+ require_relative "participant_mode"
18
19
 
19
20
  module Samagotchi
20
21
  # An Engine's tool guardrails: who can approve (the interface), the YAML
@@ -126,12 +127,12 @@ module Samagotchi
126
127
  # The gate's core checks, in order. A delegate child's boundary comes
127
128
  # before the rules, so its ask keeps the child-boundary rule id (and
128
129
  # its once/session scopes) when a strict rule asks too. The review
129
- # boundary comes before both: a participant's turn is denied what it
130
- # may not do, never asked about it (deny is sticky, so no later vote
131
- # can turn it into a question).
130
+ # boundary comes before both: a participant's review turn is denied
131
+ # what it may not do, never asked about it.
132
132
  def checks
133
133
  rules = self.rules
134
- [@failures, review_boundary, rules.hook_asks, protected_paths,
134
+ boundary = ParticipantMode.of(@session_lookup.call) == :review ? review_boundary : nil
135
+ [@failures, boundary, rules.hook_asks, protected_paths,
135
136
  (@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch), child_boundary, rules].compact
136
137
  end
137
138
 
@@ -16,7 +16,7 @@ module Samagotchi
16
16
  # (debug dumps) are indented by four spaces and belong to the record above.
17
17
  module LogLine
18
18
  LEVELS = %w[DEBUG INFO WARN ERROR].freeze
19
- TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model context broadcast].freeze
19
+ TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model context broadcast toolkit].freeze
20
20
  INDENT = " "
21
21
 
22
22
  Record = Struct.new(:ts, :level, :tag, :pid, :sid, :event, :fields, :payload, keyword_init: true)
@@ -51,6 +51,17 @@ module Samagotchi
51
51
  found && text(found)
52
52
  end
53
53
 
54
+ # A model-facing hint: report the name collision without giving the model
55
+ # a command that could move another live clone's memories.
56
+ def model_message(env: ENV, cwd: Dir.pwd)
57
+ found = find(env: env, cwd: cwd)
58
+ return unless found
59
+
60
+ name = File.basename(found.computed).sub(/_\h{8}\z/, "")
61
+ "a folder with the same project name (#{name}) exists with another path's hash; ask the user whether its " \
62
+ "memories belong to this project before using them."
63
+ end
64
+
54
65
  def text(found)
55
66
  computed = found.computed
56
67
  lead = "no project memories in #{computed}"
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # The rules for a participant's turn. P0 keeps every participant in review
5
+ # mode; this is the single resolver for the later mode split.
6
+ module ParticipantMode
7
+ module_function
8
+
9
+ def of(_session) = :review
10
+ end
11
+ end
@@ -312,17 +312,20 @@ module Samagotchi
312
312
  # a bundle has) with +add+ declared and +remove+ left out, and
313
313
  # +prompt+: text the sessions on it get in their system prompt (nil:
314
314
  # its base's, "" none; at most Toolkit::PROMPT_MAX characters);
315
- # +groups+: MCP source group names/globs (nil inherits its base).
315
+ # +groups+: MCP source group names/globs (nil inherits its base);
316
+ # +description+: optional classifier text, not included in prompts
317
+ # (at most Toolkit::DESCRIPTION_MAX characters).
316
318
  # A
317
319
  # name chi (full, lean, child) or another bundle has is a load error,
318
320
  # and so is a prompt that isn't text or is too long; a base that
319
321
  # doesn't resolve leaves the sessions on it with every tool
320
322
  # (Toolkit#resolve).
321
- def toolkit(name, base:, add: [], remove: [], prompt: nil, groups: nil)
323
+ def toolkit(name, base:, add: [], remove: [], prompt: nil, groups: nil, description: nil)
322
324
  name = name.to_s
323
325
  raise ArgumentError, "toolkit name #{name.inspect} must be a-z, 0-9, _ and -" unless name.match?(TOOLKIT_NAME)
324
326
 
325
- problem = Toolkit.prompt_error(prompt) || (Toolkit.groups_error(groups) unless groups == :all)
327
+ problem = Toolkit.prompt_error(prompt) || Toolkit.description_error(description) ||
328
+ (Toolkit.groups_error(groups) unless groups == :all)
326
329
  raise ArgumentError, "toolkit #{name}: #{problem}" if problem
327
330
  raise ArgumentError, "this chi has no toolkits (plugins: false)" unless @registries.toolkits
328
331
  if Toolkit::BUILTIN_KITS.key?(name)
@@ -333,7 +336,7 @@ module Samagotchi
333
336
  raise ArgumentError, "toolkit #{name} is declared twice" if @toolkits.any? { |kit| kit.name == name }
334
337
 
335
338
  @toolkits << Toolkit::Kit.new(name: name, base: base, add: add, remove: remove, prompt: prompt,
336
- groups: groups, source: @bundle)
339
+ groups: groups, source: @bundle, description: description)
337
340
  nil
338
341
  end
339
342
 
@@ -87,6 +87,19 @@ module Samagotchi
87
87
  nil
88
88
  end
89
89
 
90
+ # Whether +pid+ is stopped (SIGSTOP, Ctrl-Z; t: under a tracer): ps's
91
+ # state starts with T or t on macOS and Linux alike. Such a process
92
+ # takes no signal but KILL and answers no socket until it is continued.
93
+ # false when it isn't running or ps can't say.
94
+ def stopped?(pid)
95
+ return false unless signalable?(pid)
96
+
97
+ out, status = Open3.capture2("ps", "-o", "state=", "-p", pid.to_s, err: File::NULL)
98
+ status.success? && out.lstrip.start_with?("T", "t")
99
+ rescue SystemCallError, IOError
100
+ false
101
+ end
102
+
90
103
  # Field 22 (starttime); the command name (field 2) may hold spaces and
91
104
  # parentheses, so the fields are counted after its last ")".
92
105
  def proc_start_time(stat) = stat[/\A.*\)\s(.*)\z/m, 1]&.split&.[](19)
@@ -7,6 +7,7 @@ require_relative "session_chain"
7
7
  require_relative "recap_store"
8
8
  require_relative "guardrails/context"
9
9
  require_relative "guardrails/review_paths"
10
+ require_relative "participant_mode"
10
11
 
11
12
  module Samagotchi
12
13
  autoload :SessionManager, File.expand_path("session_manager", __dir__)
@@ -75,6 +76,7 @@ module Samagotchi
75
76
  subjects = check_subjects(subjects, dir: dir)
76
77
  raise Refused, NO_SUBJECTS if subjects.empty?
77
78
 
79
+ setup = setup.with(toolkit_origin: "chosen")
78
80
  SessionManager.spawn_session(prompt: nil, preview: preview(subjects), working_directory: dir, model_name: model,
79
81
  setup: setup, review: SessionReview.new(subjects: subjects),
80
82
  note: note(subjects: subjects), note_source: NOTE_SOURCE, state_dir: state_dir)
@@ -103,7 +105,8 @@ module Samagotchi
103
105
  recap = RecapStore.read(Session.session_dir(source.id, state_dir: sd))
104
106
  session = SessionManager.spawn_session(
105
107
  prompt: nil, preview: preview(subjects, source: source), working_directory: dir, model_name: choice.model,
106
- setup: SessionSetup.of(source), review: SessionReview.new(subjects: subjects, from: source.id),
108
+ setup: SessionSetup.of(source).with(toolkit_origin: "chosen"),
109
+ review: SessionReview.new(subjects: subjects, from: source.id),
107
110
  note: note(subjects: subjects, source: source, recap: recap, seed: seed), note_source: NOTE_SOURCE, state_dir: sd
108
111
  )
109
112
  warnings = [choice.warning, session.model_warning].compact
@@ -145,10 +148,11 @@ module Samagotchi
145
148
  # @param session [Session]
146
149
  # @return [String, nil]
147
150
  def share_warning(session)
151
+ mode = ParticipantMode.of(session)
148
152
  return nil if session.review?
149
153
 
150
154
  count = session.tool_output_count
151
- "#{session.id[0, 8]} is a working session, not a review session: a participant's review turn can quote what " \
155
+ "#{session.id[0, 8]} is a working session, not a review session: a participant's #{mode} turn can quote what " \
152
156
  "its history holds (#{count} tool output#{"s" unless count == 1} so far). A review session starts from its recap instead."
153
157
  end
154
158
 
@@ -166,6 +166,7 @@ module Samagotchi
166
166
  chi memory <ls|rm> [--scope project|system] the memories; rm moves one to the bundle trash
167
167
  chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check|try>
168
168
  chi guardrails replay [--dry-run] [--threshold T] [--model M] seed System 1's record from answered approvals
169
+ chi toolkit report [--days N] grade toolkit suggestions against the tools sessions used
169
170
  chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
170
171
  chi models [--format json] [TEXT] the models --model takes, from every host
171
172
  chi remote <add|ls|rm> other machines' chi web, for chi --remote NAME
@@ -32,6 +32,7 @@ module Samagotchi
32
32
  STATUS_RUNNING = "running"
33
33
  STATUS_ERROR = "error"
34
34
  STATUS_STOPPED = "stopped"
35
+ TOOLKIT_ORIGINS = %w[chosen default inherited suggested].freeze
35
36
 
36
37
  # A stopped session's marker, <session dir>/stopped: a stop (chi
37
38
  # sessions stop, from another process) creates it instead of rewriting
@@ -222,6 +223,15 @@ module Samagotchi
222
223
  @toolkit = value.is_a?(Toolkit) ? value : Toolkit.from_file(value)
223
224
  end
224
225
 
226
+ # Why this session is on its toolkit. Files from before the field read
227
+ # as chosen, so an old default session is never newly suggested.
228
+ attr_reader :toolkit_origin
229
+
230
+ def toolkit_origin=(value)
231
+ origin = value.to_s
232
+ @toolkit_origin = TOOLKIT_ORIGINS.include?(origin) ? origin : "chosen"
233
+ end
234
+
225
235
  # The session's own settings (chi --set), a SessionConfig::Settings:
226
236
  # empty for none and for a session from before them. Its core part
227
237
  # ({key => value}: recap.enabled) is the file's config_settings, its
@@ -322,7 +332,7 @@ module Samagotchi
322
332
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
323
333
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
324
334
  continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
325
- toolkit: nil, settings: nil, interrupted_turn: nil, parent_answers: nil, title: nil,
335
+ toolkit: nil, toolkit_origin: "chosen", settings: nil, interrupted_turn: nil, parent_answers: nil, title: nil,
326
336
  working_set_pins: [], review: nil)
327
337
  @id = id
328
338
  @metadata_version = metadata_version
@@ -355,6 +365,7 @@ module Samagotchi
355
365
  self.reasoning_history = reasoning_history
356
366
  self.appended_prompt_files = appended_prompt_files
357
367
  self.toolkit = toolkit
368
+ self.toolkit_origin = toolkit_origin
358
369
  self.settings = settings
359
370
  self.parent_answers = parent_answers
360
371
  self.working_set_pins = working_set_pins
@@ -464,6 +475,7 @@ module Samagotchi
464
475
  reasoning_history: setup.reasoning_history,
465
476
  appended_prompt_files: setup.appended_prompt_files,
466
477
  toolkit: setup.toolkit,
478
+ toolkit_origin: setup.toolkit_origin || "chosen",
467
479
  settings: setup.settings,
468
480
  parent_answers: setup.parent_answers,
469
481
  working_set_pins: setup.working_set_pins,
@@ -534,6 +546,7 @@ module Samagotchi
534
546
  "reasoning_history" => nil,
535
547
  "appended_prompt_files" => [],
536
548
  "toolkit" => nil,
549
+ "toolkit_origin" => "chosen",
537
550
  "bundle_settings" => {},
538
551
  "config_settings" => {},
539
552
  "interrupted_turn" => nil,
@@ -26,7 +26,7 @@ module Samagotchi
26
26
  # bin/chi subcommands that act on this machine: never sessions. chi
27
27
  # remote (this machine's list of remotes) runs here under a remote too
28
28
  # (Dispatch); the others are refused there.
29
- MACHINE = %w[bootstrap update models desktop memory bundle guardrails remote].freeze
29
+ MACHINE = %w[bootstrap update models desktop memory bundle guardrails toolkit remote].freeze
30
30
 
31
31
  # "command", "command sub" or either with a flag (its long name) →
32
32
  # SUPPORTED or LOCAL_ONLY. A LOCAL_ONLY command or subcommand lists
@@ -193,7 +193,7 @@ module Samagotchi
193
193
  return true if owner(id)&.worker?
194
194
  return false if monotonic >= deadline
195
195
 
196
- sleep(WORKER_POLL)
196
+ @sleeper.call(WORKER_POLL)
197
197
  end
198
198
  end
199
199
 
@@ -310,7 +310,10 @@ module Samagotchi
310
310
  reply = request(:post, "#{session_path(id)}/notes", body: { text: text, source: source })
311
311
  json = reply.json.is_a?(Hash) ? reply.json : {}
312
312
  case reply.status
313
- when 200 then NoteDelivery::Result.new(id: id, status: json["status"].to_sym, queued: json["queued"].to_i)
313
+ when 200
314
+ raise_refusal(reply) unless json["status"].is_a?(String)
315
+
316
+ NoteDelivery::Result.new(id: id, status: json["status"].to_sym, queued: json["queued"].to_i)
314
317
  when 409
315
318
  return NoteDelivery::Result.new(id: id, status: :refused, queued: 0) if json["error"] == "owned_by_tui"
316
319
 
@@ -470,14 +473,13 @@ module Samagotchi
470
473
  def context_add_url(target, url, why:, hint:, added_by:)
471
474
  reply = request(:post, "#{session_path(target.session_id)}/context",
472
475
  body: { url: url, why: why, hint: hint, added_by: added_by }.compact)
473
- if reply.status == 422
476
+ if reply.status == 422 && no_provider?(reply)
474
477
  require_relative "../context_providers"
475
- detail = if reply.error == "no_provider" && reply.json["detail"].to_s.start_with?("no installed bundle resolves")
476
- "no installed bundle on #{name} resolves #{url} (chi bundle install github-pr there for GitHub PRs)"
477
- else
478
- "#{reply.json["detail"]} (on #{name})"
479
- end
478
+ detail = "no installed bundle on #{name} resolves #{url} (chi bundle install github-pr there for GitHub PRs)"
480
479
  raise ContextProviders::Invalid, detail
480
+ elsif reply.status == 422
481
+ require_relative "../context_providers"
482
+ raise ContextProviders::Invalid, "#{reply.json["detail"]} (on #{name})"
481
483
  end
482
484
  context_added(reply)
483
485
  reply.json.fetch("name")
@@ -492,7 +494,7 @@ module Samagotchi
492
494
  case reply.status
493
495
  when 200 then ContextPush.new(status: :pushed, before: reply.json["before"], revision: reply.json["revision"])
494
496
  when 404
495
- return ContextPush.new(status: :missing) if reply.error == "not_found"
497
+ return ContextPush.new(status: :missing) if context_missing?(reply)
496
498
 
497
499
  raise_refusal(reply)
498
500
  when 409 then ContextPush.new(status: :not_push)
@@ -513,7 +515,7 @@ module Samagotchi
513
515
  def context_entry(target, name)
514
516
  ContextSources.check_name!(name)
515
517
  reply = request(:get, "#{session_path(target.session_id)}/context/#{name}", query: { full: 1 })
516
- return nil if reply.status == 404 && reply.error == "not_found" && reply.json["detail"].to_s.start_with?("no source")
518
+ return nil if context_missing?(reply)
517
519
 
518
520
  raise_refusal(reply) unless reply.status == 200
519
521
 
@@ -527,7 +529,7 @@ module Samagotchi
527
529
  case reply.status
528
530
  when 200 then :removed
529
531
  when 409 then reply.error == "project_source" ? :project : raise_refusal(reply)
530
- when 404 then reply.json["detail"].to_s.start_with?("no source") ? :missing : raise_refusal(reply)
532
+ when 404 then context_missing?(reply) ? :missing : raise_refusal(reply)
531
533
  else raise_refusal(reply)
532
534
  end
533
535
  end
@@ -655,6 +657,21 @@ module Samagotchi
655
657
  reply.status == 404 && reply.error == "not_found" && reply.json["detail"].to_s.start_with?("not found: /")
656
658
  end
657
659
 
660
+ def context_missing?(reply)
661
+ return false unless reply.status == 404
662
+ return true if reply.error == "context_missing"
663
+
664
+ # Older chi web used not_found for a missing context source.
665
+ reply.error == "not_found" && reply.json["detail"].to_s.start_with?("no source")
666
+ end
667
+
668
+ def no_provider?(reply)
669
+ return true if reply.error == "no_provider"
670
+
671
+ # Older chi web didn't give this refusal its own error code.
672
+ reply.json["detail"].to_s.start_with?("no installed bundle resolves")
673
+ end
674
+
658
675
  # Raises what Local's callee raises for this refusal (chi web sends
659
676
  # the fields each class is built from; see docs/architecture.md, chi
660
677
  # web's routes for the session commands), else RemoteFailed.
@@ -674,7 +691,10 @@ module Samagotchi
674
691
  when "continued", "open_children", "folder_gone"
675
692
  raise SessionContinue::Refused.new(sid, reason, ids: Array(json["ids"]))
676
693
  when "held", "not_running", "unsupported", "failed"
677
- raise SessionManager::RestartRefused.new(sid, reason, version: json["version"]) if reason
694
+ if reason
695
+ raise SessionManager::RestartRefused.new(sid, reason, version: json["version"], holds: json["holds"],
696
+ pid: json["pid"], via: invocation)
697
+ end
678
698
  when "parent_answers_refused", "parent_continue_refused", "no_vision"
679
699
  raise Refused.new(message.to_s, code: error)
680
700
  # No model configured there, or --model's host unknown there: what
@@ -67,7 +67,14 @@ module Samagotchi
67
67
  end
68
68
  end
69
69
 
70
- def stream(sock, status, headers) = BridgeClient::StreamOpen.classify(sock, status, headers)
70
+ def stream(sock, status, headers, session_id:, remote:)
71
+ outcome = BridgeClient::StreamOpen.classify(sock, status, headers)
72
+ return outcome unless status.to_i == 503 && outcome.is_a?(BridgeClient::StreamOpen::Unreachable)
73
+
74
+ expected = [WorkerLink.unanswered(session_id), WorkerLink.not_live(session_id)]
75
+ detail = expected.include?(outcome.detail) ? outcome.detail : WorkerLink.not_live(session_id)
76
+ BridgeClient::StreamOpen::Unreachable.new(attempts: outcome.attempts, detail: "#{detail} on #{remote}")
77
+ end
71
78
 
72
79
  def bridge(status, **body) = BridgeClient::Response.new(status: status, body: JSON.generate(body.compact))
73
80
  end
@@ -223,7 +230,7 @@ module Samagotchi
223
230
  end
224
231
  return refused("chunked_unsupported", sock) if headers["transfer-encoding"].to_s.downcase.include?("chunked")
225
232
 
226
- Mapping.stream(sock, status, headers)
233
+ Mapping.stream(sock, status, headers, session_id: @session_id, remote: @remote.name)
227
234
  rescue SystemCallError, SocketError, IOError
228
235
  sock&.close rescue nil
229
236
  BridgeClient::StreamOpen::Unreachable.new(attempts: 1)
@@ -23,10 +23,12 @@ require_relative "host_registry"
23
23
  require_relative "utf8_default"
24
24
  require_relative "turn_note"
25
25
  require_relative "owner_lock"
26
+ require_relative "process_group"
26
27
  require_relative "bridge_client"
27
28
  require_relative "worker_sidecar"
28
29
  require_relative "worker_link"
29
30
  require_relative "worker_signals"
31
+ require_relative "chi_invocation"
30
32
  require_relative "log"
31
33
  require_relative "log_path"
32
34
  require_relative "paths"
@@ -194,6 +196,7 @@ module Samagotchi
194
196
  review: review
195
197
  )
196
198
  session.toolkit = Toolkit.configured(delegate: delegate) if session.toolkit.nil?
199
+ session.toolkit_origin = setup.toolkit_origin || "default"
197
200
  session.model_warning = model_warning
198
201
  # With no prompt there is no first turn to run (an attaching UI sends
199
202
  # the prompts), so the session starts idle.
@@ -629,16 +632,18 @@ module Samagotchi
629
632
  # worker from before restarts, running +version+: stop it instead), a
630
633
  # WorkerIdleExit#hold_for_restart hold (:turn_running, :reminders, ...),
631
634
  # :fallback_held (disk-visible work blocks an unresponsive-worker restart),
632
- # or :failed (the worker answered something else or didn't release).
635
+ # :stopped (its worker, +pid+, is SIGSTOPped: neither the ask nor a TERM
636
+ # reaches it until it is continued), or :failed (the worker answered something else or didn't release).
633
637
  class RestartRefused < StandardError
634
- attr_reader :session_id, :reason, :version, :holds
638
+ attr_reader :session_id, :reason, :version, :holds, :pid
635
639
 
636
- def initialize(session_id, reason, version: nil, holds: [])
640
+ def initialize(session_id, reason, version: nil, holds: [], pid: nil, via: ChiInvocation::HERE)
637
641
  @session_id = session_id
638
642
  @reason = reason.to_sym
639
643
  @version = version
640
644
  @holds = Array(holds).map(&:to_sym).freeze
641
- super(RestartRefused.words(session_id, @reason, version, @holds))
645
+ @pid = pid
646
+ super(RestartRefused.words(session_id, @reason, version, @holds, pid: pid, via: via))
642
647
  end
643
648
 
644
649
  HOLDS = {
@@ -654,17 +659,22 @@ module Samagotchi
654
659
  }.freeze
655
660
 
656
661
  # What the CLI and the web say.
657
- def self.words(session_id, reason, version = nil, holds = [])
662
+ def self.words(session_id, reason, version = nil, holds = [], pid: nil, via: ChiInvocation::HERE)
663
+ stop = via.command("sessions stop #{session_id}")
658
664
  case reason
665
+ when :stopped
666
+ where = via.remote? ? " on #{via.remote}" : ""
667
+ "session #{session_id}'s worker#{" (pid #{pid})" if pid} is stopped (SIGSTOP or Ctrl-Z) and can't take " \
668
+ "a restart: continue it#{" with `kill -CONT #{pid}`" if pid}#{where}, then restart again"
659
669
  when :not_running
660
670
  "session #{session_id} has no running worker; the next prompt starts one on the newest chi"
661
671
  when :unsupported
662
672
  "session #{session_id}'s worker runs chi #{version || "(older)"}, which can't restart: " \
663
- "chi sessions stop #{session_id}, then any prompt starts it on the newest chi"
673
+ "#{stop}, then any prompt starts it on the newest chi"
664
674
  when :fallback_held
665
675
  why = holds.map { |hold| HOLDS.fetch(hold, hold.to_s.tr("_", " ")) }.join("; ")
666
676
  "session #{session_id} has disk-visible work that a restart might lose#{": #{why}" unless why.empty?}; " \
667
- "run `chi sessions stop #{session_id}`, then any prompt starts it on the newest chi"
677
+ "run `#{stop}`, then any prompt starts it on the newest chi"
668
678
  when :failed then "session #{session_id}'s worker didn't take the restart"
669
679
  else "not now: #{HOLDS.fetch(reason, reason.to_s.tr("_", " "))}"
670
680
  end
@@ -692,6 +702,10 @@ module Samagotchi
692
702
  sd = state_dir || Session.default_state_dir
693
703
  Session.load(session_id, state_dir: sd) # ArgumentError for none
694
704
  owner = refuse_tui!(session_id, state_dir: sd)
705
+ # A stopped worker answers neither the ask (READ_TIMEOUT) nor the
706
+ # fallback's TERM (FALLBACK_STOP_WAIT): say so now, not after ~70 s.
707
+ raise RestartRefused.new(session_id, :stopped, pid: owner.pid) if owner&.worker? && ProcessGroup.stopped?(owner.pid)
708
+
695
709
  dir = Session.session_dir(session_id, state_dir: sd)
696
710
  link = WorkerLink.resolve(dir, unlink: false, owner: owner)
697
711
  if link.is_a?(WorkerLink::Skew)