samagotchi 0.129.0 → 0.130.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/cli.md +29 -11
  5. data/docs/configuration.md +7 -2
  6. data/docs/desktop.md +28 -9
  7. data/docs/guardrails.md +60 -16
  8. data/docs/internals/background-tasks.md +12 -0
  9. data/docs/internals/engine.md +4 -3
  10. data/docs/internals/participants.md +25 -6
  11. data/docs/internals/remote-sessions.md +5 -4
  12. data/docs/sessions.md +5 -3
  13. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  14. data/lib/samagotchi/desktop/macos.rb +99 -19
  15. data/lib/samagotchi/desktop_command.rb +7 -2
  16. data/lib/samagotchi/engine.rb +20 -4
  17. data/lib/samagotchi/engine_peers.rb +3 -1
  18. data/lib/samagotchi/guardrails/chi_dirs.rb +64 -0
  19. data/lib/samagotchi/guardrails/child_boundary.rb +5 -5
  20. data/lib/samagotchi/guardrails/outside.rb +22 -20
  21. data/lib/samagotchi/guardrails/pair_boundary.rb +14 -3
  22. data/lib/samagotchi/guardrails/pair_command.rb +70 -10
  23. data/lib/samagotchi/guardrails/parent_approvals.rb +8 -11
  24. data/lib/samagotchi/guardrails/path_resolve.rb +231 -0
  25. data/lib/samagotchi/guardrails/protected_paths.rb +17 -116
  26. data/lib/samagotchi/guardrails/review_paths.rb +13 -11
  27. data/lib/samagotchi/guardrails/rm_targets.rb +5 -5
  28. data/lib/samagotchi/guardrails/rules.rb +23 -5
  29. data/lib/samagotchi/guardrails/scratch_writes.rb +3 -2
  30. data/lib/samagotchi/guardrails/shell_git_dirs.rb +17 -12
  31. data/lib/samagotchi/guardrails/shell_paths.rb +18 -29
  32. data/lib/samagotchi/guardrails/targets.rb +24 -13
  33. data/lib/samagotchi/remote_command.rb +13 -3
  34. data/lib/samagotchi/remotes.rb +3 -2
  35. data/lib/samagotchi/review_start.rb +3 -2
  36. data/lib/samagotchi/session.rb +49 -4
  37. data/lib/samagotchi/session_inbox.rb +17 -0
  38. data/lib/samagotchi/system_one/self_protect.rb +34 -19
  39. data/lib/samagotchi/terminal_ui.rb +2 -0
  40. data/lib/samagotchi/tool_declarations.rb +1 -1
  41. data/lib/samagotchi/tools/builtins.rb +10 -1
  42. data/lib/samagotchi/tools/execute.rb +5 -2
  43. data/lib/samagotchi/tools/peers.rb +4 -0
  44. data/lib/samagotchi/tools/task_runtime.rb +4 -2
  45. data/lib/samagotchi/tools/task_wait.rb +26 -1
  46. data/lib/samagotchi/turn_state.rb +23 -4
  47. data/lib/samagotchi/update_command.rb +9 -1
  48. data/lib/samagotchi/version.rb +1 -1
  49. data/lib/samagotchi/web/app.rb +69 -25
  50. data/lib/samagotchi/web/participant_review.rb +9 -2
  51. data/lib/samagotchi/web/proxy_gate.rb +14 -6
  52. data/lib/samagotchi/web/server.rb +29 -4
  53. data/lib/samagotchi/web/token.rb +20 -1
  54. data/lib/samagotchi/worker.rb +16 -1
  55. metadata +3 -1
data/docs/sessions.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # Sessions
2
2
 
3
- Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/sessions/<uuid>.json` (XDG-aware via `XDG_STATE_HOME`) plus a sidecar dir `<uuid>/` with `input/`/`notes/`/`output/`/`owner.lock`/`bridge.json` (`Session.session_dir`), and `tool_output/` once a long `execute` result was cut: its whole output, which the cut result names (`ToolOutputFiles`, the newest 50 kept; see [Tool output guardrails](internals/tool-guardrails.md#the-runners-cut-max_tool_output_chars)). Besides the messages and turn state, the JSON keeps `used_memory_names` (what the session read), `preloaded_memory_names` (`--memory`) and `muted_memory_names` (`--mute`), the last two written before the worker starts so a respawn builds the same prompt, `prompt_notes`, the model notes the system prompt carried at its last build (`[{name, scope, chars, digest}]`, see [Memory](memory.md#model-notes)), `parent_id`, the session that delegated this one (see *Delegating*; `null` otherwise), and `continues`, the session this one continues (the previous link of a chain, see *Session chains*; `null` otherwise); files from before those keys read as empty lists, no parent and no previous link. `model_name` is the resolved model ref (an alias applied, `host:id` when it names a host), so a resumed session keeps its model when an alias is retargeted; `model_typed` is the name it was given as when that differs (an alias), for the `models:` lookup after a resume (`null` otherwise).
3
+ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/sessions/<uuid>.json` (XDG-aware via `XDG_STATE_HOME`) plus a sidecar dir `<uuid>/` with `input/`/`notes/`/`output/`/`owner.lock`/`bridge.json` (`Session.session_dir`), and `tool_output/` once a long `execute` result was cut: its whole output, which the cut result names (`ToolOutputFiles`, the newest 50 kept; see [Tool output guardrails](internals/tool-guardrails.md#the-runners-cut-max_tool_output_chars)). Besides the messages and turn state, the JSON keeps `written_by` (the chi version that saved it last; see *Which chi a worker runs*), `used_memory_names` (what the session read), `preloaded_memory_names` (`--memory`) and `muted_memory_names` (`--mute`), the last two written before the worker starts so a respawn builds the same prompt, `prompt_notes`, the model notes the system prompt carried at its last build (`[{name, scope, chars, digest}]`, see [Memory](memory.md#model-notes)), `parent_id`, the session that delegated this one (see *Delegating*; `null` otherwise), and `continues`, the session this one continues (the previous link of a chain, see *Session chains*; `null` otherwise); files from before those keys read as empty lists, no parent and no previous link. `model_name` is the resolved model ref (an alias applied, `host:id` when it names a host), so a resumed session keeps its model when an alias is retargeted; `model_typed` is the name it was given as when that differs (an alias), for the `models:` lookup after a resume (`null` otherwise).
4
4
 
5
5
  **Message times:** an entry of `messages` may carry `at`, when it happened: a local-offset ISO time with milliseconds (`2026-10-24T14:32:05.120+02:00`). User prompts, lines merged into a turn and steers, reminders, context and task notes, and the model's replies have one (a note's is its `created_at`); tool results, `!cmd` messages and chi's own turn notes don't, nor does anything saved before chi stamped messages (no backfill). It is for the UIs only (the gap dividers, a bubble's time): it never goes to the model, so it changes nothing in the requests or their prompt cache. The one time the model reads is the reminders': due reminders go into a turn as a system message headed `[SYSTEM: REMINDERS DUE 2026-10-24 14:32 local]` (the turn's local time; `ReminderQueue#inject!`), written into the saved content then, so a later request sends the same text.
6
6
 
7
7
  **Verify verdicts:** a web Verify turn stamps its opening user message with `verify: {turn_id, quote}` (the selected source turn and quote; metadata only, never added to the prompt). When that turn completes and the final assistant reply's last non-empty line is a valid `Verdict: confirmed|refuted|unclear — <reason>`, the session file appends `{anchor: {turn_id, quote}, verdict, reason, by, check_turn_id, at}` to its optional `verdicts` array. `by` is the turn's author (nil for the owner), `check_turn_id` names the Verify turn, and `at` is its timestamp. The array is append-only; older files without it have no saved verdicts. An `unclear` verdict can be checked again by **Verify fresh** (a delegate child that gets only the claim; see *Delegating*): its verdict is appended to the parent's `verdicts` with `fresh: true`, `of` (the verdict it checked again) and `child_id`, from the parent's `fresh_verdicts/` dir, which its worker takes between turns. Verdicts are display state: none enters a prompt.
8
8
 
9
- **Input files:** a message for a session's worker waits in `input/<timestamp>.json` (`SessionInbox.write_input`: `prompt`, `client_id`, `enqueued_id`, and `images`, `no_interrupt` and `delivery` when they apply; `delivery` is written only for `"queue"`). While a turn runs, the worker merges them at each step boundary in name order, except a queued one or one with images: those wait for the turn's end and run one turn each, in name order, and a file after them still merges (`Worker#pending_input_drain`). A queue is sent only to a worker whose `bridge.json` names the `delivery` feature; an older one would steer it, so `SessionManager.deliver_turn` refuses it (409 `delivery_unavailable`), and any other 4xx the Bridge answers is a refusal too, never a fallback file (except a 413, a body over its size limit: that one still goes in as a file, its `delivery` kept). A session command recognized in a file runs without a turn; its outcome is kept at `input/settled/<enqueued_id>` (`completed`, `failed` for a command error, `canceled` with `cancel_reason` `busy` or `dropped` for one refused mid-turn or dropped as the worker left; `origin: command`) so `chi send --wait` ends instead of waiting for a turn that will not run, its detail saying `the command failed` or `the command was refused (busy)` rather than a turn's words. **Reply files:** a turn that ends with visible text leaves its reply in `output/<timestamp>.txt`, or `output/<timestamp>-<enqueued_id>.txt` when the turn ran an input file with an id (`SessionInbox.write_output`), and the session's `last_turn` keeps that `enqueued_id`; so a wait for one queued message (`chi send --queue --wait`, `ReplyWait`'s `turn:`) finds its own turn's reply or end, never another turn's. The names still sort by time, and every reader takes `*.txt`.
9
+ **Input files:** a message for a session's worker waits in `input/<timestamp>.json` (`SessionInbox.write_input`: `prompt`, `client_id`, `enqueued_id`, and `images`, `no_interrupt` and `delivery` when they apply; `delivery` is written only for `"queue"`). While a turn runs, the worker merges them at each step boundary in name order, except a queued one or one with images: those wait for the turn's end and run one turn each, in name order, and a file after them still merges (`Worker#pending_input_drain`). Such a file, with a prompt, from the user, `chi send` or a delegating parent, also ends a running `task_wait` (`SessionInbox.steer_waiting?`, a peek the worker's turn polls through `Engine#input_waiting?`; never in a participant's turn): the file stays for the drain. A queue is sent only to a worker whose `bridge.json` names the `delivery` feature; an older one would steer it, so `SessionManager.deliver_turn` refuses it (409 `delivery_unavailable`), and any other 4xx the Bridge answers is a refusal too, never a fallback file (except a 413, a body over its size limit: that one still goes in as a file, its `delivery` kept). A session command recognized in a file runs without a turn; its outcome is kept at `input/settled/<enqueued_id>` (`completed`, `failed` for a command error, `canceled` with `cancel_reason` `busy` or `dropped` for one refused mid-turn or dropped as the worker left; `origin: command`) so `chi send --wait` ends instead of waiting for a turn that will not run, its detail saying `the command failed` or `the command was refused (busy)` rather than a turn's words. **Reply files:** a turn that ends with visible text leaves its reply in `output/<timestamp>.txt`, or `output/<timestamp>-<enqueued_id>.txt` when the turn ran an input file with an id (`SessionInbox.write_output`), and the session's `last_turn` keeps that `enqueued_id`; so a wait for one queued message (`chi send --queue --wait`, `ReplyWait`'s `turn:`) finds its own turn's reply or end, never another turn's. The names still sort by time, and every reader takes `*.txt`.
10
10
 
11
11
  **Removing a queued message:** a message queued for after the running turn (`chi send --queue`, the web's Alt+Enter, `/queue TEXT` in a terminal), or one with images (it waits for the turn's end too), can be taken back while it still waits: the worker's Bridge (`POST /session/:id/turn/dequeue {enqueued_id}`, the `unqueue` feature) deletes its input file and every UI drops it (`:turn_dequeued`). It is a race with the turn's end, decided by the same rename the worker uses to take the file: once a turn took it the answer is `409 already_started` (it runs), and an id the worker no longer knows is `404 unknown_enqueued_id`. A plain message sent without `--queue` is not removable (`409 not_queued`): a running turn's next step may take it at any moment. A wait for its turn (`chi send --queue --wait`, a delegate follow-up's wait) ends at once with `removed` (the tombstone `input/removed/<enqueued_id>` says so), and a `chi -p` run whose queued prompt went ends with exit 1. The `200` carries the removed `prompt`: the web and the attached terminal put it back into the composer to edit (not its images). The images a removed message named stay in the session's `images/`. Not removable: a queued session command (`/queue /model x`: it waits in the worker, not in `input/`), a message in a stopped worker's inbox (the next worker runs it), and the plain REPL's (`--no-shared`) own queue.
12
12
 
@@ -29,7 +29,9 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
29
29
 
30
30
  **Attached by default:** plain `chi` and `chi --resume ID` run their session in a background worker and attach to it (`session.shared`, default `true`; env `SAMAGOTCHI_SESSION_SHARED`). The worker runs in the session's `working_directory`, so `!commands` and tools see the directory the session was started in. `session.shared: false` keeps the in-process REPL, `--no-shared` does for one run; a session the REPL has open can't be attached ("close it there first"). See [CLI: Sharing a session](cli.md#sharing-a-session).
31
31
 
32
- **Which chi a worker runs:** a new or woken worker runs the newest chi installed for this Ruby (`gem 'samagotchi', '>= 0.a'`, prereleases count), whoever starts it: a `chi web` or terminal still on an older chi starts it on the newest one, so a stale process doesn't spread old code. A worker already running keeps its version until it is restarted. Should the newest install fail to load, the worker runs the starter's own version and logs `boot_fallback` (its stdio is `/dev/null`). Under `bundle exec` the worker drops Bundler's env first, so it activates the newest gem too. Two consequences: a wrapper pin (`chi _0.17.0_ web`) pins only that process, not the workers it starts; and a source checkout (`bin/chi` in a worktree, `bundle exec`) starts its workers on its own `lib/`, but a session it made that is later woken by an installed chi (`chi web`, `chi send`) runs the installed code. The names an older starter hands a newer worker (the boot line, its env keys, `input/` files, the session JSON fields and the Bridge routes sessions use on each other) are pinned in `spec/worker_contract_spec.rb`.
32
+ **Which chi a worker runs:** a new or woken worker runs the newest chi installed for this Ruby (`gem 'samagotchi', '>= 0.a'`, prereleases count), whoever starts it: a `chi web` or terminal still on an older chi starts it on the newest one, so a stale process doesn't spread old code. A worker already running keeps its version until it is restarted. Should the newest install fail to load, the worker runs the starter's own version and logs `boot_fallback` (its stdio is `/dev/null`). Under `bundle exec` the worker drops Bundler's env first, so it activates the newest gem too. Two consequences: a wrapper pin (`chi _0.17.0_ web`) pins only that process, not the workers it starts; and a source checkout (`bin/chi` in a worktree, `bundle exec`) starts its workers on its own `lib/`, but a session it made that is later woken by an installed chi (`chi web`, `chi send`) runs the installed code. The names an older starter hands a newer worker (the boot line, its env keys, `input/` files, the session JSON fields and the Bridge routes sessions use on each other) are pinned in `spec/worker_contract_spec.rb`. A chi newer than 0.130.0 keeps the top-level keys it doesn't know (a newer chi's fields) when it saves a session, after its own, and doesn't act on them; `written_by` names the chi that saved the file last, and a worker woken on a file a newer chi saved logs `newer_writer`. chi 0.130.0 and older drop those keys: a session a newer chi made loses its newer fields when an older chi wakes or saves it (for example `chi --attach` from a project whose Ruby has an older chi). Keep the chi in every Ruby you run it from newer than 0.130.0.
33
+
34
+ A new session field (the rule in `Session::FIELDS`' comment): only top-level keys are kept (a new sub-key of `review`, `toolkit` or another value object is still dropped by an older chi), and an older chi keeps a key without honouring it, so a new field must be safe to come back unchanged after an older chi ran turns on the session.
33
35
 
34
36
  **A worker outlives whoever started it:** a spawned worker leaves its spawner's process tree (it is forked again, in a session of its own, and reparented to init), so closing the terminal of a `chi web`, or a tool tearing down a background `chi send --wait` and its children, no longer takes the worker and its turn with it.
35
37
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.129.0
3
+ version: 0.130.1
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -41,9 +41,11 @@ module Samagotchi
41
41
  BUILD_NAME = ".chi-helper-build"
42
42
  OLD_NAME = ".chi-helper-old"
43
43
  # Apps started by launchd get a bare env (no shell rc files), so the
44
- # launch file carries what chi needs to find its gems and state. Only
45
- # these, and only when set: no tokens, no runtime knobs.
44
+ # launch file carries what chi needs to find its gems, state and tools.
45
+ # Only these, and only when set: no tokens, no runtime knobs.
46
46
  ENV_ALLOWLIST = %w[XDG_CONFIG_HOME XDG_STATE_HOME GEM_HOME GEM_PATH RUBYLIB].freeze
47
+ # PATH is separately filtered to absolute dirs and frozen when written,
48
+ # so the helper's chi, workers and execute can find the shell's tools.
47
49
  # Without it Ruby reads stdin as US-ASCII and non-ASCII notes break.
48
50
  LANG = "en_US.UTF-8"
49
51
  # chi broadcast's default triage deadline, and the helper's seconds on
@@ -54,6 +56,17 @@ module Samagotchi
54
56
  # one launch-file section each, in this order.
55
57
  TERMINALS = %w[kitty tmux].freeze
56
58
 
59
+ # PATH dirs the helper can use from launchd's / working directory:
60
+ # keep absolute entries only, in order, once each.
61
+ def self.path_dirs(env)
62
+ env["PATH"].to_s.split(File::PATH_SEPARATOR).select { _1.start_with?("/") }.uniq
63
+ end
64
+
65
+ def self.baked_path(env)
66
+ dirs = path_dirs(env)
67
+ dirs.empty? ? nil : dirs.join(File::PATH_SEPARATOR)
68
+ end
69
+
57
70
  # Runs a command without a shell; [output, success].
58
71
  class Runner
59
72
  def run(argv)
@@ -76,7 +89,8 @@ module Samagotchi
76
89
 
77
90
  # The tmux: settings, or nil while tmux.enabled is off. The binary is
78
91
  # tmux.binary, else the tmux on +env+'s PATH ("" when there is none:
79
- # see #warnings); the socket dir is where tmux puts `-L` sockets
92
+ # only absolute dirs are usable from launchd; see #warnings); the socket
93
+ # dir is where tmux puts `-L` sockets
80
94
  # ($TMUX_TMPDIR when it is a directory, else /tmp, then tmux-<uid>).
81
95
  # Both are frozen at bake time, like the rest of the launch file.
82
96
  def self.tmux_settings(get = Config.method(:get), env: ENV)
@@ -91,8 +105,7 @@ module Samagotchi
91
105
  end
92
106
 
93
107
  def self.path_tmux(env)
94
- env["PATH"].to_s.split(File::PATH_SEPARATOR).reject(&:empty?).map { File.join(_1, "tmux") }
95
- .find { File.file?(_1) && File.executable?(_1) }.to_s
108
+ path_dirs(env).map { File.join(_1, "tmux") }.find { File.file?(_1) && File.executable?(_1) }.to_s
96
109
  end
97
110
 
98
111
  # Each switched-on terminal's settings by name ("kitty" => …).
@@ -148,10 +161,14 @@ module Samagotchi
148
161
  # `gem cleanup`), else this checkout's bin/chi. A section per terminal
149
162
  # switched on (TERMINALS order); the broadcast timeout when given.
150
163
  # The XDG dirs go in absolute (Paths): launchd starts the app in /,
151
- # where a relative one would mean /<dir>.
164
+ # where a relative one would mean /<dir>. PATH is filtered to absolute,
165
+ # deduplicated dirs so chi, its workers and execute find shell tools;
166
+ # it is frozen at bake time.
152
167
  def launch_config
153
168
  env = { "LANG" => LANG }
154
169
  ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
170
+ path = self.class.baked_path(@env)
171
+ env["PATH"] = path if path
155
172
  env.merge!(Paths.absolute_xdg(env: env, set: :key))
156
173
  config = { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
157
174
  TERMINALS.each { |name| config[name] = @terminals[name] if @terminals[name] }
@@ -178,22 +195,55 @@ module Samagotchi
178
195
  launch["sources_sha"] != sources_sha || !launch_ok?(launch)
179
196
  end
180
197
 
181
- # The launch file names another chi version or path than this one, or
182
- # other terminal settings or broadcast timeout: refresh_launch_file
183
- # fixes that without a rebuild.
198
+ # The launch file names an older chi version than this one (or none), or
199
+ # other terminal settings, broadcast timeout or a missing bakeable PATH:
200
+ # refresh_launch_file fixes that without a rebuild. A different argv or
201
+ # baked PATH, or a newer version, is intentional: the ruby, chi and PATH
202
+ # the helper runs change only with chi desktop install --force or upgrade.
184
203
  def launch_outdated?
185
204
  launch = read_launch
186
- launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"] ||
205
+ older_version?(launch["version"]) ||
187
206
  TERMINALS.map { launch[_1] } != TERMINALS.map { @terminals[_1] } ||
188
- launch["broadcast_timeout"] != @broadcast_timeout
207
+ launch["broadcast_timeout"] != @broadcast_timeout || path_missing?(launch)
208
+ end
209
+
210
+ # Why chi update can't keep the launch file's ruby or chi: one of them
211
+ # is gone. nil when it runs, or has no usable argv (a refresh writes
212
+ # this chi's then).
213
+ def launch_problem
214
+ argv = usable_argv(read_launch)
215
+ missing = Array(argv).reject { |path| File.exist?(path) }
216
+ return nil if missing.empty?
217
+
218
+ "chi not found at #{missing.join(", ")}: run chi desktop install --force from the shell whose chi it should run"
219
+ end
220
+
221
+ # The chi version the launch file names, nil without one.
222
+ def launch_version = read_launch["version"]
223
+
224
+ # The launch file names a newer chi than this one: an older chi's
225
+ # chi update keeps the helper as it is (no rebuild from older sources).
226
+ def launch_newer?
227
+ Gem::Version.new(launch_version.to_s) > Gem::Version.new(@version)
228
+ rescue ArgumentError
229
+ false
189
230
  end
190
231
 
191
232
  # Rewrite the launch file for this chi and its settings, keeping
192
- # the env it was installed with. The app reads it at each send: no restart.
233
+ # its argv and the env it was installed with; a PATH only when it has
234
+ # none (changing them takes chi desktop install --force or upgrade). A
235
+ # newer version stays. The app reads it at each send.
193
236
  def refresh_launch_file
194
237
  config = launch_config
195
- env = read_launch["env"]
196
- config["env"] = env if env.is_a?(Hash)
238
+ old = read_launch
239
+ config["argv"] = usable_argv(old) || config["argv"]
240
+ config["version"] = old["version"] unless older_version?(old["version"])
241
+ old_env = old["env"]
242
+ if old_env.is_a?(Hash)
243
+ config["env"] = old_env
244
+ path = self.class.baked_path(@env)
245
+ config["env"]["PATH"] = path if config["env"]["PATH"].to_s.empty? && path
246
+ end
197
247
  write_launch_file(config)
198
248
  end
199
249
 
@@ -242,7 +292,9 @@ module Samagotchi
242
292
 
243
293
  # @yield [String] progress lines
244
294
  # @param login [Boolean] also start the app at login
245
- def install(force: false, login: false, &progress)
295
+ # @param keep_launch [Boolean] keep the launch file's ruby, chi and PATH
296
+ # (chi update's rebuild) instead of baking this shell's
297
+ def install(force: false, login: false, keep_launch: false, &progress)
246
298
  raise Error, "already installed (#{app_version}); use chi desktop upgrade" if installed? && !force
247
299
 
248
300
  toolchain!
@@ -250,7 +302,7 @@ module Samagotchi
250
302
  progress&.call("building #{APP_NAME} #{@version} (can take a few minutes)…")
251
303
  built = build_app
252
304
  swap_in(built)
253
- write_launch_file
305
+ write_launch_file(keep_launch: keep_launch)
254
306
  register_app
255
307
  self.login = true if login
256
308
  progress&.call("installed #{app_path}")
@@ -258,9 +310,9 @@ module Samagotchi
258
310
  end
259
311
 
260
312
  # Keeps the login item as it was.
261
- def upgrade(&)
313
+ def upgrade(keep_launch: false, &)
262
314
  was_login = installed? && login == "enabled"
263
- install(force: true, login: was_login, &)
315
+ install(force: true, login: was_login, keep_launch: keep_launch, &)
264
316
  end
265
317
 
266
318
  # SMAppService status as the app reports it ("enabled",
@@ -313,6 +365,7 @@ module Samagotchi
313
365
  launch_ok: launch_ok?(launch),
314
366
  stale: stale?,
315
367
  baked_dirs: env.select { |name, _| name.start_with?("XDG_") },
368
+ baked_path: env["PATH"],
316
369
  service: dump.include?(BUNDLE_ID),
317
370
  running: running,
318
371
  host_keys: host_keys
@@ -393,12 +446,39 @@ module Samagotchi
393
446
  {}
394
447
  end
395
448
 
449
+ # This chi is newer than the version the launch file names; a missing or
450
+ # unreadable version counts as older.
451
+ def older_version?(version)
452
+ Gem::Version.new(@version) > Gem::Version.new(version.to_s)
453
+ rescue ArgumentError
454
+ true
455
+ end
456
+
457
+ def usable_argv(launch)
458
+ argv = launch["argv"]
459
+ argv if argv.is_a?(Array) && !argv.empty? && argv.all? { |path| path.is_a?(String) && !path.empty? }
460
+ end
461
+
462
+ def path_missing?(launch)
463
+ return false unless self.class.baked_path(@env)
464
+
465
+ env = launch["env"]
466
+ !env.is_a?(Hash) || env["PATH"].to_s.empty?
467
+ end
468
+
396
469
  def launch_ok?(launch)
397
470
  argv = Array(launch["argv"])
398
471
  !argv.empty? && argv.all? { |path| File.exist?(path) }
399
472
  end
400
473
 
401
- def write_launch_file(config = launch_config)
474
+ def write_launch_file(config = launch_config, keep_launch: false)
475
+ if keep_launch
476
+ old = read_launch
477
+ config["argv"] = usable_argv(old) || config["argv"]
478
+ old_env = old["env"]
479
+ old_path = old_env["PATH"] if old_env.is_a?(Hash)
480
+ config["env"]["PATH"] = old_path unless old_path.to_s.empty?
481
+ end
402
482
  FileUtils.mkdir_p(@support_dir)
403
483
  AtomicFile.write(launch_path, JSON.pretty_generate(config) + "\n")
404
484
  end
@@ -118,11 +118,12 @@ module Samagotchi
118
118
  "#{s[:app_version] || "?"} (up to date: chi #{s[:chi_version]} changed nothing in it)"
119
119
  end
120
120
  launch = s[:launch_argv].empty? ? "(none)" : s[:launch_argv].join(" ")
121
- launch += s[:launch_ok] ? " (ok)" : " (missing: chi update)"
121
+ launch += s[:launch_ok] ? " (ok)" : " (missing: chi desktop install --force)"
122
122
  dirs = s[:baked_dirs].empty? ? "(defaults)" : s[:baked_dirs].map { |name, value| "#{name}=#{value}" }.join(" ")
123
+ path = path_row(s[:baked_path])
123
124
  service = s[:service] ? "registered" : "not registered (try chi desktop upgrade, or log out and back in)"
124
125
  running = s[:running] ? "running" : "not running (open -g \"#{s[:app_path]}\")"
125
- rows = [["Chi Helper", version], ["app", s[:app_path]], ["runs chi", launch], ["state dirs", dirs],
126
+ rows = [["Chi Helper", version], ["app", s[:app_path]], ["runs chi", launch], ["state dirs", dirs], ["PATH", path],
126
127
  ["service", service], ["hotkey", hotkey(s[:hotkey])], ["process", running], ["at login", login(s[:login])]]
127
128
  rows << ["host keys", host_keys(s[:host_keys])] unless Array(s[:host_keys]).empty?
128
129
  @stdout.puts(format_rows(rows))
@@ -153,6 +154,10 @@ module Samagotchi
153
154
  end
154
155
  end
155
156
 
157
+ def path_row(path)
158
+ path.to_s.empty? ? "(none: macOS's default; chi update fills it, chi desktop install --force changes it)" : path
159
+ end
160
+
156
161
  def format_rows(rows)
157
162
  width = rows.map { |label, _| label.length }.max
158
163
  rows.map { |label, value| "#{label.ljust(width)} #{value}" }.join("\n")
@@ -664,6 +664,19 @@ module Samagotchi
664
664
  @turn_state.controller
665
665
  end
666
666
 
667
+ # Whether a message a person sent waits for the running turn's next
668
+ # boundary (run_turn's input_waiting: probe): a task_wait returns on it
669
+ # (PeerView#input_waiting?). False outside a turn, for a turn given no
670
+ # probe, and when the probe raises (warned once a turn; the tool never
671
+ # fails for it).
672
+ def input_waiting?
673
+ probe = @turn_state.input_waiting
674
+ !!probe&.call
675
+ rescue StandardError => e
676
+ Log.warn(:turn, "input_waiting_failed", error: e.class.name, msg: e.message) if @turn_state.input_waiting_failed!
677
+ false
678
+ end
679
+
667
680
  # Cancel the currently running turn, if any.
668
681
  # @param reason [Symbol] cancellation reason
669
682
  # @return [Boolean] whether a cancellation was triggered
@@ -2132,6 +2145,9 @@ module Samagotchi
2132
2145
  # @param pending_input [#call, nil] optional drain proc returning
2133
2146
  # Array<String> of steering messages queued while the turn runs; drained
2134
2147
  # by the agentic loop at iteration boundaries (see KernelLoop#run).
2148
+ # @param input_waiting [#call, nil] optional probe: true while a message
2149
+ # a person sent waits for the next boundary (it peeks, never drains);
2150
+ # a running task_wait returns on it (#input_waiting?). nil: never.
2135
2151
  # @param continue [Boolean] resume the conversation without appending a
2136
2152
  # user prompt (continue after the iteration limit, reminder turns);
2137
2153
  # +prompt+ is ignored and :turn_started carries `continue: true`
@@ -2170,9 +2186,9 @@ module Samagotchi
2170
2186
  # re-raises; a provider error adds error_kind:, retryable:, host: and a
2171
2187
  # one-line summary:.
2172
2188
  def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false,
2173
- origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil, verify: nil)
2189
+ origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil, verify: nil, input_waiting: nil)
2174
2190
  turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
2175
- continue: continue, id: id, verify: verify)
2191
+ continue: continue, id: id, verify: verify, input_waiting: input_waiting)
2176
2192
  if continue
2177
2193
  turn.resumed = resumed
2178
2194
  turn.resume_from = resume_from
@@ -2220,7 +2236,7 @@ module Samagotchi
2220
2236
  # The turn's state before anything can fail it: the session running,
2221
2237
  # the turn flag, its cancel controller and sink. The cross-thread state
2222
2238
  # is set here and cleared in #release_turn only.
2223
- def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil, verify: nil)
2239
+ def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil, verify: nil, input_waiting: nil)
2224
2240
  origin = TurnOrigin.of(origin)
2225
2241
  # A participant's Verify fresh child: every turn is theirs (their
2226
2242
  # author, boundary and cap), whoever started it.
@@ -2240,7 +2256,7 @@ module Samagotchi
2240
2256
  # invalidated recap) while the model is working. A hook's notice goes
2241
2257
  # where the turn's events go (the REPL renders only its sink; a
2242
2258
  # worker's observers carry it to the bridge).
2243
- @turn_state.begin!(controller: effective_controller, sink: on_event)
2259
+ @turn_state.begin!(controller: effective_controller, sink: on_event, input_waiting: input_waiting)
2244
2260
  # Drop a recap already in flight: the turn makes it stale.
2245
2261
  @recap&.invalidate!
2246
2262
  # The gate's context: who queued this turn, and git asked afresh.
@@ -8,7 +8,8 @@ module Samagotchi
8
8
  # The kernel's Tools::Peers, following the current session. cancelled?
9
9
  # is the running turn's cancel, for a tool that waits (delegate_result):
10
10
  # the controller is set from another thread and a tool gets no other
11
- # way to see it.
11
+ # way to see it. input_waiting? is whether a person's message waits for
12
+ # the running turn's next boundary (task_wait returns on it).
12
13
  PeerView = Struct.new(:engine) do
13
14
  def session_id = engine.session&.id
14
15
  def cwd = engine.session&.working_directory
@@ -16,6 +17,7 @@ module Samagotchi
16
17
  def state_dir = engine.peer_state_dir
17
18
  def cancelled? = !!engine.active_cancel_controller&.cancelled?
18
19
  def cancel_reason = engine.active_cancel_controller&.reason
20
+ def input_waiting? = engine.input_waiting?
19
21
  def relay = engine.relay_peer
20
22
  def model_ref = engine.effective_model_ref
21
23
  def thinking = engine.thinking_override
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../config"
4
+ require_relative "../paths"
5
+ require_relative "../memory_paths"
6
+ require_relative "../hooks/loader"
7
+ require_relative "path_resolve"
8
+
9
+ module Samagotchi
10
+ module Guardrails
11
+ # chi's own dirs, the sets the guardrails judge a path or a command's
12
+ # words against, as this process's environment and config.yml name them
13
+ # (read each call). Each surface keeps its set:
14
+ # .protected the config dir, the hooks dir, the approval store's
15
+ # dir, the installed bundles' dir, attached context: an
16
+ # ask only the user may answer (ParentApprovals.chi_files?)
17
+ # .touches .protected and the session repo's git hooks dir: a
18
+ # rule's touches: chi_dirs (Targets#touches_chi?)
19
+ # .self_protect .protected and the whole state dir, resolved: what
20
+ # System 1 never answers for (SelfProtect)
21
+ # .review the state dir, the config dir and a hooks dir
22
+ # configured outside it: what a participant's turn never
23
+ # reads (ReviewPaths)
24
+ # ProtectedPaths' deny/ask lists and ScratchWrites' memories dir are
25
+ # built by the wiring from the same places.
26
+ module ChiDirs
27
+ module_function
28
+
29
+ # chi's config dir, the hooks dir, the approval store's dir, the
30
+ # installed bundles' dir and attached context.
31
+ def protected
32
+ hooks = begin Hooks::Loader.hooks_dir rescue nil end
33
+ bundles = begin MemoryPaths.bundles_dir rescue nil end
34
+ [ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails"), bundles, File.join(Paths.state_dir, "context")]
35
+ .compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
36
+ end
37
+
38
+ # .protected (as ParentApprovals.chi_dirs, the name its callers and
39
+ # specs know) and the git hooks dir of +base+'s repo (+context+).
40
+ def touches(context, base)
41
+ require_relative "parent_approvals"
42
+ ParentApprovals.chi_dirs + [context.hooks_dir(base)].compact
43
+ end
44
+
45
+ # .protected (as ParentApprovals.chi_dirs) and the whole state dir,
46
+ # resolved.
47
+ def self_protect
48
+ require_relative "parent_approvals"
49
+ [*ParentApprovals.chi_dirs, Paths.state_dir]
50
+ .map { |dir| PathResolve.real(dir.to_s.chomp("/")) }.reject(&:empty?).uniq
51
+ end
52
+
53
+ # chi's state dir and config dir, as Paths has them now, and the hooks
54
+ # dir when config.yml puts it outside the config dir (hooks run as the
55
+ # owner: a participant reads none of them).
56
+ def review
57
+ config = File.join(Paths.config_home, Paths::APP_DIR)
58
+ hooks = begin Hooks::Loader.hooks_dir rescue nil end
59
+ hooks = nil if hooks.to_s.empty? || PathResolve.reals(hooks).any? { |real| PathResolve.within?(real, config) }
60
+ [Paths.state_dir, config, hooks].compact
61
+ end
62
+ end
63
+ end
64
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "outside"
4
- require_relative "protected_paths"
4
+ require_relative "path_resolve"
5
5
  require_relative "shell_lex"
6
6
  require_relative "shell_paths"
7
7
  require_relative "shared_git"
@@ -88,8 +88,8 @@ module Samagotchi
88
88
  # repository (and not into root, which may sit inside the main one),
89
89
  # under chi's env or the call's env: (Targets#shell_envs).
90
90
  def names_other_checkout?(targets, root)
91
- real_root = ProtectedPaths.real(root)
92
- others = [*@worktrees.call, @common_dir.call].compact.map { |dir| ProtectedPaths.real(dir) } - [real_root]
91
+ real_root = PathResolve.real(root)
92
+ others = [*@worktrees.call, @common_dir.call].compact.map { |dir| PathResolve.real(dir) } - [real_root]
93
93
  return false if others.empty?
94
94
 
95
95
  commands = ShellLex.simple_commands(ShellLex.lex(targets.command.to_s))
@@ -99,8 +99,8 @@ module Samagotchi
99
99
  walk.step(words).any? do |_word, path|
100
100
  next false unless path
101
101
 
102
- ProtectedPaths.reals(path).any? do |real|
103
- !ShellPaths.within?(real, real_root) && others.any? { |dir| ShellPaths.within?(real, dir) }
102
+ PathResolve.reals(path).any? do |real|
103
+ !PathResolve.under?(real, real_root) && others.any? { |dir| PathResolve.under?(real, dir) }
104
104
  end
105
105
  end
106
106
  end
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pathname"
4
- require "tmpdir"
5
- require_relative "protected_paths"
4
+ require_relative "path_resolve"
6
5
  require_relative "../memory_bundle/index_sync"
7
6
 
8
7
  module Samagotchi
@@ -20,17 +19,18 @@ module Samagotchi
20
19
  # A path whose landing place resolving can't tell (.resolve: a dangling
21
20
  # symlink, a file with another hard link) is always outside.
22
21
  module Outside
23
- TMP_DIRS = %w[/tmp /var/tmp].freeze
22
+ TMP_DIRS = PathResolve::TMP_DIRS
24
23
 
25
24
  # @param path [String] absolute
26
25
  # @param root [String] the session's repo root (or cwd)
27
26
  def self.outside?(path, root:)
28
27
  reals = resolve(path) or return true
29
- session = ProtectedPaths.real(root)
28
+ session = PathResolve.real(root)
29
+ tmp = nil
30
30
  reals.any? do |real|
31
31
  next false if within?(real, session)
32
32
  next false if MemoryBundle::IndexSync.memory_scope(path)
33
- next false if tmp_roots.any? { |tmp| within?(real, tmp) && !within?(session, tmp) }
33
+ next false if (tmp ||= session_tmp_roots(session)).any? { |dir| within?(real, dir) }
34
34
 
35
35
  true
36
36
  end
@@ -44,7 +44,7 @@ module Samagotchi
44
44
  # @param root [String] the session's project root
45
45
  def self.inside?(path, root:)
46
46
  reals = resolve(path) or return false
47
- session = ProtectedPaths.real(root)
47
+ session = PathResolve.real(root)
48
48
  reals.all? { |real| within?(real, session) }
49
49
  end
50
50
 
@@ -55,15 +55,16 @@ module Samagotchi
55
55
  # @param root [String] the session's repo root (or cwd)
56
56
  def self.tmp_write?(path, root:)
57
57
  reals = resolve(path) or return false
58
- session = ProtectedPaths.real(root)
59
- reals.all? { |real| tmp_roots.any? { |tmp| within?(real, tmp) && !within?(session, tmp) } }
58
+ session = PathResolve.real(root)
59
+ tmp = session_tmp_roots(session)
60
+ reals.all? { |real| tmp.any? { |dir| within?(real, dir) } }
60
61
  end
61
62
 
62
- # +path+ resolved (ProtectedPaths.reals: the lexical answer and where
63
+ # +path+ resolved (PathResolve.reals: the lexical answer and where
63
64
  # the kernel lands, a `..` after a symlinked dir climbing out of its
64
65
  # target), or nil when that doesn't say where a write lands: the path
65
66
  # is or goes through a dangling symlink (a write would create its
66
- # target; ProtectedPaths.real follows it, the lexical answer stops at
67
+ # target; PathResolve.real follows it, the lexical answer stops at
67
68
  # it, and either way the link's own place doesn't count as where the
68
69
  # file is), or is a file with another hard link (a write would change
69
70
  # that file too). Both outside? and tmp_write? read it, so neither
@@ -71,7 +72,7 @@ module Samagotchi
71
72
  # @param path [String] absolute
72
73
  # @return [Array<String>, nil]
73
74
  def self.resolve(path)
74
- reals = ProtectedPaths.reals(path)
75
+ reals = PathResolve.reals(path)
75
76
  return nil if reals.any? { |real| Pathname.new(real).ascend.any?(&:symlink?) }
76
77
  return nil if reals.any? { |real| File.file?(real) && File.stat(real).nlink > 1 }
77
78
 
@@ -80,16 +81,17 @@ module Samagotchi
80
81
  nil
81
82
  end
82
83
 
83
- # The tmp dirs that exist, resolved.
84
- def self.tmp_roots
85
- [Dir.tmpdir, *TMP_DIRS].filter_map do |dir|
86
- File.realpath(dir) if File.directory?(dir)
87
- rescue SystemCallError
88
- nil
89
- end.uniq
90
- end
84
+ # The tmp dirs that exist, resolved (PathResolve.tmp_roots).
85
+ def self.tmp_roots = PathResolve.tmp_roots
86
+
87
+ # The tmp dirs +session+ (resolved) isn't in (PathResolve.tmp_roots).
88
+ def self.session_tmp_roots(session) = PathResolve.tmp_roots(session: session, roots: tmp_roots)
91
89
 
92
- def self.within?(path, root) = path == root || path.start_with?(File.join(root, ""))
90
+ # Whether +path+ is +root+ or in it (PathResolve.under?): cased alike
91
+ # on a disk that folds case when +root+ exists (resolved, it has its
92
+ # on-disk case), by their exact text when it doesn't (a missing root
93
+ # keeps the case it was typed in).
94
+ def self.within?(path, root) = PathResolve.under?(path, root, fold: File.exist?(root))
93
95
  end
94
96
  end
95
97
  end
@@ -25,7 +25,9 @@ module Samagotchi
25
25
  # read or written (read, write, edit, a plugin's targets: paths), and
26
26
  # on what a command does (execute, and any call whose targets carry
27
27
  # a command: PairCommand, which also refuses chi and chi web on a
28
- # loopback name, background and scheduled jobs, the environment and
28
+ # loopback name, URL openers (open, xdg-open, a browser) and
29
+ # listeners (nc -l, socat …LISTEN, a static HTTP server), background
30
+ # and scheduled jobs, the environment and
29
31
  # credential printers, git settings that run commands, home sweeps
30
32
  # and links to them, and a program named by a $var or a glob); a
31
33
  # write or edit of git's hooks (GIT_HOOKS);
@@ -78,8 +80,17 @@ module Samagotchi
78
80
  GIT_CONFIG = "a participant's turn can't change git's global config or set what makes git run a command " \
79
81
  "(core.hooksPath, core.fsmonitor, core.sshCommand, core.pager, core.editor, ! aliases)"
80
82
  GIT_HOOKS = "a participant's turn can't write git's hooks (.git/hooks): git runs them in the owner's name"
81
- CHI_WEB = "a participant's turn can't call chi web on this machine: on loopback it answers as the owner " \
82
- "(sessions, shares, answers)"
83
+ # chi web on loopback asks the owner's token too (Web::App#token_gate,
84
+ # LAN mode); this text check stays as defense in depth: a loopback-only
85
+ # chi web still answers loopback as the owner.
86
+ CHI_WEB = "a participant's turn can't call chi web on this machine: it is the owner's (sessions, shares, answers)"
87
+ # A page opened in the owner's browser carries the owner's cookies
88
+ # (chi web's on 127.0.0.1 or the LAN address among them) to whatever
89
+ # it points at; a port listening here could catch them.
90
+ OPENER = "a participant's turn can't open a URL or a file in the owner's apps: a browser sends that page the " \
91
+ "owner's cookies"
92
+ LISTENER = "a participant's turn can't listen on a port: what it serves on this machine could catch the " \
93
+ "owner's browser cookies"
83
94
  VERB = "a participant's turn runs only a program named plainly: no $, $(…) or glob in a command's name"
84
95
  UNREADABLE = "a participant's turn runs only commands chi can read: split this command, or write the file " \
85
96
  "with the write tool"