samagotchi 0.133.0 → 0.134.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -1
  3. data/docs/cli.md +11 -0
  4. data/docs/configuration.md +8 -4
  5. data/docs/guardrails.md +102 -13
  6. data/docs/internals/background-tasks.md +1 -0
  7. data/docs/internals/participants.md +6 -5
  8. data/docs/internals/tool-guardrails.md +2 -0
  9. data/docs/plugins.md +2 -1
  10. data/docs/sessions.md +3 -2
  11. data/docs/testing.md +7 -2
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/chi_home.rb +12 -2
  14. data/lib/samagotchi/desktop/macos.rb +19 -4
  15. data/lib/samagotchi/engine.rb +13 -0
  16. data/lib/samagotchi/guardrails/implicit_writers.rb +143 -0
  17. data/lib/samagotchi/guardrails/pair_boundary.rb +6 -4
  18. data/lib/samagotchi/guardrails/pair_command.rb +493 -60
  19. data/lib/samagotchi/guardrails/parent_approvals.rb +1 -1
  20. data/lib/samagotchi/guardrails/read_only_shell.rb +34 -2
  21. data/lib/samagotchi/guardrails/shell_git_dirs.rb +6 -1
  22. data/lib/samagotchi/guardrails/shell_lex.rb +23 -0
  23. data/lib/samagotchi/guardrails/shell_paths.rb +3 -2
  24. data/lib/samagotchi/guardrails/targets.rb +8 -3
  25. data/lib/samagotchi/home_command.rb +22 -6
  26. data/lib/samagotchi/home_doctor.rb +36 -3
  27. data/lib/samagotchi/home_notice.rb +8 -1
  28. data/lib/samagotchi/hooks/loader.rb +5 -0
  29. data/lib/samagotchi/log_line.rb +12 -0
  30. data/lib/samagotchi/project_env.rb +3 -11
  31. data/lib/samagotchi/self_report.rb +3 -1
  32. data/lib/samagotchi/system_one/self_protect.rb +58 -2
  33. data/lib/samagotchi/testing/waiting.rb +13 -0
  34. data/lib/samagotchi/toolkit_binding.rb +16 -0
  35. data/lib/samagotchi/update_command.rb +7 -3
  36. data/lib/samagotchi/update_hint.rb +6 -1
  37. data/lib/samagotchi/version.rb +1 -1
  38. data/lib/samagotchi/web/public/answer_fold.js +262 -0
  39. data/lib/samagotchi/web/public/answer_refs.js +2 -0
  40. data/lib/samagotchi/web/public/app.js +18 -1
  41. data/lib/samagotchi/web/public/cards_place.js +5 -2
  42. data/lib/samagotchi/web/public/chip_popover.js +73 -11
  43. data/lib/samagotchi/web/public/claim_link.js +4 -2
  44. data/lib/samagotchi/web/public/composer.css +3 -1
  45. data/lib/samagotchi/web/public/history_reveal.js +23 -0
  46. data/lib/samagotchi/web/public/index.html +44 -28
  47. data/lib/samagotchi/web/public/llm_context_chip.js +5 -13
  48. data/lib/samagotchi/web/public/refs.js +6 -7
  49. data/lib/samagotchi/web/public/review_start.js +1 -1
  50. data/lib/samagotchi/web/public/start_page.js +4 -4
  51. data/lib/samagotchi/web/public/start_settings.js +29 -18
  52. data/lib/samagotchi/web/public/start_toolkit.js +5 -4
  53. data/lib/samagotchi/web/public/timing.js +5 -1
  54. data/lib/samagotchi/web/public/turn_html.js +12 -6
  55. data/lib/samagotchi/web/public/turn_range.js +32 -0
  56. data/lib/samagotchi/web/public/turn_view.js +21 -11
  57. data/lib/samagotchi/web/public/verdict_card.js +5 -13
  58. data/lib/samagotchi/web/public/verify_target.js +7 -10
  59. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b85b6006a6570eb4145c39cf37ad004a56332b33240353c78d15529d532cd71
4
- data.tar.gz: 6c194c4f62179e781a121e1e35eb9f8407795cb6e2b519ffa3ff917d0b6096eb
3
+ metadata.gz: 1400c2aa3cceb5a84b6aa0d5610021b5e5893415afa37f98e8c99efe291de436
4
+ data.tar.gz: eda23134194381fd10460ba0b026ae10aa5a8aaf674fbc9dc4504359b1acb86d
5
5
  SHA512:
6
- metadata.gz: 777ad65e1223ba4f99058f5ecdd3d89621af1b1f2f61ff02b21a0760607a92d18d179f9da49dae7c257a07ac107fdcfadd2c0bcf14c8555a25a1570f07c8729e
7
- data.tar.gz: e5115ba552ca82ac8a1f6d79f917d50d970bcd7202866a3ac30a15a89541ea94b61ef0a2ff61f0594f996c2a874aa01e7fe1cb3c4cf1551357f017417416c217
6
+ metadata.gz: 7029cb713c02b4c3099ade377bc57618ec6467c2a1f04a4b21c7ebd3fbb6e34705e1ad54fe547672becdc6553824b785c96222e310690fd707a12c0fb057e852
7
+ data.tar.gz: e7400f091254ba9ffdf04f14160e8d6816cbe264016f8be8558c9da091edcc33bf34ed93652c83b6647be728a349b31631db49fc26e483280deda75a7658e5e3
data/CHANGELOG.md CHANGED
@@ -7,6 +7,72 @@ and commands may change between minor versions. How releases are made:
7
7
  [docs/releasing.md](docs/releasing.md).
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.134.0] - 2026-10-30
11
+
12
+ ### Added
13
+
14
+ - Web: a long answer (over ~25 lines or ~2,000 characters) gets a fold row above it: one click folds it to its first
15
+ line and its length, another opens it. Your folds are kept per browser across reloads; a jump into a folded answer
16
+ (a claim link, the working set, "View check") opens it.
17
+
18
+ ### Fixed
19
+
20
+ - Web: the start page's ⚙ and 🧰 buttons say whether their panel is open (`aria-expanded`) to screen readers.
21
+ - Web: an open sheet or popover is placed again when the window resizes (a rotation, a phone's keyboard): its header
22
+ no longer ends up above the screen, and a sheet turns back into a popover on a wide window.
23
+ - Web: on a phone the ⚙ panel's sheet may take 85 % of the screen, so with "Apply and budget" open its closing note is
24
+ no longer cut off at the bottom edge.
25
+ - Web: the ⚙ and 🧰 panels keep the keyboard focus while open (Tab wraps inside, `aria-modal`), their override chips
26
+ say whether the panel is open (`aria-expanded`), and Esc or × gives focus back to the chip that opened it.
27
+ - Web: "default" on the ⚙ panel's Context row now clears a set apply rule and budget too: the chip read "ctx apply
28
+ payoff · budget 64000" while the row showed default, and the new session got them.
29
+ - Web: on a touch screen a tapped row or link in the ⚙ and 🧰 panels (and the ⚙ and 🧰 buttons) no longer keeps its
30
+ hover tint.
31
+ - Web: a sheet's and a popover's × is a 36 px tap target on a touch screen (it was 22x16 px).
32
+ - Web: the Stop sheet's "+N more" no longer sits 74 px to the right (it shared a class with the ⚙ panel's row).
33
+ - Pair turns: a read-only mention of mise's config (`find . -name mise.toml && make`) no longer asks the owner; a
34
+ delete, move or write of it still does, and `.tool-versions` (mise reads it too) is covered the same way.
35
+ - `chi home doctor` checks the desktop helper only when the helper's chi reads this chi's config dir: a run with
36
+ `XDG_CONFIG_HOME` elsewhere no longer reports the installed helper against a home it never boots.
37
+ - The new-version hint ("… the desktop helper can be updated: chi update") follows the same rule: a run with
38
+ `XDG_CONFIG_HOME` elsewhere no longer hints about the installed helper.
39
+ - `chi home set` under `bundle exec` refuses to record its own Ruby, whose gem path is the bundle's, instead of making
40
+ it the home every launch boots (`--ruby` another Ruby still works).
41
+ - `chi update` under `bundle exec`, run on another Ruby than the chi home's, hands over to the home's chi outside the
42
+ bundle instead of loading the bundle into it (which failed noisily).
43
+ - `chi self`'s home row says when this chi runs on the home's Ruby and gems but isn't the home's newest chi (`this chi
44
+ is X`), as the terminal line and `chi home doctor` do.
45
+ - `chi home doctor` reports a worker that couldn't boot the chi home's chi (the debug log's newest `boot_failed` line
46
+ since the home was set, unless a worker started after it), with what to run.
47
+ - Toolkit suggestions (`toolkit.suggest.mode: on`): a suggestion that times out or gets no answer from System 1 now
48
+ says so in a notice (the session runs its default kit) and a timeout logs a warning, instead of falling back silently.
49
+ - Pair turns: a participant can no longer write git's config at all, so no setting makes git run a command later in
50
+ your name (a hook, a filter or driver, a helper, an include): `git config` only reads, parsed strictly (an unknown or
51
+ abbreviated option counts as a write), and `git -c`, `--config-env`, a clone's `-c`/`--config`, `--template` and
52
+ `GIT_TEMPLATE_DIR` are denied, as are a submodule's hooks (`.git/modules/*/hooks`) and the hook installers
53
+ (`pre-commit install`, `husky`, `lefthook install`). A command git runs from its text (`rebase --exec`,
54
+ `submodule foreach`) is checked like any command. Reading git's config (`git config --get`, `-l`) is no longer
55
+ refused.
56
+ - Pair turns: a command that writes outside the project with no word naming where now asks you like any other
57
+ outside write: global installs (`npm -g`, `pip install`, `gem install`, `brew`, `cargo`/`go install`, `pipx`,
58
+ `uv tool`), user config (`npm config set` writes `~/.npmrc`, `gh config set`), `defaults write`, `launchctl`; and
59
+ so does a shell or interpreter reading its script from stdin (`… | sh`, `| bash -x`, `curl … | bash`,
60
+ `sh < run.sh`). Global installs are recognised in any spelling (`npm i --location global`, `python3.12 -m pip`,
61
+ `npm_config_global=true`, `npm link`), with more tools (`deno`, `dotnet tool`, `corepack`, `bundle config set`).
62
+ - System 1 no longer auto-allows a command that names the memories folder and then climbs out of it by a script's
63
+ own calls (`python3 -c '…os.chdir(os.pardir)…'`, `ruby -e '…File.dirname(Dir.pwd)…'`) or runs a script it can't
64
+ see (a file, stdin, `| sh -x`, `python3.12 x.py`, `make`, `awk -f`): it could write `config.yml`. You decide
65
+ those; plain memory edits stay eligible.
66
+ - Guardrails: a `task_create` whose `env:` points git at another config (`GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`,
67
+ `GIT_CONFIG_KEY_n`/`VALUE_n`) is no longer read-only, and a pair turn's command that sets one is denied like
68
+ `git -c`.
69
+ - Pair turns: more spellings that hid a git config write or a command are caught: a git global option that takes a
70
+ value (`--attr-source`, `--shallow-file`, one git adds later), `git-config` and the other dashed builtins, a script
71
+ after a shell's options (`sh -c -- S`, `bash -c -x S`) or in an option cluster (`ruby -we`, `perl -le`, `node -pe`,
72
+ `python3 -Ic`), and a command handed through an environment variable (`GIT_EDITOR`, `GIT_SSH_COMMAND`,
73
+ `GIT_EXTERNAL_DIFF`, `GIT_PAGER`, `EDITOR`, …). `git maintenance register/start`, `git lfs install` and
74
+ `scalar register` now ask you.
75
+
10
76
  ## [0.133.0] - 2026-10-30
11
77
 
12
78
  ### Added
@@ -5077,7 +5143,8 @@ and long-lived sessions.
5077
5143
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
5078
5144
  hotkey panel that send selected text or the clipboard to your sessions.
5079
5145
 
5080
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.133.0...HEAD
5146
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.134.0...HEAD
5147
+ [0.134.0]: https://github.com/dm1try/samagotchi/compare/v0.133.0...v0.134.0
5081
5148
  [0.133.0]: https://github.com/dm1try/samagotchi/compare/v0.132.0...v0.133.0
5082
5149
  [0.132.0]: https://github.com/dm1try/samagotchi/compare/v0.131.0...v0.132.0
5083
5150
  [0.131.0]: https://github.com/dm1try/samagotchi/compare/v0.130.1...v0.131.0
data/docs/cli.md CHANGED
@@ -1026,6 +1026,17 @@ touch screen), and so does each code block of a rendered answer. An answer
1026
1026
  copies its Markdown source, not the rendered text; a code block copies just
1027
1027
  its code; a prompt copies the text as you typed it.
1028
1028
 
1029
+ A long answer (over 25 lines or 2,000 characters of its text) can be folded
1030
+ by hand: a slim "▾ N lines" row sits just above it once its turn has ended.
1031
+ Click it and the answer folds to one row, "▸ its first line · N lines" (the
1032
+ first line of what a hook presented in its place, else its first heading,
1033
+ else its first line); click again to unfold. A delegate report's reply
1034
+ folds the same way. Folding is this browser's choice, kept per session in
1035
+ localStorage (`chi_answer_fold:<session id>`, the last 200 folded answers;
1036
+ the keys of sessions gone from the list are dropped when the list loads);
1037
+ other viewers and the participant page are not affected. A jump into a
1038
+ folded answer (a claim link, say) unfolds it for that page load.
1039
+
1029
1040
  ### Web views
1030
1041
 
1031
1042
  `web.view` picks how the page draws a turn: `stage` (the default: the
@@ -1375,7 +1375,8 @@ launches it. It is recorded in `~/.config/samagotchi/home.json` (`$XDG_CONFIG_HO
1375
1375
  - **Setting it.** `chi home set` records the Ruby your `chi` runs on and its gem path (a chruby shell's
1376
1376
  `GEM_HOME`/`GEM_PATH` are part of it: that is where its chi lives); `chi home set --ruby PATH` records another
1377
1377
  Ruby with its own gem dirs. Either needs samagotchi installed there (`gem install samagotchi` with that Ruby
1378
- first). Without a home.json, the first `chi bootstrap` or `chi update` records one, once (from an installed chi,
1378
+ first). Under `bundle exec`, `chi home set` refuses to record its own Ruby (the bundle's gem path is no home);
1379
+ `--ruby` another Ruby still works. Without a home.json, the first `chi bootstrap` or `chi update` records one, once (from an installed chi,
1379
1380
  never under `bundle exec`): of the desktop helper's Ruby (when its newest chi is at least the one running) and the
1380
1381
  Ruby chi runs on, the one with the newer chi. Nothing else ever overwrites it.
1381
1382
  `chi home` shows it; `chi home doctor` checks it (see [CLI](cli.md#commands)).
@@ -1396,7 +1397,7 @@ launches it. It is recorded in `~/.config/samagotchi/home.json` (`$XDG_CONFIG_HO
1396
1397
  `home_older_than_spawner`), but chi web's restart runs the newest chi it knows outside the home instead
1397
1398
  (`restart_home_older`): its flags may be ones the older chi doesn't know.
1398
1399
  - **A home that still fails at boot** (a broken install): the worker writes one `boot_failed` line to the debug log
1399
- (`log.file`) and exits, rather than leave the spawner waiting.
1400
+ (`log.file`) and exits, rather than leave the spawner waiting; `chi home doctor` reports the newest one.
1400
1401
  - Each machine has its own home (a remote's is that machine's).
1401
1402
 
1402
1403
  ### Checking it: chi home doctor
@@ -1408,7 +1409,8 @@ fails (a launch can't boot what it should), 0 otherwise. Run it in a project fol
1408
1409
  |---|---|---|
1409
1410
  | the home | `fail`: its Ruby is gone (a Ruby upgrade) or has no samagotchi | `gem install samagotchi` with the new Ruby, then `chi home set` from it |
1410
1411
  | this chi | `warn`: the chi you ran is another Ruby's, or another version, than the home's (`ok` for a source checkout, which starts its own code) | `chi home set` to make this one the home, or run the home's |
1411
- | the desktop helper | `fail`: its launch file's Ruby or chi is gone; `warn`: it runs `[ruby, chi]` (installed before the home), boots the home as it was before a `chi home set`, or sets `GEM_HOME`/`GEM_PATH` | `chi desktop install --force` / `chi desktop upgrade` |
1412
+ | the last worker boot | `fail`: the newest worker boot in the debug log since the home was set is a `boot_failed` line (the home's Ruby couldn't load chi), not a worker's `start`; nothing without a log file | `gem install samagotchi` with the home's Ruby (or `chi home set` another), then `chi sessions restart ID` |
1413
+ | the desktop helper (when installed, and its chi reads this chi's config dir: none with `XDG_CONFIG_HOME` elsewhere) | `fail`: its launch file's Ruby or chi is gone; `warn`: it runs `[ruby, chi]` (installed before the home), boots the home as it was before a `chi home set`, or sets `GEM_HOME`/`GEM_PATH` | `chi desktop install --force` / `chi desktop upgrade` |
1412
1414
  | sessions | `warn`: sessions last saved by a newer chi than the home's (their workers run without what it added) | `chi update` |
1413
1415
  | the tools' `chi` | `warn`: `$XDG_STATE_HOME/samagotchi/bin/chi` boots another chi than the home | the next worker start rewrites it (`chi sessions restart ID` for a running session) |
1414
1416
  | this project's Gemfile.lock | `warn`: the nearest Gemfile.lock up to the git root bundles samagotchi (`bundle exec chi` isn't supported) | `bundle remove samagotchi` there; run chi itself |
@@ -1530,7 +1532,9 @@ toolkit:
1530
1532
  suggested`, and the turn's first request declares it; the notice reads
1531
1533
  `toolkit: web (suggested, p=0.71) · /toolkit to change`. Anything else (the default, a low-confidence or unknown
1532
1534
  pick, `undecided`, no answer, a failing System 1, which warns once as `[samagotchi:toolkit]`) keeps the default
1533
- kit. A Stop during the wait (or before it, even with the answer in) ends the turn before its first request, and
1535
+ kit. No answer in time or none at all says so in a warn notice, `toolkit: full (no suggestion: System 1 took over
1536
+ 5000 ms) · /toolkit to change` (or `System 1 gave no answer`), and a timeout logs a `toolkit_suggest_timeout`
1537
+ warning: a slow System 1 host wants a larger `guardrails.system_one.timeout_ms`. A Stop during the wait (or before it, even with the answer in) ends the turn before its first request, and
1534
1538
  nothing switches. After an `undecided`, a later prompt within the defer limits may switch the session; that notice
1535
1539
  says `from this turn`, and that request re-reads the whole prompt (system prompt, tools and conversation, which
1536
1540
  the defer limits keep small) without `/toolkit`'s cache confirmation. `/toolkit` or `ctx.use_toolkit` marks the
data/docs/guardrails.md CHANGED
@@ -433,16 +433,46 @@ message:
433
433
  project env ([Project env](sessions.md#project-env)); a command that runs
434
434
  `mise` or `direnv` asks the owner (`ENV_LOADER`: they load the project's env
435
435
  files, and `.envrc`, `mise.local.toml` and its kin are secrets like `.env`),
436
- and so does a write (by a file tool or a command) to mise's config
437
- (`MISE_CONFIG`: the owner's next worker start runs its `[env]`);
436
+ and so does a write (by a file tool or a command) to mise's config, its
437
+ `.tool-versions` included (`MISE_CONFIG`: the owner's next worker start runs
438
+ its `[env]` and installs what it pins; a command asks when a part of it that
439
+ names one may write: `rm`, `mv`, `find … -delete` and `find … | xargs rm`
440
+ do, a read-only part such as `find . -name mise.toml && make` doesn't, the
441
+ list split at `;`, `&&`, `||` and newlines outside `( … )`; mise's own
442
+ writes, `mise use`/`mise set`, ask as runs of mise; a config that changes
443
+ without being named, by `git checkout`, `git stash pop`, `tar x`, `patch` or
444
+ `cp -r dir/. .`, isn't asked about: the owner's next restart runs it);
438
445
  and a command still
439
446
  running at the limit is killed, never handed over as a background task;
440
- - git settings that make git run a command (`core.hooksPath`,
441
- `core.fsmonitor`, `core.sshCommand`, `core.pager`, `core.editor`, a `!`
442
- alias) by `git config` or `git -c`, and `git config --global`/`--system`;
447
+ - git config writes, whatever the key (any may make git run a command later
448
+ in the owner's name: hooks, `core.fsmonitor`, a filter, diff or merge
449
+ driver, a credential or gpg helper, `hook.<name>.command`, an include): a
450
+ pair turn's `git config` only reads, parsed strictly
451
+ (`PairCommand#git_config_read?`: `--get`, `--get-all`, `--get-regexp`,
452
+ `--get-urlmatch`, `--get-color(bool)`, `-l`/`--list`, `get`, `list` or a lone
453
+ key, with `--show-origin`, `--show-scope`, `--name-only`, `-z`, `--type=…`,
454
+ `--default`, `--file F` and the like, every word accounted for; any other
455
+ option, an abbreviation git would take (`--glob`, `--comm`), a short form
456
+ (`-t`) or a word too many is a write). Denied too: `git -c`, `--config-env`,
457
+ a clone's, init's or submodule's `-c`/`--config`, an assignment of
458
+ `GIT_CONFIG_*` or `GIT_TEMPLATE_DIR`, `git` under an alias, `git config` after
459
+ `xargs` or in `find -exec`, and an interpreter's script naming `git config`
460
+ or `git -c`, behind any of git's own options (`--attr-source X`,
461
+ `--shallow-file X`; one chi doesn't know is read both with and without the
462
+ next word as its value, `PairCommand.git_command_starts`), and by a
463
+ builtin's dashed name (`git-config`, `…/git-core/git-submodule foreach`:
464
+ judged as `git config`, `git submodule`). A command git runs now from its text (`git submodule foreach`,
465
+ `rebase --exec`, `bisect run`, a `filter-branch` filter, `--upload-pack`,
466
+ `--receive-pack`, `--extcmd`, an `ext::` URL) is read as a command of its
467
+ own and asks as an outside write. A `.gitattributes` write isn't asked
468
+ about: it only selects a driver, and defining one is what's denied;
443
469
  and writing git's hooks: a `write` or `edit` of a path in a `.git/hooks`
444
- folder (as given or as it resolves), or a command that isn't read-only and
445
- names one (`printf … > .git/hooks/pre-commit`, `cp x .git/hooks/`);
470
+ folder, a submodule's `.git/modules/<name>/hooks` too (as given or as it
471
+ resolves), or a command whose part that names one isn't read-only
472
+ (`printf … > .git/hooks/pre-commit`, `cp x .git/hooks/`), and installing
473
+ them: `git init`/`clone --template`, and the hook installers
474
+ (`PairCommand::HOOK_INSTALLERS`: `pre-commit install`, `husky`, `lefthook
475
+ install`, `simple-git-hooks`, `overcommit --install`);
446
476
  - running chi, by any wrapper (`bin/chi`, `bundle exec chi`, `ruby … bin/chi`,
447
477
  `env … chi`, `watch`, `npx`, `sh -c 'chi …'`, `$(chi …)`), in an assignment's
448
478
  value or an alias, a function, after `-exec`/`-c`/`-e`, but `chi --version` and
@@ -482,7 +512,12 @@ message:
482
512
  A script a command hands on is read too (`Guardrails::PairCommand`): one a
483
513
  shell runs (`sh -c`, `eval`, a heredoc or here-string) as a command of its
484
514
  own, one another interpreter runs (`ruby -e`, `python - <<EOF`) by its text
485
- (chi, secrets, the environment, detaching). A heredoc written as data (`cat`,
515
+ (chi, secrets, the environment, detaching). So is a command handed through a
516
+ variable a program runs through a shell (`PairCommand::COMMAND_ENV`:
517
+ `GIT_EDITOR`, `GIT_SEQUENCE_EDITOR`, `EDITOR`, `VISUAL`, `GIT_EXTERNAL_DIFF`,
518
+ `GIT_SSH_COMMAND`, `GIT_SSH`, `GIT_PROXY_COMMAND`, `GIT_PAGER`, `PAGER`,
519
+ `MANPAGER`, `GIT_ASKPASS`, `SSH_ASKPASS`), as a prefix, `env`'s or `export`'s:
520
+ read as `sh -c` would run it, for the refusals and for an outside write. A heredoc written as data (`cat`,
486
521
  `tee`, `git commit -F -`) is not looked into: where it goes is checked. A
487
522
  command the checks can't read is refused, never let through on a text match.
488
523
 
@@ -502,7 +537,44 @@ rule lowers it), so it comes even in auto mode. A command fails closed
502
537
  (`~/oth*`), `git -C`'s folder and the paths in a script it hands on (`sh -c`
503
538
  read as a command, `python -c`/`ruby -e`/a heredoc by the paths in its text)
504
539
  among them. The program's own word doesn't count (`/usr/bin/sed`), nor does
505
- `PATH=…`, nor a message's heredoc (`git commit -m "$(cat <<'EOF' … EOF)"`).
540
+ `PATH=…`, nor a message's heredoc (`git commit -m "$(cat <<'EOF' … EOF)"`);
541
+ and it runs no program that writes to its own place outside with no word
542
+ naming it (`Guardrails::ImplicitWriters`, examples rather than a closed
543
+ set: `npm`/`pnpm`/`bun`/`deno` global in any spelling (`-g`, `--global=true`,
544
+ `--location global`, `npm_config_global=true`), `npm`/`yarn`/`bun link`,
545
+ `npm config set`/`login` (`~/.npmrc`), `yarn global`, `pip install`/`pip
546
+ config` by any interpreter (`python3.12 -m pip`, `pypy3 -mpip`; with or
547
+ without `--user`: its site-packages is wherever its Python is; a project
548
+ venv's `.venv/bin/pip` and `-t`/`--target`/`--prefix` don't count), `uv pip
549
+ install --system`, `gem install`, `bundle config set` without `--local`,
550
+ `brew` but its reading verbs, `defaults write`, `cargo install`, `go
551
+ install`/`go env -w`, `pipx`, `uv tool`/`uv python install`, `dotnet tool
552
+ install -g`, `corepack enable`, `rustup`/`pyenv`/`rbenv`/`asdf`/`volta`/`nvm`
553
+ installs and globals, `code --install-extension`, `gh auth`/`config
554
+ set`/`extension install`, `launchctl` but its reading verbs, `git maintenance register`/`start`, `git
555
+ lfs install`, `scalar register`/`clone`; a project
556
+ install such as `npm install` or `bundle install` and build caches don't
557
+ count), and no shell or interpreter that reads its script from stdin: one
558
+ on the receiving end of a pipe whatever its options (`… | sh`, `| bash -x`,
559
+ `| sh -e`, `| python3 -c 'exec(input())'`), `< file`, `bash -s`, `python3 -`,
560
+ or with no script at all: chi can't see that script. A shell's `-e`, `-x`
561
+ and `-o …` are flags, only `-c` (or a cluster holding it, `-ec`, `-cx`)
562
+ hands it a script: the first word after its options (`sh -c -- S`, `bash -c
563
+ -x S`); an interpreter's own code flag does (`python -c`, `ruby -e`,
564
+ `node -e`/`-p`/`--eval=`, `perl -e`/`-E`, `php -r`), alone or in a cluster
565
+ (`ruby -we`, `perl -le`, `node -pe`, `python3 -Ic`, `python3 -c'…'`;
566
+ `PairCommand.cluster_code_at`: a letter that takes the rest of the word,
567
+ `ruby -rset`, holds no code). A script file it names (`sh
568
+ run.sh`, `ruby x.rb`) is not read.
569
+
570
+ Accepted gaps (an accident guard for a trusted team): a script file the turn
571
+ wrote and then runs; inline scripts read only by their text for paths
572
+ (`find … -exec sh -c '…'`, `flock … sh -c`, `awk 'BEGIN{system(…)}'`,
573
+ `deno eval`); a `GIT_CONFIG_*` set by `printf -v` or `read` and exported
574
+ (it affects that command only); a variable outside `COMMAND_ENV` whose
575
+ value a program runs (`LESSOPEN`, a tool's own `*_EDITOR`); `npx -y`,
576
+ `npm exec`, `pnpm dlx`, `uvx`: they fetch into a cache, which doesn't count,
577
+ and run the package as in-project work.
506
578
 
507
579
  Anything else asks: `rm -rf ~/x`, `sed -i … ~/x`, `curl -o ~/x`, `dd of=~/x`,
508
580
  `touch`/`mkdir` outside, `perl -pi … ~/x`, `cd .. && make`, `cd && make`, and
@@ -629,7 +701,8 @@ repo is in count as outside.
629
701
  `touches: chi_dirs` (shell tools) matches a command that names a path in chi's
630
702
  own folders: the config folder (`config.yml`, `memories/`), the hooks folder,
631
703
  the approval store, installed bundles (`memories/.bundles`), the session repo's
632
- git hooks (`core.hooksPath` honoured) or any `.git/hooks` outside a tmp folder.
704
+ git hooks (`core.hooksPath` honoured) or any `.git/hooks` (a submodule's `.git/modules/<name>/hooks` too)
705
+ outside a tmp folder.
633
706
  Each word is read as a path against the folder the command is in by then
634
707
  (`cd`, `pushd`/`popd` and `( … )` followed), with `~`, `$HOME` and
635
708
  `$XDG_CONFIG_HOME`/`$XDG_STATE_HOME` expanded and symlinks resolved. So
@@ -657,7 +730,9 @@ only `run list|view`, `pr list|view|diff|checks`, `issue list|view` and
657
730
  at a word's start), an env prefix, `sh -c`, `xargs`, `eval`, `sudo` and the
658
731
  like make a command not read-only, as does a `task_create` `env:` that sets `BASH_ENV`, `ENV`, `CDPATH`, `IFS`,
659
732
  `SHELLOPTS`, `BASHOPTS`, `BASH_FUNC_…`, `LD_PRELOAD`, `LD_AUDIT`, `DYLD_INSERT_LIBRARIES`, `GIT_EXTERNAL_DIFF`,
660
- `GIT_CONFIG_PARAMETERS` or `GIT_CONFIG_COUNT` (they change what its words run; `PATH` doesn't count). It errs
733
+ or a git config variable (`GIT_CONFIG`, `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`, `GIT_CONFIG_PARAMETERS`,
734
+ `GIT_CONFIG_COUNT`, `GIT_CONFIG_KEY_n`/`VALUE_n`: `ShellGitDirs::CONFIG_ENV`) (they change what its words run; `PATH`
735
+ doesn't count; a pair turn's command that assigns a git config variable is denied like `git -c`). It errs
661
736
  towards asking.
662
737
 
663
738
  A tool name may be a glob, so one rule covers a plugin's tools (an MCP server's,
@@ -1061,8 +1136,22 @@ gate below passes, in this order:
1061
1136
  it knew the dir: a script or heredoc body (`python3 -c '…'`, `cat >> x.md
1062
1137
  <<'EOF'`), a phrase (`echo "- a note"`) or a glob with no `/`. So
1063
1138
  `cd memories; cd ..`, `cd "${PWD%/*}"`, `cd $(dirname $PWD)` or `cd -`
1064
- loses the exemption, and the memories text then refuses it. A script body
1065
- is not looked into beyond its text: an accident guard, not containment.
1139
+ loses the exemption, and the memories text then refuses it. So does a
1140
+ script a shell or interpreter runs that climbs by its own calls
1141
+ (`SelfProtect::SCRIPT_CLIMB`: `os.pardir`, `dirname`, `chdir`, `Dir.pwd`,
1142
+ `Path.parent`, `rindex`, a slice from the end), and one chi can't see: a
1143
+ script file (`cd memories && python3 x.py`) or stdin (`… | python3`,
1144
+ `sh < f`, any interpreter or shell a pipe feeds, `| sh -x` too), by any
1145
+ version's name (`python3.12`, `pypy3`, `Rscript`, `tclsh`), or a program
1146
+ that runs a file (`SelfProtect::FILE_RUNNERS`: `make`, `rake`, `awk -f`,
1147
+ `deno run`, `npm run`). A heredoc only written (`cat >> x.md <<'EOF'`) is
1148
+ data whatever it says. A script body is not looked into beyond its text:
1149
+ an accident guard, not containment (a script can build a path some other
1150
+ way: `p.replace("memories", "config.yml")`, `p[0:len(p)-8]`, `.sub`,
1151
+ `.slice` aren't caught). The price is some harmless commands the user now
1152
+ answers: `python3 scripts/lint.py <memories>/notes.md`, `cat notes.md |
1153
+ python3 -c …` from the memories folder, a climb word written as data by a
1154
+ script (`File.write(".../notes.md", "dirname")`).
1066
1155
  A protected rule
1067
1156
  (`Verdict::PROTECTED_RULES`) may be listed by name and System 1 may answer
1068
1157
  it, but a call that really writes config.yml or the hooks is refused here;
@@ -45,6 +45,7 @@ Behavior:
45
45
  - `execute` and `task_create` both spawn with chi's own Ruby/Bundler setup scrubbed (`TaskRuntime.sanitized_spawn_env`): `RUBYOPT` loses its `-r…bundler/setup` flags (others stay) and `RUBYLIB`, `BUNDLE_GEMFILE`, `BUNDLE_BIN_PATH`, `BUNDLER_VERSION` are unset, so a project's `ruby -e` or `bundle exec` uses its own Gemfile when chi runs under `bundle exec`. `task_create` refuses those keys in the model's `env`; `execute`'s `env` comes from chi's callers (`Builtins`, `!cmd`), not the model, and is set over the scrub. An `execute` handed over at its limit keeps the process it spawned, so the task runs with the same env.
46
46
  - `execute` and `task_create` export chi's own markers into the command, set over the model's `env` (which can't set them: `Error: env key is reserved`): `SAMAGOTCHI_PARENT_SESSION` (the session's id, `chi` without one; a chi started there answers as a parent agent), `SAMAGOTCHI_SESSION_MODEL` (the resolved ref the session runs on, read at each call so it follows `/model`; unset when unknown) `SAMAGOTCHI_SESSION_THINKING` (the session's own thinking level; unset when it has none) and `SAMAGOTCHI_SESSION_REASONING_HISTORY` (its own reasoning_history level, the same way). `chi self` reads the last three for its model, thinking level and reasoning history. The four are `ChildEnv::SESSION_MARKERS` (`TaskRuntime::MARKER_ENV_KEYS`), which a spawned worker unsets (`ChildEnv.unset`, merged into its spawn env). A session's worker never inherits them from whoever started it (a `chi send` run by another session's `execute`): its other commands (a user's `!cmd`, an MCP server, a context command) have none.
47
47
  - They also pass on a set `XDG_STATE_HOME` / `XDG_CONFIG_HOME` made absolute (`Builtins.xdg_absolute`, via `Paths`), so a chi the command runs in another `cwd` (`chi send`) reads this chi's state and config. These aren't reserved: an XDG dir the model sets in `task_create`'s `env` stays its own.
48
+ - Every runner starts from the session's project env, `ProjectEnv.tools_env`: the overlay a spawned worker computed once at boot (`SessionManager.apply_project_env` → `ProjectEnv.compute` → `ProjectEnv.install!`; its folder's `mise env`, and the chi shim's dir first on PATH so `chi` is the chi home's), less `ProjectEnv::TOOLS_DROP` (`RUBYOPT`, `RUBYLIB`, Bundler's: the scrub above sets those). Outside a worker (`ProjectEnv::NONE`) it is `{}`. The sites: `execute` and `task_create` (`Tools::Builtins#project_env`, under the markers and the model's `env`; a participant's turn gets `{}`), the user's `!cmd` (`SessionCommands#shell`) and a context source's command (`ContextFetch#env_for`). MCP servers take `ProjectEnv.current.env` whole under the server's own keys (`bundles/mcp/plugin.rb` `server_env`: no `TOOLS_DROP`, no participant rule). chi's own process env is never changed, and the project env never enters the prompt. User docs: [Project env](../sessions.md#project-env); the launch side: [How chi launches itself](../architecture.md#how-chi-launches-itself).
48
49
 
49
50
  ## Finish wake
50
51
 
@@ -195,11 +195,12 @@ Everything else is `:owner`: `/answer`, `/command`, `/images` (an image a prompt
195
195
  to act: the turn only reads (`Guardrails::ReviewBoundary`), and proposes what the owner applies ([Apply](#apply)).
196
196
  - **`:pair`**, a working session's participant invited to act: the turn acts with the session's tools under the
197
197
  owner's guardrails, its asks go to the owner and offer once only, and `Guardrails::PairBoundary` denies secrets,
198
- chi itself and chi web on loopback, background and scheduled jobs, the environment and credential printers, git's
199
- hooks, owner-voiced tools and the owner's task outputs, and the model's own question, and asks before a command
200
- that makes a link and before a write that may land outside the project, failing closed (a session at or above
201
- home has none: every write asks) ([guardrails](../guardrails.md#a-participants-pair-boundary)). No Apply: it
202
- acted itself.
198
+ chi itself and chi web on loopback, background and scheduled jobs, the environment and credential printers, git
199
+ config writes (reads only) and git's hooks, owner-voiced tools and the owner's task outputs, and the model's own question, and asks before a command
200
+ that makes a link and before a write that may land outside the project, failing closed (a global install or a
201
+ user config write that names no path, `Guardrails::ImplicitWriters`, and a script read from stdin count; a
202
+ session at or above home has none: every write asks) ([guardrails](../guardrails.md#a-participants-pair-boundary)).
203
+ No Apply: it acted itself.
203
204
 
204
205
  `ParticipantMode.of(session, features:)` is the session's: what a participant invited now gets. No session is
205
206
  `:review`. A worker names `pair_turns` (`Delivery::PAIR_FEATURE`) when it runs pair turns; one without it runs
@@ -50,6 +50,8 @@ Configuration (config.yml `execute.*` keys and CLI flags too, as for `read`):
50
50
  - `SAMAGOTCHI_EXECUTE_TIMEOUT_SEC` (`execute.timeout_sec`, default `120`): what a command still running then gets is `execute.on_timeout`.
51
51
  - `SAMAGOTCHI_EXECUTE_ON_TIMEOUT` (`execute.on_timeout`, default `task`): `task` hands the command over as a background task, not killed; the result starts `Still running at the 120s limit: moved to a background task, not killed.` (`Execute::HANDED_OVER`), then its `task_id:` and the tails of stdout and stderr so far, and `task_wait` waits for it. `kill` stops it and answers `Error: command timed out after …s` (`Execute::TIMEOUT_PREFIX`) with the hint to use `task_create`. The `!cmd` bang always kills. See [Background tasks](background-tasks.md).
52
52
 
53
+ The env a command runs in (the session's project env, `ProjectEnv.tools_env`, then the Ruby/Bundler scrub and chi's markers) is the same for `execute`, `task_create`, `!cmd` and context sources: see [Background tasks](background-tasks.md).
54
+
53
55
  Implementation note:
54
56
 
55
57
  - Shared logic lives in `lib/samagotchi/tools/output_guardrails.rb` and is used by `read` and `execute`, and by `edit` (its env switches) and `delegate_wait` (the head+tail preview of a child's long reply).
data/docs/plugins.md CHANGED
@@ -1310,7 +1310,8 @@ bundle.shutdown
1310
1310
  value, or raises `Samagotchi::Testing::Waiting::TimedOut` ("timed out after 5 s waiting for <what>", plus
1311
1311
  `; last saw: <value>` from the `seen:` callable). `join!(thread, what)` and `pop!(queue, what)` are
1312
1312
  `Thread#join` and `Queue#pop` with that deadline and message; `wait_until { }` returns the block's last value
1313
- instead of raising, for a wait whose timeout is fine; `process_gone?(pid)`. Every deadline is multiplied by
1313
+ instead of raising, for a wait whose timeout is fine; `process_gone?(pid)`; `time_bound(seconds, below: nil)`, a
1314
+ wall-clock upper bound scaled the same way and kept under `below:` (the unscaled deadline it tells apart from). Every deadline is multiplied by
1314
1315
  `SPEC_TIME_SCALE` (default 1, at least 1; chi's CI sets 2), so a slow runner gets headroom.
1315
1316
  - **Model notes:** `sandbox.model_notes_for(model_id, small: nil, cwd: nil)` lists the model notes chi would load
1316
1317
  into the prompt for a bare model id (`deepseek/deepseek-v4.1-flash`, no host prefix), from the sandbox's
data/docs/sessions.md CHANGED
@@ -396,8 +396,9 @@ mise.toml pins Ruby 3.4 gets that `ruby`, `gem` and `bundle` in its tools while
396
396
  - **Pair turns** (a participant's, see [Participants](internals/participants.md)) run their `execute` without the
397
397
  project env. MCP servers are the session's, started once in the project env, so a pair turn's MCP calls do use
398
398
  it. A participant's command that runs `mise` or `direnv`, and any write to mise's config (`mise.toml`,
399
- `.mise.toml`, `mise.<env>.toml`, `.config/mise.toml`, `.config/mise/…`, `mise/` or `.mise/config*.toml`: your
400
- next worker start runs it, and its `[env]` can run commands), asks you first. `.envrc` and mise's local files
399
+ `.mise.toml`, `mise.<env>.toml`, `.config/mise.toml`, `.config/mise/…`, `mise/` or `.mise/config*.toml`, and
400
+ `.tool-versions`: your next worker start runs it, and its `[env]` can run commands), a delete or move of one
401
+ included, asks you first; a read-only mention (`find . -name mise.toml`) doesn't. `.envrc` and mise's local files
401
402
  (`mise.local.toml`, `mise.<env>.local.toml`, dotted or not, and `config.local.toml` / `config.<env>.local.toml` in
402
403
  `.config/mise/`, `mise/`, `.mise/`) are secrets to them, as `.env` is.
403
404
  - **What tools still drop**: every `execute`, task and `!cmd` takes no `RUBYOPT`, `RUBYLIB`, `BUNDLE_GEMFILE`,
data/docs/testing.md CHANGED
@@ -95,6 +95,9 @@ run, CI) and too long on a fast one. The contract a new spec follows:
95
95
  4. **"Nothing happens" is shown by counting passes** (the worker's loop rounds, spec/support/worker_loop_rounds.rb;
96
96
  a class's ticks), not by a pause.
97
97
  5. **A wall-clock bound** only as "not the full N s", with at least 5× headroom measured on the slow (x86) laptop.
98
+ Write it `be < time_bound(3, below: 5)`: the bound scaled like a helper's deadline, and kept under the unscaled
99
+ deadline it tells apart from (here a 5 s read timeout), so the check still fails on the full wait at any scale. A
100
+ cost bound with nothing to tell apart from ("no quadratic blow-up") takes no `below:`.
98
101
 
99
102
  The helpers are the bundle test kit's (`Samagotchi::Testing::Waiting`, [plugins.md](plugins.md#testing-a-bundle-experimental)),
100
103
  so core and bundle specs share one copy; `SpecWaiting` (spec/support/waiting.rb) includes them in every example
@@ -107,6 +110,7 @@ group and also answers them as module calls (`SpecWaiting.pop!`, for a stubbed l
107
110
  same deadline and message. A gate a stubbed lib thread waits on takes `timeout: 30`: it only ends a hang.
108
111
  - `wait_until(timeout:) { … }` returns the block's last value (falsy at the deadline): for a value the example
109
112
  checks after, or a `wait-ok` wait.
113
+ - `time_bound(seconds, below: nil)`: a wall-clock upper bound, scaled and kept under `below:` (rule 5).
110
114
  - `eventually(timeout: 5) { expect(…) }` (spec only) retries until the expectations hold, then re-raises the last
111
115
  failure prefixed `still failing after 5 s:`, so the matcher's own expected/got is in the message.
112
116
 
@@ -129,8 +133,8 @@ Every example also runs under a 120 s limit (`spec_helper`'s `around`; `integrat
129
133
  limit, `0` turns it off (a debugger session). The limit is not scaled.
130
134
 
131
135
  **The cops.** `Samagotchi/SpecSleep` (an untagged `sleep` in a spec) and `Samagotchi/SilentWait` (a `wait_until`
132
- whose value is dropped, an example's last line included, `expect(wait_until { … }).to be(true)`, or an untagged
133
- `Timeout.timeout`) live in `rubocop/` and run in `rake lint`, so CI fails on them. RSpec counts `wait_for`,
136
+ whose value is dropped, an example's last line included, `expect(wait_until { … }).to be(true)`, an untagged
137
+ `Timeout.timeout`, or a statement `thread.join(N)`, whose N is unscaled and whose nil is dropped: use `join!`) live in `rubocop/` and run in `rake lint`, so CI fails on them. RSpec counts `wait_for`,
134
138
  `eventually`, `join!` and `pop!` as an example's expectation (`RSpec/NoExpectationExample`), so a wait can be an
135
139
  example's last line. What the cops can't see, `script/affected-specs` hints at (`timing:`, below).
136
140
 
@@ -184,6 +188,7 @@ any other file requires it, so a spec file run alone still finds its requires. E
184
188
  | `fake_provider_server.rb` | `FakeProviderServer`: an in-process HTTP provider replaying spec/fixtures/providers, recording requests |
185
189
  | `fake_tailscale.rb` | A fake `tailscale` for `Web::Tunnel::Tailscale` (status, serve, lock from JSON files; argv.log) |
186
190
  | `git_repo.rb` | *helper*: `git_repo`, a temp folder with `git init`, removed after the example |
191
+ | `lock_holder.rb` | *helper*: `spawn_lock_holder(session_dir)`, a stand-in worker process holding a session's owner lock, which says so on a pipe |
187
192
  | `integration_server.rb` | *helper*: the live model server `:integration` examples use, from `SAMAGOTCHI_INTEGRATION_*` ([Integration specs](#integration-specs)) |
188
193
  | `mcp_bundle.rb` | The shipped mcp bundle's path and the fake MCP server its specs start (`MCP_FAKE_COMMAND`, no RubyGems) |
189
194
  | `no_console.rb` | Loaded first (`ruby -r`) by spawned bin/chi children: `IO.console` is nil, so output never wraps at a terminal's width |
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.133.0
3
+ version: 0.134.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:
@@ -42,6 +42,15 @@ module Samagotchi
42
42
  class Invalid < StandardError
43
43
  end
44
44
 
45
+ # A record of this process under Bundler (`bundle exec chi home set`)
46
+ # would name the bundle's Ruby and gems as the home every launch boots.
47
+ class UnderBundler < StandardError
48
+ def initialize(msg = "under Bundler (bundle exec) this chi's gem path is the bundle's, not a chi home: " \
49
+ "run chi itself, or name another Ruby with --ruby")
50
+ super
51
+ end
52
+ end
53
+
45
54
  # home.json's fields. +gem_path+ is what `Gem.paths=` takes (dirs joined
46
55
  # by File::PATH_SEPARATOR); +mise+ an absolute path or nil.
47
56
  Record = Data.define(:ruby, :gem_home, :gem_path, :mise, :set_at, :set_by_version) do
@@ -274,8 +283,9 @@ module Samagotchi
274
283
  nil
275
284
  end
276
285
 
277
- # The test UpdateCommand's @bundler makes: a first record from a
278
- # `bundle exec` would name the bundle's gems.
286
+ # Whether this process runs under Bundler (`bundle exec`), where a
287
+ # record of it would name the bundle's gems: record_first!, chi home
288
+ # set (UnderBundler) and chi update's @bundler.
279
289
  def under_bundler?(env = ENV) = !!(defined?(::Bundler) || !env["BUNDLE_GEMFILE"].to_s.empty?)
280
290
 
281
291
  # mise's absolute path: on +env+'s PATH, else in MISE_DIRS; nil without.
@@ -259,6 +259,24 @@ module Samagotchi
259
259
  "the desktop helper's launch file sets #{baked.join(", ")} for its sessions' tools: chi desktop upgrade"
260
260
  end
261
261
 
262
+ # The config dir the helper's chi reads (its home.json, config.yml):
263
+ # the launch file's XDG_CONFIG_HOME when baked, else ~/.config.
264
+ # @return [String]
265
+ def launch_config_home
266
+ baked = read_launch["env"]
267
+ dir = baked["XDG_CONFIG_HOME"] if baked.is_a?(Hash)
268
+ # Spelled out: Paths.config_home's ~ is this process's HOME, not @env's.
269
+ dir.to_s.empty? ? File.join(@env["HOME"] || Dir.home, ".config") : dir
270
+ end
271
+
272
+ # Whether the helper's chi reads this chi's config dir (home.json,
273
+ # config.yml): false for a run with XDG_CONFIG_HOME elsewhere (a spec,
274
+ # a smoke run), whose home and bundles the helper never sees, though
275
+ # its launch file sits under the real ~/Library.
276
+ def reads_this_config?
277
+ File.expand_path(launch_config_home) == Paths.config_home(env: @env)
278
+ end
279
+
262
280
  # The chi version the launch file names, nil without one.
263
281
  def launch_version = read_launch["version"]
264
282
 
@@ -316,10 +334,7 @@ module Samagotchi
316
334
  # reads: the launch file's XDG_CONFIG_HOME when baked; disabled hosts
317
335
  # are left out. @return [Array<Hash>] name:, hosts:, state:
318
336
  def host_keys
319
- baked = read_launch["env"]
320
- dir = baked["XDG_CONFIG_HOME"] if baked.is_a?(Hash)
321
- # Spelled out: Paths.config_home's ~ is this process's HOME, not @env's.
322
- config_env = { "XDG_CONFIG_HOME" => dir.to_s.empty? ? File.join(@env["HOME"] || Dir.home, ".config") : dir }
337
+ config_env = { "XDG_CONFIG_HOME" => launch_config_home }
323
338
  hosts = ConfigFile.hosts_config(env: config_env).values.select(&:api_key_env).group_by(&:api_key_env)
324
339
  hosts.map do |name, entries|
325
340
  state = if HostKeys.from_keychain(name, env: @env) then :keychain
@@ -3044,6 +3044,15 @@ module Samagotchi
3044
3044
  return if pending.nil?
3045
3045
 
3046
3046
  outcome, judged = pending.wait(turn.controller)
3047
+ if outcome == :timeout
3048
+ # The session starts on its default without a word otherwise: a
3049
+ # slow System 1 host (a local one) is the usual cause.
3050
+ timeout_ms = SystemOne::Client.configured_timeout_ms
3051
+ Log.warn(:toolkit, "toolkit_suggest_timeout", session_id: turn.session.id, timeout_ms: timeout_ms,
3052
+ reason: "System 1 gave no answer within guardrails.system_one.timeout_ms")
3053
+ @toolkit_binding.fell_back(turn.session, "System 1 took over #{timeout_ms} ms")
3054
+ return
3055
+ end
3047
3056
  unless outcome == :answered
3048
3057
  Log.info(:toolkit, "toolkit_suggest_#{outcome}", session_id: turn.session.id)
3049
3058
  return
@@ -3070,6 +3079,10 @@ module Samagotchi
3070
3079
  deferred: ToolkitSuggest.model_reply?(session.messages))
3071
3080
  switched ? "suggested" : "chosen"
3072
3081
  when "defer" then "defer"
3082
+ when "no_judgment"
3083
+ # System 1 failed (its own warn) or answered nothing usable.
3084
+ @toolkit_binding.fell_back(session, "System 1 gave no answer")
3085
+ "default"
3073
3086
  else "default"
3074
3087
  end
3075
3088
  end