samagotchi 0.131.0 → 0.133.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/bin/chi +19 -1
  4. data/docs/architecture.md +59 -1
  5. data/docs/cli.md +29 -8
  6. data/docs/configuration.md +115 -16
  7. data/docs/desktop.md +16 -6
  8. data/docs/guardrails.md +10 -4
  9. data/docs/internals/anthropic.md +3 -2
  10. data/docs/internals/engine.md +4 -4
  11. data/docs/internals/prompt-caching.md +26 -6
  12. data/docs/memory.md +16 -2
  13. data/docs/plugins.md +68 -1
  14. data/docs/sessions.md +60 -6
  15. data/docs/testing.md +1 -1
  16. data/lib/samagotchi/bootstrap_command.rb +35 -6
  17. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -3
  19. data/lib/samagotchi/bundles/model-notes/manifest.yml +4 -1
  20. data/lib/samagotchi/bundles/model-notes/model_notes_tone.md +3 -0
  21. data/lib/samagotchi/bundles/model-notes/notes/README.md +14 -0
  22. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  23. data/lib/samagotchi/cache_confirm.rb +18 -0
  24. data/lib/samagotchi/chi_home.rb +311 -0
  25. data/lib/samagotchi/chi_shim.rb +77 -0
  26. data/lib/samagotchi/child_env.rb +5 -3
  27. data/lib/samagotchi/config.rb +2 -0
  28. data/lib/samagotchi/context_fetch.rb +4 -2
  29. data/lib/samagotchi/desktop/macos.rb +100 -18
  30. data/lib/samagotchi/engine.rb +35 -4
  31. data/lib/samagotchi/engine_snapshots.rb +5 -0
  32. data/lib/samagotchi/gem_update.rb +19 -10
  33. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  34. data/lib/samagotchi/guardrails/pair_boundary.rb +21 -2
  35. data/lib/samagotchi/guardrails/pair_command.rb +57 -1
  36. data/lib/samagotchi/guardrails/protected_paths.rb +6 -3
  37. data/lib/samagotchi/guardrails/review_paths.rb +7 -4
  38. data/lib/samagotchi/guardrails/verdict.rb +1 -1
  39. data/lib/samagotchi/home_command.rb +145 -0
  40. data/lib/samagotchi/home_doctor.rb +177 -0
  41. data/lib/samagotchi/home_notice.rb +80 -0
  42. data/lib/samagotchi/installed_versions.rb +5 -3
  43. data/lib/samagotchi/llm_context_command.rb +53 -7
  44. data/lib/samagotchi/llm_context_override.rb +3 -2
  45. data/lib/samagotchi/plugin/api.rb +45 -1
  46. data/lib/samagotchi/plugin/context.rb +34 -1
  47. data/lib/samagotchi/plugin/loader.rb +5 -2
  48. data/lib/samagotchi/plugin/prompt_sections.rb +124 -0
  49. data/lib/samagotchi/project_env.rb +360 -0
  50. data/lib/samagotchi/run_options.rb +1 -0
  51. data/lib/samagotchi/self_report.rb +33 -0
  52. data/lib/samagotchi/session_access/contract.rb +1 -1
  53. data/lib/samagotchi/session_commands.rb +68 -19
  54. data/lib/samagotchi/session_manager.rb +54 -1
  55. data/lib/samagotchi/session_model_settings.rb +5 -2
  56. data/lib/samagotchi/system_prompt.rb +34 -3
  57. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  58. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  59. data/lib/samagotchi/testing/boundary.rb +3 -1
  60. data/lib/samagotchi/testing/bundle.rb +22 -3
  61. data/lib/samagotchi/testing/host.rb +22 -1
  62. data/lib/samagotchi/testing/recorder.rb +6 -1
  63. data/lib/samagotchi/testing/recording_api.rb +1 -1
  64. data/lib/samagotchi/tools/builtins.rb +13 -2
  65. data/lib/samagotchi/update_command.rb +65 -7
  66. data/lib/samagotchi/version.rb +1 -1
  67. data/lib/samagotchi/web/app.rb +61 -1
  68. data/lib/samagotchi/web/public/app.js +4 -1
  69. data/lib/samagotchi/web/public/chip_popover.js +5 -2
  70. data/lib/samagotchi/web/public/composer.css +11 -1
  71. data/lib/samagotchi/web/public/data.js +8 -4
  72. data/lib/samagotchi/web/public/index.html +52 -10
  73. data/lib/samagotchi/web/public/level_chip.js +22 -38
  74. data/lib/samagotchi/web/public/llm_context_chip.js +30 -73
  75. data/lib/samagotchi/web/public/model_pick.js +26 -0
  76. data/lib/samagotchi/web/public/model_picker.js +3 -1
  77. data/lib/samagotchi/web/public/review_start.js +13 -57
  78. data/lib/samagotchi/web/public/send_turn.js +3 -2
  79. data/lib/samagotchi/web/public/start_page.js +131 -99
  80. data/lib/samagotchi/web/public/start_settings.js +298 -0
  81. data/lib/samagotchi/web/public/start_toolkit.js +157 -0
  82. data/lib/samagotchi/web/public/thinking_chip.js +9 -22
  83. data/lib/samagotchi/web/server.rb +27 -8
  84. data/lib/samagotchi/web/update_check.rb +3 -2
  85. metadata +11 -1
@@ -107,7 +107,7 @@ class Plugin
107
107
  @next_id = 0
108
108
  @dead = nil
109
109
  @closing = false
110
- @stdin, @stdout, @stderr, @wait = Open3.popen3(env.to_h { |k, v| [k.to_s, v.to_s] }, *command,
110
+ @stdin, @stdout, @stderr, @wait = Open3.popen3(env.to_h { |k, v| [k.to_s, v&.to_s] }, *command,
111
111
  chdir: cwd, pgroup: true)
112
112
  @pid = @wait.pid
113
113
  start_watchdog
@@ -433,7 +433,7 @@ class Plugin
433
433
  def spawn(server, ctx)
434
434
  cancelled = -> { ctx.cancelled? }
435
435
  client = server.client = Client.new(
436
- server.command, env: server.env, cwd: server.cwd,
436
+ server.command, env: server_env(server), cwd: server.cwd,
437
437
  log: ->(event, **fields) { ctx.log.debug("mcp_#{event}", server: server.name, **fields) },
438
438
  on_exit: ->(reason) { exited(server, reason, ctx) },
439
439
  on_notification: lambda { |method, _params|
@@ -454,6 +454,13 @@ class Plugin
454
454
  client
455
455
  end
456
456
 
457
+ # A server's env: the session's project env (the worker's mise env, on a
458
+ # chi that has it: Samagotchi::ProjectEnv) under the server's own keys.
459
+ def server_env(server)
460
+ project = defined?(Samagotchi::ProjectEnv) ? Samagotchi::ProjectEnv.current.env : {}
461
+ project.merge(server.env)
462
+ end
463
+
457
464
  # The first sentence of initialize's instructions (at most
458
465
  # DESCRIPTION_CHARS), nil when there are none.
459
466
  def info_of(result)
@@ -494,7 +501,7 @@ class Plugin
494
501
  return nil unless lock.flock(File::LOCK_EX | File::LOCK_NB)
495
502
 
496
503
  cancelled = -> { ctx.cancelled? }
497
- client = Client.new(server.command, env: server.env, cwd: server.cwd,
504
+ client = Client.new(server.command, env: server_env(server), cwd: server.cwd,
498
505
  log: ->(event, **fields) { ctx.log.debug("mcp_#{event}", server: server.name, **fields) })
499
506
  begin
500
507
  initialized = client.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {},
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: model-notes
3
- version: 0.1.0
3
+ version: 0.2.0
4
4
  scope: system
5
5
  description: "Reviewed model notes: working habits a model family needs, loaded into the prompt only for the models each note's models: line names"
6
6
  trust_level: reviewed
@@ -9,3 +9,6 @@ files:
9
9
  model_notes_deepseek.md:
10
10
  sha256: sha256:0b07e40edc1a3d4a3ad6bea08d9b338913f78cab19a5809bcf4593f459f9bc5e
11
11
  description: "DeepSeek V4 working habits: explore briefly then act, commit per item, stop digging, memory_read for memories, file tools over heredocs"
12
+ model_notes_tone.md:
13
+ sha256: sha256:e47a93dc3b3cb78a9bd3eb7f04ef1c4d2a30b4a7c0110a66e1e3a26e363827ca
14
+ description: "Concise answers for every model (models: *): answer first, no preambles or closing offers"
@@ -0,0 +1,3 @@
1
+ models: *
2
+ How to write answers:
3
+ - Answer first, in plain words. Skip preambles, restating the question and closing offers. Use a list only for steps or several parallel items.
@@ -35,3 +35,17 @@ bundle itself.
35
35
  The dates and tasks differ, so this is a hint, not an A/B: an A/B is the same kind of task with the note on and
36
36
  muted (`chi send --new --mute model_notes_deepseek`). The shipped text is shorter than the one measured (795
37
37
  characters against 1,519) and keeps its habits.
38
+
39
+ ## model_notes_tone
40
+
41
+ - **models:** `*`: every model. It stacks on a family's note (`spec/model_ids.yml` `expected_overlaps`).
42
+ - **Origin, 2026-10-29:** chi's prompt has no style guidance, so each model talks in its own voice and answers run
43
+ long. A `/tone` bundle (presets, a session command) was built and parked; the user chose the lighter way, a model
44
+ note, with the gentlest text: only the anti-padding part of the concise preset ("leave out what went as expected"
45
+ was dropped: it can hide useful confirmations).
46
+ - **Approved:** the text as shipped, by the user, 2026-10-29 (after the check below); ships in model-notes (the `dev`
47
+ profile) first, `core` later only after more evidence (other model families, a week of the user's own use).
48
+ - **Evidence:** a with/without check on the local Qwen3.8 Flash Next (Splash), 5 tasks (4 questions about this repo,
49
+ 1 small code edit), one run each, `chi --non-interactive` with isolated state: answers 12.5k → 10.1k characters
50
+ (−19%), 4 of 5 shorter; openings more direct, closing summaries gone; nothing a task needed was lost (dropped: one
51
+ worked glob example, one aside about the model's own fixed mistake). N=1 per task: a direction, not a measurement.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.131.0
3
+ version: 0.133.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -12,6 +12,9 @@ module Samagotchi
12
12
  # The costs worth asking about (Thinking.cache_cost,
13
13
  # ReasoningHistory.cache_cost); :tail and nil are not.
14
14
  COSTLY = %i[full provider history].freeze
15
+ # Thread.current key: whether the plugin command running on this
16
+ # thread may ask (#asking).
17
+ ASKING = :samagotchi_cache_confirm_asking
15
18
 
16
19
  # The line that applies a change, and the question the reply ends with.
17
20
  Confirm = Data.define(:line, :text) do
@@ -42,6 +45,21 @@ module Samagotchi
42
45
  changed && history && COSTLY.include?(cost)
43
46
  end
44
47
 
48
+ # Run +block+ as a plugin command whose sender can answer a confirm
49
+ # (+flag+: SessionCommands#run's confirm, false once the line has
50
+ # --yes): Plugin::Context#cache_confirm asks only inside it, on this
51
+ # thread. Nests; threads the block starts don't inherit it.
52
+ def asking(flag)
53
+ previous = Thread.current[ASKING]
54
+ Thread.current[ASKING] = flag ? true : false
55
+ yield
56
+ ensure
57
+ Thread.current[ASKING] = previous
58
+ end
59
+
60
+ # Whether the plugin command on this thread may ask (#asking).
61
+ def asking? = Thread.current[ASKING] == true
62
+
45
63
  # Whether a cache-changing command should ask, and its reply line.
46
64
  # Kept independent of any UI so commands and plugins can share it.
47
65
  # @param line [String] the full command line without --yes
@@ -0,0 +1,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "open3"
6
+ require "rbconfig"
7
+ require "time"
8
+ require_relative "atomic_file"
9
+ require_relative "installed_gem"
10
+ require_relative "log"
11
+ require_relative "paths"
12
+ require_relative "version"
13
+
14
+ module Samagotchi
15
+ # The chi home: the one Ruby (and its gem path) every chi launch boots,
16
+ # whoever launches it (a terminal on another Ruby, chi web, the macOS
17
+ # helper under launchd). Recorded in home.json under the config dir
18
+ # ($XDG_CONFIG_HOME/samagotchi/home.json) by `chi home set`, or once, the
19
+ # first time, by chi bootstrap or chi update (#record_first!); never
20
+ # overwritten implicitly.
21
+ #
22
+ # One mechanism (docs/architecture.md, "How chi launches itself"):
23
+ # Record#boot_argv puts `Gem.paths = {…}` at the front of a `ruby -e`
24
+ # boot string, so no GEM_HOME/GEM_PATH ever goes into an environment
25
+ # (the tools a worker runs keep the spawner's, or the project's). Every
26
+ # launch site asks #current at launch time and keeps its own argv when
27
+ # there is no usable home.
28
+ module ChiHome
29
+ FILE = "home.json"
30
+ # What a launch outside a worker boots: the newest chi in the home,
31
+ # run as its own `chi` executable (ARGV after the "--").
32
+ CHI_BOOT = "gem 'samagotchi', '>= 0.a'; load Gem.bin_path('samagotchi', 'chi')"
33
+ # Where `mise` usually is when PATH doesn't have it (launchd's bare PATH).
34
+ MISE_DIRS = ["/opt/homebrew/bin", "/usr/local/bin", File.join(Dir.home, ".local", "bin")].freeze
35
+ # What a probe of another Ruby must not inherit: they would move it off
36
+ # its own gems.
37
+ PROBE_UNSET = %w[GEM_HOME GEM_PATH RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH].freeze
38
+ PROBE = "require 'json'; print JSON.generate('ruby' => RbConfig.ruby, 'gem_home' => Gem.dir, " \
39
+ "'gem_path' => Gem.path.join(File::PATH_SEPARATOR))"
40
+ GEMSPEC = /\Asamagotchi-(#{Gem::Version::VERSION_PATTERN})\.gemspec\z/
41
+
42
+ class Invalid < StandardError
43
+ end
44
+
45
+ # home.json's fields. +gem_path+ is what `Gem.paths=` takes (dirs joined
46
+ # by File::PATH_SEPARATOR); +mise+ an absolute path or nil.
47
+ Record = Data.define(:ruby, :gem_home, :gem_path, :mise, :set_at, :set_by_version) do
48
+ # home.json's (or a probe's) fields, checked: ruby, gem_home and every
49
+ # gem_path entry absolute paths, mise an absolute path or nil.
50
+ # @raise [Invalid]
51
+ def self.from_h(hash)
52
+ fields = members.to_h { |key| [key, hash[key.to_s]] }
53
+ %i[ruby gem_home gem_path].each do |key|
54
+ value = fields[key]
55
+ raise Invalid, "#{key} is missing" unless value.is_a?(String) && !value.empty?
56
+ end
57
+ raise Invalid, "ruby is not an absolute path" unless fields[:ruby].start_with?("/")
58
+ raise Invalid, "gem_home is not an absolute path" unless fields[:gem_home].start_with?("/")
59
+
60
+ dirs = fields[:gem_path].split(File::PATH_SEPARATOR)
61
+ raise Invalid, "gem_path has a relative entry" unless dirs.all? { |dir| dir.start_with?("/") }
62
+
63
+ mise = fields[:mise]
64
+ raise Invalid, "mise is not an absolute path" unless mise.nil? || (mise.is_a?(String) && mise.start_with?("/"))
65
+
66
+ new(**fields)
67
+ end
68
+
69
+ def to_h = members.to_h { |key| [key.to_s, public_send(key)] }
70
+ def gem_dirs = gem_path.split(File::PATH_SEPARATOR).reject(&:empty?)
71
+ def spec_dirs = gem_dirs.map { |dir| File.join(dir, "specifications") }
72
+ # The home Ruby's own gem command (its bindir, not a shim).
73
+ def gem_bin = File.join(File.dirname(ruby), "gem")
74
+ def ruby_ok? = File.file?(ruby) && File.executable?(ruby)
75
+
76
+ # The samagotchi versions installed in the home, newest last.
77
+ # @return [Array<Gem::Version>]
78
+ def versions
79
+ spec_dirs.flat_map do |dir|
80
+ Dir.children(dir).filter_map { |name| GEMSPEC.match(name)&.[](1) }
81
+ rescue SystemCallError
82
+ []
83
+ end.select { |v| Gem::Version.correct?(v) }.map { |v| Gem::Version.new(v) }.uniq.sort
84
+ end
85
+
86
+ def newest_version = versions.last
87
+
88
+ # Whether a gem named +name+ is installed in the home.
89
+ def gem?(name)
90
+ spec_dirs.any? { |dir| !Dir.glob(File.join(dir, "#{name}-*.gemspec")).empty? }
91
+ end
92
+
93
+ # The `Gem.paths =` line every boot starts with: string literals only
94
+ # (String#inspect escapes `#{`), no env read.
95
+ def prelude = "Gem.paths = {'GEM_HOME' => #{gem_home.inspect}, 'GEM_PATH' => #{gem_path.inspect}}"
96
+
97
+ # [ruby, "-e", "<prelude>; <run>"]: a site appends "--" and its args
98
+ # when +run+ runs chi's executable (CHI_BOOT).
99
+ def boot_argv(run:) = [ruby, "-e", "#{prelude}; #{run}"]
100
+
101
+ # Whether this process runs on the home: its Ruby and its gem dir.
102
+ def running_here?(ruby: RbConfig.ruby, gem_dir: Gem.dir)
103
+ ChiHome.real(ruby) == ChiHome.real(self.ruby) && ChiHome.real(gem_dir) == ChiHome.real(gem_home)
104
+ end
105
+ end
106
+
107
+ @warned = {}
108
+ @warn_mutex = Mutex.new
109
+
110
+ module_function
111
+
112
+ # @return [String] $XDG_CONFIG_HOME/samagotchi/home.json
113
+ def path(env: ENV) = File.join(Paths.config_home(env: env), Paths::APP_DIR, FILE)
114
+
115
+ # The record as home.json has it, usable or not (chi home shows it);
116
+ # nil without one.
117
+ # @raise [Invalid] a file that isn't home.json's shape
118
+ def read(path: self.path)
119
+ return nil unless File.file?(path)
120
+
121
+ data = JSON.parse(File.read(path))
122
+ raise Invalid, "#{Paths.tilde(path)} is not a JSON object" unless data.is_a?(Hash)
123
+
124
+ Record.from_h(data)
125
+ rescue JSON::ParserError, SystemCallError => e
126
+ raise Invalid, "#{Paths.tilde(path)}: #{e.message.lines.first.to_s.strip}"
127
+ rescue Invalid => e
128
+ raise if e.message.start_with?(Paths.tilde(path))
129
+
130
+ raise Invalid, "#{Paths.tilde(path)}: #{e.message}"
131
+ end
132
+
133
+ # The home every launch boots, or nil, where each site keeps its own
134
+ # argv: no home.json; one that isn't its shape (warned once); its ruby
135
+ # gone (a Ruby upgrade); no samagotchi in its gem path; or this chi
136
+ # runs from a source checkout (a worktree's bin/chi spawns its own code).
137
+ # @param gem_spec [Gem::Specification, nil] the installed gem this runs from
138
+ # @return [Record, nil]
139
+ def current(path: self.path, gem_spec: InstalledGem.spec)
140
+ return nil unless gem_spec
141
+
142
+ home = read(path: path)
143
+ return nil unless home
144
+
145
+ why = problem(home)
146
+ why ? warn_once(:unusable, path: path, problem: why) : home
147
+ rescue Invalid => e
148
+ warn_once(:invalid, path: path, error: e.message)
149
+ end
150
+
151
+ # Why a recorded home can't boot chi, or nil.
152
+ # @return [String, nil]
153
+ def problem(home)
154
+ return "home ruby missing: #{home.ruby}" unless home.ruby_ok?
155
+ return "no samagotchi in the home's gems (#{home.gem_path})" if home.versions.empty?
156
+
157
+ nil
158
+ end
159
+
160
+ # The home's boot, or nil without a usable home (#current).
161
+ # @return [Array<String>, nil]
162
+ def boot_argv(run:, home: current) = home&.boot_argv(run: run)
163
+
164
+ # Where a gem install lands: the home's gem dir, else this process's
165
+ # (UpdateCheck's writability).
166
+ def gem_dir(gem_spec: InstalledGem.spec) = current(gem_spec: gem_spec)&.gem_home || Gem.dir
167
+
168
+ # The specification dirs new workers activate chi from: the home's,
169
+ # else this process's (InstalledVersions' "installed; restart").
170
+ def spec_dirs(gem_spec: InstalledGem.spec) = current(gem_spec: gem_spec)&.spec_dirs || Gem::Specification.dirs
171
+
172
+ # The gem command a gem install runs: the home's, else this Ruby's own.
173
+ def gem_bin(home = current) = home ? home.gem_bin : File.join(RbConfig::CONFIG["bindir"], "gem")
174
+
175
+ # The env for a gem install child (chi update, chi bootstrap's
176
+ # commonmarker): into the home's gems whatever the spawner's
177
+ # GEM_HOME/GEM_PATH say (a chruby shell, an old helper's launch file),
178
+ # and with mise's dir on PATH (mise's rubygems plugin runs `mise
179
+ # reshim` after an install). {} without a home: the env as today.
180
+ # @return [Hash{String=>String}]
181
+ def install_env(home = current, env: ENV)
182
+ return {} unless home
183
+
184
+ result = { "GEM_HOME" => home.gem_home, "GEM_PATH" => home.gem_path }
185
+ if home.mise
186
+ dirs = env["PATH"].to_s.split(File::PATH_SEPARATOR)
187
+ mise_dir = File.dirname(home.mise)
188
+ result["PATH"] = (dirs + [mise_dir]).uniq.join(File::PATH_SEPARATOR) unless dirs.include?(mise_dir)
189
+ end
190
+ result
191
+ end
192
+
193
+ # The fallback pin for a worker's `>= 0.a` (a broken newest install):
194
+ # the spawner's own version when the home has it, else the home's
195
+ # newest.
196
+ # @return [String, nil]
197
+ def fallback_version(home, gem_spec)
198
+ versions = home.versions
199
+ own = gem_spec && Gem::Version.new(gem_spec.version.to_s)
200
+ (own && versions.include?(own) ? own : versions.last)&.to_s
201
+ end
202
+
203
+ # This process's Ruby and gem path as a record (chi home set without
204
+ # --ruby, a first record from the running chi).
205
+ def from_running(env: ENV)
206
+ Record.new(ruby: real(RbConfig.ruby), gem_home: Gem.dir, gem_path: Gem.path.join(File::PATH_SEPARATOR),
207
+ mise: find_mise(env: env), set_at: Time.now.utc.iso8601, set_by_version: VERSION)
208
+ end
209
+
210
+ # Another Ruby's own gem dirs, asked of it (its GEM_*/RUBYOPT unset
211
+ # unless +gem_env+ gives the GEM_HOME/GEM_PATH its chi lived under).
212
+ # @param runner [#call] (env, argv) → [output, success]
213
+ # @return [Record, nil] nil when it doesn't run or answer
214
+ def probe(ruby, gem_env: {}, env: ENV, runner: method(:capture))
215
+ child_env = PROBE_UNSET.to_h { |key| [key, nil] }.merge(gem_env.slice("GEM_HOME", "GEM_PATH").compact)
216
+ output, ok = runner.call(child_env, [ruby, "-e", PROBE])
217
+ return nil unless ok
218
+
219
+ data = JSON.parse(output.to_s.lines.last.to_s)
220
+ return nil unless data.is_a?(Hash)
221
+
222
+ Record.from_h(data.merge("ruby" => data["ruby"].is_a?(String) ? real(data["ruby"]) : data["ruby"],
223
+ "mise" => find_mise(env: env), "set_at" => Time.now.utc.iso8601,
224
+ "set_by_version" => VERSION))
225
+ rescue JSON::ParserError, TypeError, Invalid
226
+ nil
227
+ end
228
+
229
+ # @raise [SystemCallError]
230
+ def write!(home, path: self.path)
231
+ FileUtils.mkdir_p(File.dirname(path))
232
+ AtomicFile.write(path, "#{JSON.pretty_generate(home.to_h)}\n", perm: 0o644)
233
+ home
234
+ end
235
+
236
+ # The first record, once, from an installed chi (chi bootstrap, chi
237
+ # update): of the macOS helper's ruby (when its launch file names one
238
+ # whose newest chi is at least this one: the Ruby it already runs) and
239
+ # this chi's, the one with the newest chi; the helper's on a tie.
240
+ # Never over an existing home.json, never from a checkout or under
241
+ # Bundler, never a Ruby without samagotchi.
242
+ # @param launch_path [String, nil] the helper's launch.json
243
+ # @param bundler [Boolean, nil] running under Bundler (nil: found out)
244
+ # @return [Record, nil] the home written, nil when none was
245
+ def record_first!(gem_spec: InstalledGem.spec, launch_path: nil, path: self.path, env: ENV,
246
+ runner: method(:capture), bundler: nil)
247
+ return nil unless gem_spec
248
+ return nil if bundler.nil? ? under_bundler?(env) : bundler
249
+ return nil if File.exist?(path)
250
+
251
+ own = Gem::Version.new(VERSION)
252
+ helper = helper_home(launch_path, env: env, runner: runner)
253
+ helper = nil if helper && (helper.newest_version.nil? || helper.newest_version < own)
254
+ home = [helper, from_running(env: env)].compact.select { |candidate| problem(candidate).nil? }
255
+ .max_by(&:newest_version)
256
+ home && write!(home, path: path)
257
+ rescue SystemCallError => e
258
+ Log.warn(:config, "home_record_failed", error: e.class.name, msg: e.message)
259
+ nil
260
+ end
261
+
262
+ # The helper's launch.json ruby (argv[0]) probed with the GEM_* it was
263
+ # installed with; nil without one.
264
+ def helper_home(launch_path, env:, runner:)
265
+ return nil unless launch_path && File.file?(launch_path)
266
+
267
+ launch = JSON.parse(File.read(launch_path))
268
+ ruby = Array(launch["argv"]).first if launch.is_a?(Hash)
269
+ return nil unless ruby.is_a?(String) && ruby.start_with?("/") && File.executable?(ruby)
270
+
271
+ gem_env = launch["env"].is_a?(Hash) ? launch["env"] : {}
272
+ probe(ruby, gem_env: gem_env, env: env, runner: runner)
273
+ rescue JSON::ParserError, SystemCallError
274
+ nil
275
+ end
276
+
277
+ # The test UpdateCommand's @bundler makes: a first record from a
278
+ # `bundle exec` would name the bundle's gems.
279
+ def under_bundler?(env = ENV) = !!(defined?(::Bundler) || !env["BUNDLE_GEMFILE"].to_s.empty?)
280
+
281
+ # mise's absolute path: on +env+'s PATH, else in MISE_DIRS; nil without.
282
+ def find_mise(env: ENV)
283
+ dirs = env["PATH"].to_s.split(File::PATH_SEPARATOR).select { |dir| dir.start_with?("/") } + MISE_DIRS
284
+ dirs.map { |dir| File.join(dir, "mise") }.find { |file| File.file?(file) && File.executable?(file) }
285
+ end
286
+
287
+ def real(path)
288
+ File.realpath(path)
289
+ rescue SystemCallError, TypeError
290
+ path.to_s
291
+ end
292
+
293
+ def capture(env, argv)
294
+ output, status = Open3.capture2e(env, *argv)
295
+ [output, status.success?]
296
+ rescue SystemCallError => e
297
+ [e.message, false]
298
+ end
299
+
300
+ # Logs a home that can't be used, once per process and reason; nil.
301
+ def warn_once(event, **fields)
302
+ key = [event, fields]
303
+ first = @warn_mutex.synchronize { @warned[key] ? false : (@warned[key] = true) }
304
+ Log.warn(:config, "home_#{event}", **fields) if first
305
+ nil
306
+ end
307
+
308
+ # Specs: forget what was warned.
309
+ def reset_warnings! = @warn_mutex.synchronize { @warned.clear }
310
+ end
311
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "shellwords"
5
+ require_relative "atomic_file"
6
+ require_relative "chi_home"
7
+ require_relative "log"
8
+ require_relative "paths"
9
+
10
+ module Samagotchi
11
+ # The `chi` a worker's tools run: $XDG_STATE_HOME/samagotchi/bin/chi, a
12
+ # shell script that execs the chi home's Ruby with its boot
13
+ # (ChiHome::Record#boot_argv, CHI_BOOT). ProjectEnv puts its dir first on
14
+ # the worker's PATH, so after the project env (whose PATH would find the
15
+ # project Ruby's chi, an old one or none) an agent's `chi send`, `chi self`
16
+ # or `chi answer` still runs the home's chi. The dir holds `chi` only: the
17
+ # project's ruby, gem and bundle stay the project's; bin/chi, a checkout
18
+ # path or `bundle exec chi` stay themselves.
19
+ module ChiShim
20
+ NAME = "chi"
21
+ HEADER = "# chi's own chi for its tools (ChiShim): rewritten at each worker start from the chi home.\n"
22
+ # A project's RUBYOPT/RUBYLIB/RUBYGEMS_GEMDEPS/GEMRC/BUNDLE_* (a `bundle
23
+ # exec` the tool runs under, a mise [env]) would load the project's gems
24
+ # or settings into the home's chi, its LD_*/DYLD_* libraries into its
25
+ # ruby: unset before the exec.
26
+ UNSET = "unset RUBYOPT RUBYLIB RUBYGEMS_GEMDEPS GEMRC LD_PRELOAD LD_LIBRARY_PATH DYLD_INSERT_LIBRARIES " \
27
+ "DYLD_LIBRARY_PATH\n" \
28
+ "for v in $(/usr/bin/env | /usr/bin/sed -n 's/^\\(BUNDLE[A-Za-z0-9_]*\\)=.*/\\1/p'); do unset \"$v\"; done\n"
29
+
30
+ module_function
31
+
32
+ # @return [String] $XDG_STATE_HOME/samagotchi/bin
33
+ def dir(env: ENV) = File.join(Paths.state_dir(env: env), "bin")
34
+
35
+ def path(dir: self.dir) = File.join(dir, NAME)
36
+
37
+ # The script for +home+.
38
+ # @param home [ChiHome::Record]
39
+ def content(home)
40
+ argv = home.boot_argv(run: ChiHome::CHI_BOOT) + ["--"]
41
+ "#!/bin/sh\n#{HEADER}#{UNSET}exec #{argv.map { |arg| Shellwords.escape(arg) }.join(" ")} \"$@\"\n"
42
+ end
43
+
44
+ # Writes the shim for +home+ unless it is already that (atomically: a
45
+ # tool running `chi` meanwhile gets the old one or the new one).
46
+ # @return [Boolean] whether the shim is there and current
47
+ def ensure!(home, dir: self.dir)
48
+ file = path(dir: dir)
49
+ want = content(home)
50
+ return true if current?(file, want)
51
+
52
+ FileUtils.mkdir_p(dir)
53
+ AtomicFile.write(file, want, perm: 0o755)
54
+ true
55
+ rescue SystemCallError => e
56
+ Log.warn(:worker, "chi_shim_failed", path: file, error: e.class.name, msg: e.message)
57
+ false
58
+ end
59
+
60
+ # Why the shim isn't +home+'s (chi home doctor), or nil: nil too
61
+ # without one (the first worker start writes it).
62
+ # @return [String, nil]
63
+ def problem(home, dir: self.dir)
64
+ file = path(dir: dir)
65
+ return nil if !File.exist?(file) || current?(file, content(home))
66
+
67
+ "the chi script tools run (#{Paths.tilde(file)}) boots another chi than the home: " \
68
+ "the next worker start rewrites it (chi sessions restart ID for a running session)"
69
+ end
70
+
71
+ def current?(file, want)
72
+ File.file?(file) && File.executable?(file) && File.read(file) == want
73
+ rescue SystemCallError
74
+ false
75
+ end
76
+ end
77
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "installed_gem"
4
4
  require_relative "installed_versions"
5
+ require_relative "project_env"
5
6
 
6
7
  module Samagotchi
7
8
  # What a chi process another chi starts must not take from its spawner's
@@ -23,11 +24,12 @@ module Samagotchi
23
24
  module_function
24
25
 
25
26
  # The keys of +env+ a child chi must not inherit: the guardrails
26
- # settings, the session markers and a smoke's fake "newest installed"
27
- # (InstalledVersions::ENV_KEY, for the spawner's notices only).
27
+ # settings, the session markers, a smoke's fake "newest installed"
28
+ # (InstalledVersions::ENV_KEY, for the spawner's notices only) and the
29
+ # worker's project env marker and record (a child worker applies its own).
28
30
  # @return [Array<String>]
29
31
  def spawner_keys(env = ENV)
30
- env.each_key.select { |key| key.start_with?(GUARDRAILS_PREFIX) } + SESSION_MARKERS + [InstalledVersions::ENV_KEY]
32
+ env.each_key.select { |key| key.start_with?(GUARDRAILS_PREFIX) } + SESSION_MARKERS + [InstalledVersions::ENV_KEY, ProjectEnv::MARKER_ENV, ProjectEnv::ORIG_ENV]
31
33
  end
32
34
 
33
35
  # #spawner_keys mapped to nil: Process.spawn unsets a key mapped to nil.
@@ -138,6 +138,8 @@ module Samagotchi
138
138
  Entry.new(key: "session.keep_status", yaml_path: %w[session keep_status], type: :string, default: "", expose: %i[env config cli], lifecycle: :next_retention_sweep),
139
139
  Entry.new(key: "session.sweep_interval_hours", yaml_path: %w[session sweep_interval_hours], type: :integer, default: 24, expose: %i[env config cli], lifecycle: :next_retention_sweep),
140
140
  Entry.new(key: "session.idle_exit_minutes", yaml_path: %w[session idle_exit_minutes], type: :float, default: 30.0, expose: %i[env config cli], lifecycle: :worker_start),
141
+ # Tools follow the project: a worker applies its folder's `mise env` at start (ProjectEnv); off keeps the spawner's env.
142
+ Entry.new(key: "session.project_env", yaml_path: %w[session project_env], type: :enum, default: "mise", expose: %i[env config], enum_values: %w[mise off], lifecycle: :worker_start),
141
143
  # Plain `chi` runs like `chi --shared` (bin/chi, LaunchMode); false, or --no-shared per run, keeps the plain REPL. No CLI flag: that would duplicate --shared.
142
144
  Entry.new(key: "session.shared", yaml_path: %w[session shared], type: :bool, default: true, expose: %i[env config], lifecycle: :next_invocation),
143
145
  # false: a session nothing happened in is deleted when it is left (SessionManager.discardable?).
@@ -5,6 +5,7 @@ require_relative "context_sources"
5
5
  require_relative "context_providers"
6
6
  require_relative "log"
7
7
  require_relative "process_group"
8
+ require_relative "project_env"
8
9
 
9
10
  module Samagotchi
10
11
  # Runs an attached context source's command once and records the result
@@ -108,8 +109,9 @@ module Samagotchi
108
109
 
109
110
  def env_for(location, name)
110
111
  previous = location.snapshot_path(name)
111
- { "SAMAGOTCHI_CONTEXT_NAME" => name,
112
- "SAMAGOTCHI_CONTEXT_PREVIOUS" => File.exist?(previous) ? previous : nil }
112
+ # The session's project env (ProjectEnv) under the source's own keys.
113
+ ProjectEnv.tools_env.merge("SAMAGOTCHI_CONTEXT_NAME" => name,
114
+ "SAMAGOTCHI_CONTEXT_PREVIOUS" => File.exist?(previous) ? previous : nil)
113
115
  end
114
116
 
115
117
  # @return [Run]