samagotchi 0.128.0 → 0.129.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -1
  3. data/docs/architecture.md +12 -5
  4. data/docs/cli.md +2 -1
  5. data/docs/guardrails.md +150 -25
  6. data/docs/internals/participants.md +17 -3
  7. data/docs/internals/web-push.md +4 -1
  8. data/docs/sessions.md +1 -1
  9. data/lib/samagotchi/bridge_client.rb +8 -2
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/guardrail_wiring.rb +6 -5
  12. data/lib/samagotchi/guardrails/child_boundary.rb +23 -13
  13. data/lib/samagotchi/guardrails/outside.rb +54 -5
  14. data/lib/samagotchi/guardrails/pair_boundary.rb +78 -26
  15. data/lib/samagotchi/guardrails/pair_command.rb +261 -66
  16. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -1
  17. data/lib/samagotchi/guardrails/protected_paths.rb +98 -5
  18. data/lib/samagotchi/guardrails/read_only_shell.rb +191 -8
  19. data/lib/samagotchi/guardrails/review_paths.rb +22 -8
  20. data/lib/samagotchi/guardrails/rm_targets.rb +4 -3
  21. data/lib/samagotchi/guardrails/scratch_writes.rb +3 -1
  22. data/lib/samagotchi/guardrails/shell_git_dirs.rb +67 -35
  23. data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
  24. data/lib/samagotchi/guardrails/shell_paths.rb +73 -25
  25. data/lib/samagotchi/guardrails/targets.rb +128 -24
  26. data/lib/samagotchi/session_manager.rb +9 -15
  27. data/lib/samagotchi/system_one/self_protect.rb +108 -16
  28. data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
  29. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  30. data/lib/samagotchi/version.rb +1 -1
  31. data/lib/samagotchi/web/app.rb +9 -7
  32. data/lib/samagotchi/web/public/app.js +8 -1
  33. data/lib/samagotchi/web/public/participant.js +2 -0
  34. data/lib/samagotchi/web/public/verdict_fresh.js +12 -5
  35. data/lib/samagotchi/worker.rb +82 -32
  36. data/lib/samagotchi/worker_idle_exit.rb +17 -0
  37. data/lib/samagotchi/worker_link.rb +13 -5
  38. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9b175cb8468dd1b6dccbb74c3c372b0796aa18126b47034d07d11d6ead1e1e22
4
- data.tar.gz: e6b6ea98bf78a7a43962a70ab6daf3cd35519c7db1cefdae44ad8021d4274b75
3
+ metadata.gz: 91648ee9b8cf9906ded9d6f865f424b10148222e8007516d8d74718a5a4a9c41
4
+ data.tar.gz: 0ca9cd428118fd52e4c2dc3f1b5e1750d6d7f4bf41ca0d039c8769660a2db2e9
5
5
  SHA512:
6
- metadata.gz: adcfe4e020e6829703e1b9e1d2836d53bb3ddc336da1001c598853352eef75a3ca3df25b7e4d07d292bdaf8b413be228e57487a8b570365e2fba4cecd98490f0
7
- data.tar.gz: d4b57b741cd271740cb0826e0ff50845a5213c1de30b28c9477ba1b126e5448511f5b73861a3380ae2a93e4102e41df9d4093b14a193a61dcbdd7ed6894a11cd
6
+ metadata.gz: 1fa243881749c0b856bcb0c40f42937a253f33b0a0904d6145950cbf81c52262bfd3bdcf883389041f06383d7796150d7ee74a74bb9b8a8118d90ac3b4807a1c
7
+ data.tar.gz: dc1b3dafdca94da6af0338e1a76b3df6e914847514f6eeb9e61359de941cc789955e5a4b04f6e04417402fcdc221d460940051dcde3101737694cb080b038e1a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,86 @@ 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.129.0] - 2026-10-28
11
+
12
+ ### Changed
13
+
14
+ - A delegate child's read-only command that writes only into a tmp dir (`sqlite3 -readonly -safe ../main/x.db "…" >
15
+ /tmp/rows`, `cp ../main/x.db /tmp/`, `… | tee /tmp/f`) no longer asks as a change outside its folder
16
+ (`child-boundary`); a tmp path that links back into the repository still asks, as does a target spelt with `$` or
17
+ `~`, or one after a `cd`/`pushd` the shell may not have taken (anything but one existing dir joined by `&&`).
18
+
19
+ ### Fixed
20
+
21
+ - The owner page's Verify fresh button no longer stays disabled ("restart this session to use Verify") after the
22
+ session's worker comes back with the `verify` feature: the cards follow the session's update without a reload.
23
+ - The web's "Couldn't start a fresh check." under a verdict card no longer vanishes at the next redraw (the participant
24
+ page re-creates its cards on every update): the page keeps it until the next attempt.
25
+ - A `write` or `edit` of a dangling symlink (or a path through one) or of a file with another hard link no longer
26
+ counts as in a tmp dir or in the session's repo: a write there lands outside, so the delegate child's
27
+ `child-boundary` and `path: outside_repo` rules ask for it.
28
+ - A `task_create` call's `env:` now counts in the guardrails' path checks: `env: {TMPDIR: <main checkout>}` with
29
+ `echo x > $TMPDIR/a.txt` (or a `HOME`, `GIT_DIR`, `GIT_WORK_TREE` there) asks as the delegate child's
30
+ `child-boundary`, and `rm -rf`, chi-dir and `git: outside_repo` checks read it too. An `env:` setting `BASH_ENV`,
31
+ `ENV`, `CDPATH`, `IFS`, an exported `BASH_FUNC_…`, `LD_PRELOAD` or a git config/diff-program variable makes the
32
+ command not read-only.
33
+ - An exit and a restart asked of one worker at once (an attached terminal's `/exit` with "stop them and exit" while
34
+ chi web's "Restart workers" runs) no longer both hear yes: the first one taken decides, and the other is refused
35
+ ("the worker is already leaving as a plain exit"), so a restart no longer reports "restarted" for a worker that
36
+ left with no successor.
37
+ - A delegate child's `task_create` whose `env:` redefines the shell (`BASH_ENV`, an exported `BASH_FUNC_cat%%`,
38
+ `CDPATH`, …) now asks as `child-boundary` from its own folder too: `cat a.txt` there with a startup file or an
39
+ exported function that writes the main checkout no longer ran unasked.
40
+
41
+ ### Security
42
+
43
+ - A participant's pair turn (`chi share`, a working session) can no longer call this machine's chi web on a loopback
44
+ name (`curl -X POST 127.0.0.1:4567/api/sessions/ID/participants`, `localhost`, `[::1]`, `0.0.0.0`, the host name,
45
+ with chi web's recorded or configured port, by any client or script): a LAN chi web takes a token-less loopback
46
+ request as the owner's, so the command could mint a grant or queue an owner turn. chi web's own auth is unchanged.
47
+ - A pair turn's "a write outside the project always asks the owner" now fails closed. A command runs unasked only when
48
+ it only reads, only writes (`>`, `cp`, `tee`) into the project or a tmp dir, or stays inside the project (it runs
49
+ there and every path it names lands there or in a tmp dir). `rm -rf ~/x`, `sed -i … ~/x`, `curl -o ~/x`,
50
+ `dd of=~/x`, `touch`/`mkdir` outside, `perl -pi`, `python -c "open('/…', 'w')"`, `cd .. && make`, and a command with
51
+ a `$var`, a `$(…)` (but a commit message's heredoc), `xargs` or a `cd` it can't follow now ask. A tmp path is one by
52
+ the delegate child's rule (`/var/tmp` too, not the tmp dir the project is in, never through a dangling symlink or
53
+ onto a file with another hard link).
54
+ - A pair turn in a session whose folder is the home folder or above it asks before every write (the review boundary
55
+ already read nothing there).
56
+ - A pair command that makes a link (`ln`, `cp -s`/`-l`, a script's `symlink`) asks the owner, and one linking to home
57
+ or a folder above a secret is denied: `ln -s ~/.config g && cat g/gh/hosts.yml` passed, as the link didn't exist yet
58
+ when the paths were judged.
59
+ - A pair turn can no longer write git's hooks (`.git/hooks/*`, by `write`/`edit` or a command), schedule a command
60
+ (`crontab`, `at`, `batch`), run a credential printer (`gh auth token`, `git credential fill`, `gcloud … print-access-token`,
61
+ `op`, `aws configure get`, …) or `ps -E`, read the owner's background tasks (`task_wait`, `task_get`, `task_list`), or
62
+ run a program whose name is a `$var`, a `$(…)` or a glob (`bin/ch? …`).
63
+ - A guardrail check no longer cuts a heredoc's body out of the command it then runs (since 0.81.0,
64
+ `ShellLex.code_text` built its result on the caller's string): with the guardrails bundle's `command:` rules, an
65
+ owner's `git commit -m "$(cat <<'EOF' … EOF)"` or `git commit -F - <<EOF` could run with an empty message, and a pair
66
+ turn's `cat > docs/x.md <<EOF … EOF` with an empty body.
67
+ - Guardrails take a path's `..` the way the kernel does: after a symlinked folder it climbs out of the link's target.
68
+ A participant's review turn could read `node_modules/foo/../../.config/gh/hosts.yml` (with `foo` an `npm link` to
69
+ `~/src/foo`) as a project file while the read opened `~/.config/gh/hosts.yml`; a pair turn's secret check, the
70
+ protected paths (`mem/../config.yml` with `mem` a link to chi's memories), `outside_repo`, the delegate child's
71
+ boundary, `touches: chi_dirs` and `rm: outside_tmp` had the same gap. Each now refuses or asks when either reading of
72
+ the path does, and a write through a dangling symlink to `config.yml`, the hooks or `.bundles/` is asked or denied
73
+ like the file it creates.
74
+ - A shell `cd` or `pushd` whose `..` climbs out of a symlinked folder's target now counts as landing in either folder:
75
+ zsh (chi's shell on macOS) and bash chdir to the physical one when the logical one doesn't exist, and `cd -P`
76
+ always does. A pair turn's `cd node_modules/foo/../../.config/gh && cat hosts.yml` (or `cd -P
77
+ node_modules/foo/../.. && echo x > .zshrc`) was allowed while it read or wrote outside the project; `touches:
78
+ chi_dirs`, `git: outside_repo` and the delegate child's boundary read the same cd logically only. Later words are
79
+ now resolved and judged against both folders, and `cd -` leaves the folder unknown.
80
+ - System 1 (`on` mode) no longer auto-allows a command that climbs out of chi's memories folder into its config:
81
+ `cd …/memories; cd ..; echo 'mode: off' >> config.yml`, `cd "${PWD%/*}"` or `cd $(dirname $PWD)` passed its
82
+ own-files check, since only the text was read. The check now resolves the command's words like
83
+ `shell-touches-chi` (cd chains, symlinks, `..`, the call's `env:`), so `cd ~/.config; cd samagotchi; cat
84
+ config.yml` is refused too. A memories command keeps its exemption while its text shows no climb (`..`, `$`,
85
+ backticks, `cd -`) and every word resolves or is data (a `python3 -c` or heredoc body, `echo "- a note"`); a call
86
+ whose `env:` redefines the shell is never auto-allowed.
87
+
88
+ Update with `chi update`, then restart `chi web` and the session workers (`chi sessions restart`).
89
+
10
90
  ## [0.128.0] - 2026-10-28
11
91
 
12
92
  ### Changed
@@ -4810,7 +4890,8 @@ and long-lived sessions.
4810
4890
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
4811
4891
  hotkey panel that send selected text or the clipboard to your sessions.
4812
4892
 
4813
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.128.0...HEAD
4893
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.129.0...HEAD
4894
+ [0.129.0]: https://github.com/dm1try/samagotchi/compare/v0.128.0...v0.129.0
4814
4895
  [0.128.0]: https://github.com/dm1try/samagotchi/compare/v0.127.0...v0.128.0
4815
4896
  [0.127.0]: https://github.com/dm1try/samagotchi/compare/v0.126.0...v0.127.0
4816
4897
  [0.126.0]: https://github.com/dm1try/samagotchi/compare/v0.125.0...v0.126.0
data/docs/architecture.md CHANGED
@@ -120,7 +120,7 @@ terminal renders the events it gets over the Bridge.
120
120
  - Attached (`bin/chi`, `--attach ID`, `--shared [--resume ID]`) → `TerminalUI::AttachLauncher`: no `Engine`
121
121
  and no `OwnerLock`; finds or starts the session's worker and runs `TerminalUI::AttachedLoop`
122
122
  as a client of its Bridge (`BridgeClient#follow`, `post_turn`, `post_command`, `cancel`, `answer`,
123
- `dismiss_question`). The worker runs in the session's `working_directory`, so `!cmd` and
123
+ `dismiss_question`; opening the SSE connection answers `BridgeClient::StreamOpen::Opened`, `Refused` or `Unreachable`). The worker runs in the session's `working_directory`, so `!cmd` and
124
124
  the tools don't depend on where the terminal attached from.
125
125
  - `bin/chi web` → builds `Web::Server` (Rack+WEBrick on `127.0.0.1:4567`, `--port`/`SAMAGOTCHI_WEB_PORT`, `--open`;
126
126
  `web.host: lan` adds a LAN address, `--new-token` replaces its access token).
@@ -173,6 +173,8 @@ agent logic and can be used without any terminal rendering; the UI is a thin lay
173
173
  but `RelineSeam` (prepended to `Reline::LineEditor`) sends its drawing to the
174
174
  `Screen`. The saved history and cards in a join or resync are rendered by
175
175
  `TerminalUI::JoinReplay` with the loop's screen, view, link, gap clock and renderer.
176
+ The loop's concerns live in mixins under `terminal_ui/attached_loop/`: `AttachedLoop::Composer`, `Questions`,
177
+ `Queueing`, `Sending` and `ExitCommands` (the `/exit` ask's reply text is `TerminalUI::ExitLines`).
176
178
  With input from a pipe (`chi -p X </dev/null`), `TerminalUI::OwnTurns` keeps this
177
179
  run's own prompts until their turns end; `#run` distinguishes an empty answer from a
178
180
  prompt this terminal removed before it ran (both exit 1), as well as chi's other outcomes.
@@ -376,7 +378,10 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
376
378
  the event log held (`WorkerIdleExit#hold_for_request`): `200 {status: "exiting"}` and it leaves
377
379
  like an idle exit, or `409 {status: "held", reason:}` with `turn_running`, `input_queued`,
378
380
  `continue_offered`, `client_connected` (a stream not named by the asker), `reminders`,
379
- `background_tasks` (with `tasks: [{id, command}]`: the running tasks a finish wake watches) or `starting`.
381
+ `background_tasks` (with `tasks: [{id, command}]`: the running tasks a finish wake watches), `starting`, or
382
+ `leaving_exit` / `leaving_delete` / `leaving_restart` when another client's exit of another kind is already taken
383
+ (`Worker#claim_exit`, claimed before a stop-all; the words are `WorkerIdleExit::LEAVING_WORDS`). A second exit of
384
+ the kind already taken is answered `200` as that one.
380
385
  - `GET /session/:id/state` — `session_state_snapshot` (JSON).
381
386
  - `GET /session/:id/stats` — `Engine#stats_snapshot` for attached `/stats`: the metrics, with the context window and prompt profile asked from the server before the first turn.
382
387
  - `GET /session/:id/snapshot` — the snapshot frame's content as one request (the web server renders
@@ -506,7 +511,8 @@ for client discovery, with the Bridge's secret (see above).
506
511
  reading `bridge.json` or probing a port itself: `WorkerLink.resolve(session_dir, unlink:, owner:)` answers a typed
507
512
  outcome. The rule is the owner lock plus the sidecar's `pid`; there is no port probe (a port can be reused by another
508
513
  session's Bridge, a free lock is the kernel's proof the worker died). In order: no lock holder, or a chi REPL's: a
509
- sidecar is `Stale(:lock_free)` (removed with `unlink: true`, only if it is still the file that was read), else `None`;
514
+ sidecar is `Stale(:lock_free)` (removed with `unlink: true`, only if it is still the file that was read and the lock,
515
+ read again just before, is still free: a worker that took it meanwhile is resolved as that worker), else `None`;
510
516
  a holder with no kind yet: `Starting`; a leaving worker: `Leaving` (marked before its Bridge stops, on every way out:
511
517
  `Worker#teardown` marks it too, with `TEARDOWN_LEAVE_WAIT`); a worker whose sidecar names no port or another
512
518
  pid than the holder's (a worker that has not written its own yet): `Starting`, `stuck` once it has held the lock
@@ -565,7 +571,8 @@ it holds with `update.notify: off`; a child that prints no rows fails with its o
565
571
  done install refreshes the hub's installed version (`SessionHub#refresh_installed`) before its `done`.
566
572
  Worker restarts target every worker older than the version installed now (refreshed the same way)
567
573
  and publish per-session results. Only one of an install, a worker restart pass and a web restart runs
568
- at a time (409 `update_running`). `POST /api/update/restart_web` (409 `restart_unavailable` without a
574
+ at a time (409 `update_running`). `POST /api/update/restart_workers` (`handle_restart_workers`) starts the worker pass
575
+ by itself. `POST /api/update/restart_web` (409 `restart_unavailable` without a
569
576
  chi wrapper to exec) schedules WEBrick shutdown only after its response is sent; after the hub and
570
577
  listener stop, `Server` execs the RubyGems chi wrapper with every flag chi web was started with, as
571
578
  `RunOptions`' parser recorded them (`RunOptions.web_restart_argv`), but the one-shot `--open` and
@@ -618,7 +625,7 @@ model: [internals/remote-sessions.md](internals/remote-sessions.md).
618
625
  |---|---|---|---|---|
619
626
  | `GET /api/sessions/resolve?id=` | owner | an id or a unique prefix | 200 `{id}` | 404 `not_found`; 400 `ambiguous_id` with `ids` |
620
627
  | `GET /api/sessions/summaries` | owner | `dir` (a folder: its project; none: every project), `cwd`, `live=1`, `archived=1`, `tests=0`, `sort`, `order`, `limit` | 200: the rows `chi sessions list` prints (`SessionManager.session_summaries`: `status`, `live`, `busy`, `owner`, `recap`, `ctx_pct`, `waiting`, `waiting_id`, `title`, `preview`, …) | 400 `invalid_dir`, `invalid_cwd` |
621
- | `GET /api/projects?dir=` | owner | optional page-scope folder | 200: project rows with counts, roots, default/current marks; no `dir`: no current project | 400 `invalid_dir`; 503 `no_hub` |
628
+ | `GET /api/projects?dir=` | owner | optional page-scope folder | 200: project rows (`Web::ProjectList`: the hub's projects plus the start project) with counts, roots, default/current marks; no `dir`: no current project | 400 `invalid_dir`; 503 `no_hub` |
622
629
  | `POST /api/sessions` (more fields) | owner | `toolkit`, `tools`, `no_tools`, `parent_answers`; `client_id` (`cli:send`/`cli:answer`: the first line is checked as chi send checks it); `check_vision: true` (images follow) | 201 adds `model_warning`, `parent_answers_warning` (a level looser than config.yml's, capped) and `first_turn_id` (the id the prompt it starts with gets for its turn; null for an `idle` create) | 400 `invalid_toolkit`, `invalid_parent_answers`, `invalid_continues` (beside `continues`); 403 `parent_answers_refused`, `parent_continue_refused`; 422 `no_vision` |
623
630
  | `POST /api/sessions/:id/turn` (more fields) | owner | `client_id` as above; `check_vision: true` with `images` | as before | 403 `parent_answers_refused`, `parent_continue_refused`; 422 `no_vision` |
624
631
  | `POST /api/sessions/:id/wait/prepare` | owner | `question_id` (absent: the pending one; `null`: none), `turn`, `created`, `busy` (+ `queue`) | 200 `{session_id, cursor, baseline: {messages, question_id, last_turn}, own_turn, known_turn, running, live, settled, last_turn_id}` (`WaitStart`) | 409 `busy`, `waiting` (`SendCommand::Busy`'s words, and its fields: `question`, `parent_strategy`) |
data/docs/cli.md CHANGED
@@ -1210,7 +1210,8 @@ pills there (a review turn refuses `/` lines).
1210
1210
 
1211
1211
  After the presets, both pages have a built-in **Verify**: the quote, then
1212
1212
  an ask to check the claim against the code and end on a line `Verdict:
1213
- confirmed|refuted|unclear — <reason>`. On your page a selection in a turn
1213
+ confirmed|refuted|unclear — <reason>`. A session whose worker predates Verify (started by an older chi) refuses it (409
1214
+ `verify_unavailable`); a participant's page disables Verify and Verify fresh with a hint to restart the session. On your page a selection in a turn
1214
1215
  also offers **Copy link**: a link to that turn and quote for a participant's
1215
1216
  browser (the one their review link was opened in), which opens their page
1216
1217
  scrolled to the quote and highlights it; with no one invited, a link to it
data/docs/guardrails.md CHANGED
@@ -274,7 +274,8 @@ sub-agent](sub-agent.md)) tell them to use `chi answer` only.
274
274
 
275
275
  ## Protected paths
276
276
 
277
- Built in, for `write`, `edit` and `memory_write` (symlinks resolved):
277
+ Built in, for `write`, `edit` and `memory_write` (symlinks resolved; see
278
+ [How paths resolve](#how-paths-resolve)):
278
279
 
279
280
  - deny: the approval store's dir, installed bundles (`memories/.bundles/`),
280
281
  and [attached context](context.md) sources (`<state dir>/context/`: a
@@ -305,7 +306,21 @@ else the parent's checkout). Rule `child-boundary`, source `core`; it asks when
305
306
  overwriting a branch other than its own.
306
307
 
307
308
  Read-only commands (`git -C ../app log`, `cat ../app/x`) and paths elsewhere
308
- (`/usr/bin`, `~/.gem`) never ask. The answer is once or for the child's
309
+ (`/usr/bin`, `~/.gem`) never ask, nor does a read-only command that writes only
310
+ into a tmp dir (`/tmp`, `/var/tmp`, `$TMPDIR`): its `>` / `>>` targets, `cp`'s
311
+ destination (the last argument, or `-t` / `--target-directory`) and `tee`'s
312
+ files, resolved against its `cwd:` and any `cd` or `pushd` before them, as in
313
+ `sqlite3 -readonly -safe ../app/x.db "select 1" > /tmp/rows` or
314
+ `cp ../app/x.db /tmp/x.db`. A target that links back out of the tmp dir (a
315
+ symlink, a dangling one, a file with another hard link), `cp -l`/`-s`, `mv`,
316
+ `&>`, `>|` and `>&file` still ask, as does a target with a `$` or `~` (the
317
+ call's `env:` may set it), a `popd`, and a `cd`/`pushd` that isn't one existing
318
+ dir spelt `/…`, `.` or `..` joined by `&&` only (it may fail or be skipped and
319
+ leave the shell elsewhere); so does every tmp write when the
320
+ child's folder is itself in that tmp dir. A `task_create` call's paths resolve under chi's env and
321
+ under its own `env:` (its `$TMPDIR`, `$HOME`, `GIT_DIR`, …): either one naming another checkout asks. An `env:` that
322
+ redefines the shell (`BASH_ENV`, `BASH_FUNC_cat%%`, `CDPATH`, …, as under `skip_read_only` below) always asks, even
323
+ for `cat a.txt` in the child's own folder: what it runs is in no word of the command. The answer is once or for the child's
309
324
  session only: a repo or rule answer would hold in every worktree, for every
310
325
  child, so a stored repo or rule approval doesn't let it through either. Its
311
326
  ask goes before the rules, so with the guardrails bundle in strict mode the
@@ -337,7 +352,9 @@ participant's turn may call only the `review` kit's tools:
337
352
  `.pypirc`, ssh keys (`id_rsa`, `id_ed25519`, …), `*.tfstate`,
338
353
  `secrets.yml`/`.json`, `.docker/config.json`, `.kube/config` and any config
339
354
  under `.git` (a submodule's too). The path is checked as given and as it
340
- resolves, so a symlink in the project to `~/.ssh` is `~/.ssh`. A session
355
+ resolves, so a symlink in the project to `~/.ssh` is `~/.ssh`, and
356
+ `node_modules/foo/../../.config/gh/hosts.yml` with `foo` a link to `~/src/foo`
357
+ is `~/.config/gh/hosts.yml` ([How paths resolve](#how-paths-resolve)). A session
341
358
  whose folder is the home folder or above it reads nothing.
342
359
  - `memory_read` with `scope: project` (a read without a scope would fall back
343
360
  to the system memories). The prompt's preloaded memories and the memory
@@ -391,31 +408,56 @@ message:
391
408
  each word resolved as `touches: chi_dirs` resolves it; a glob that matches a
392
409
  secret or whose fixed part leads into a denied folder (`~/.ss?/id_*`,
393
410
  `~/.config/*/config.yml`, `.en?`), a `$var` word whose fixed part does
394
- (`~/.ss${d}h`), and a text match for the rest;
411
+ (`~/.ss${d}h`), and a text match for the rest. A prefix that comes through a
412
+ variable (`x=~/.s; cat ${x}sh/config`) isn't followed: such a command isn't
413
+ denied, it asks (its `$var` word and the assignment's path place it outside
414
+ the project);
395
415
  - sweeping a folder that holds secrets: `find`, `tar`, `cp`, `rsync`, `scp`,
396
416
  `zip`, `ditto`, `grep`/`rg`/`ag` and the like given home or any folder above
397
- a denied one;
398
- - the environment and the keychain: `env` or `printenv` alone, `set`,
399
- `export`, `declare` or `typeset` that list, `launchctl getenv`, `ps e`,
400
- `security`, `/proc/*/environ`. On top of it, a pair turn's `execute` runs
417
+ a denied one, and linking to one (`ln -s ~/.config g`, `ln -s ../.. up`);
418
+ - the environment, the keychain and stored credentials: `env` or `printenv`
419
+ alone, `set`, `export`, `declare` or `typeset` that list, `launchctl
420
+ getenv`, `ps e`/`ps -E`, `security`, `/proc/*/environ`, and the commands
421
+ that print a credential (`gh auth token`, `git credential fill` and the
422
+ `git credential-*`/`docker-credential-*` helpers, `gcloud … print-access-token`,
423
+ `op`, `aws configure get`/`export-credentials`, `az account
424
+ get-access-token`, `heroku auth:token`, `kubectl config view --raw`;
425
+ `PairCommand::CREDENTIAL_PRINTERS`). On top of it, a pair turn's `execute` runs
401
426
  without every variable whose name matches `KEY|TOKEN|SECRET|PASS|CREDENTIAL|AUTH`
402
427
  (`PairBoundary::SECRET_ENV_NAME`, case-insensitive), and a command still
403
428
  running at the limit is killed, never handed over as a background task;
404
429
  - git settings that make git run a command (`core.hooksPath`,
405
430
  `core.fsmonitor`, `core.sshCommand`, `core.pager`, `core.editor`, a `!`
406
431
  alias) by `git config` or `git -c`, and `git config --global`/`--system`;
432
+ and writing git's hooks: a `write` or `edit` of a path in a `.git/hooks`
433
+ folder (as given or as it resolves), or a command that isn't read-only and
434
+ names one (`printf … > .git/hooks/pre-commit`, `cp x .git/hooks/`);
407
435
  - running chi, by any wrapper (`bin/chi`, `bundle exec chi`, `ruby … bin/chi`,
408
436
  `env … chi`, `watch`, `npx`, `sh -c 'chi …'`, `$(chi …)`), in an assignment's
409
437
  value or an alias, a function, after `-exec`/`-c`/`-e`, but `chi --version` and
410
438
  `chi --help`: its commands act in the owner's name (shares, caps, sends,
411
439
  answers, sessions);
440
+ - calling chi web on this machine: a command whose text names a loopback name
441
+ (`localhost` and its subdomains, `127.x`, `0.0.0.0`, `[::1]`, `::ffff:127.x`,
442
+ `0:PORT`) or this machine's host name together with chi web's port (the one
443
+ the running chi web recorded in `web.json`, config's `web.port`, 4567), by
444
+ `curl`, `wget`, `nc`, `/dev/tcp`, a script's URL or anything else: a loopback
445
+ request without a token is the owner on a LAN chi web
446
+ (`PairBoundary::CHI_WEB`). A Bridge answers only with its secret, which sits
447
+ in chi's state dir;
448
+ - a program whose name the shell expands: a `$var`, a `$(…)` or a glob in a
449
+ command's name (`bin/ch? …`, `e=ch; ${e}i …`, `$CMD x`), whatever it would
450
+ turn out to be (`PairBoundary::VERB`);
412
451
  - leaving something running: `&` at a command's end, `nohup`, `setsid`,
413
- `disown`, `screen -d…`, `tmux new -d`;
452
+ `disown`, `screen -d…`, `tmux new -d`, and scheduling it: `crontab`, `at`,
453
+ `batch`;
414
454
  - owner-voiced tools, which act in the owner's name beyond the turn:
415
455
  `delegate` and any `delegate_*` tool, `register_reminder`,
416
456
  `cancel_reminder`, `send_note`, `forget_outputs`, `task_create`,
417
457
  `task_stop`, `find_tools` (it loads tools into the session), and
418
- `memory_write` (a memory reaches the owner's later sessions);
458
+ `memory_write` (a memory reaches the owner's later sessions); and the tools
459
+ that read the owner's background tasks and their output, `task_wait`,
460
+ `task_get`, `task_list` (a pair turn starts none);
419
461
  - `ask_user_question`, with the advice to ask in the reply: nobody on the
420
462
  participant's side answers a card.
421
463
 
@@ -426,13 +468,41 @@ own, one another interpreter runs (`ruby -e`, `python - <<EOF`) by its text
426
468
  `tee`, `git commit -F -`) is not looked into: where it goes is checked. A
427
469
  command the checks can't read is refused, never let through on a text match.
428
470
 
429
- And it **asks the owner** before a `write` or `edit` outside the session's
430
- project root, and before a command that writes there (a redirection's target,
431
- `tee`'s files, a `cp`/`mv`/`ln`/`install`/`rsync`/`ditto` destination, or a
432
- target it can't resolve), every time: the ask is fresh (`Verdict#fresh_ask`: no
433
- stored approval answers it, and no rule lowers it), so it comes even in auto
434
- mode. The tmp dirs (`Dir.tmpdir`, `$TMPDIR`, `/tmp`, `/private/tmp`) and
435
- `/dev/null` neither ask nor deny.
471
+ And it **asks the owner** before a `write` or `edit` that may land outside the
472
+ session's project root, and before a command that may write there, every time:
473
+ the ask is fresh (`Verdict#fresh_ask`: no stored approval answers it, and no
474
+ rule lowers it), so it comes even in auto mode. A command fails closed
475
+ (`PairCommand#outside_writes?`): it runs unasked only when it
476
+
477
+ - only reads (`ReadOnlyShell.read_only?`, wherever it reads), or
478
+ - only reads but for writes it can name (a `>`/`>>` target, `cp`'s
479
+ destination, `tee`'s files) into the project or a tmp dir
480
+ (`ReadOnlyShell.read_only_but_writes?`), or
481
+ - stays inside the project: every part of it runs in the project, and every
482
+ path it names lands in the project or a tmp dir, an option's or an
483
+ assignment's value (`of=`, `--output=`, `GIT_DIR=`), a glob's fixed part
484
+ (`~/oth*`), `git -C`'s folder and the paths in a script it hands on (`sh -c`
485
+ read as a command, `python -c`/`ruby -e`/a heredoc by the paths in its text)
486
+ among them. The program's own word doesn't count (`/usr/bin/sed`), nor does
487
+ `PATH=…`, nor a message's heredoc (`git commit -m "$(cat <<'EOF' … EOF)"`).
488
+
489
+ Anything else asks: `rm -rf ~/x`, `sed -i … ~/x`, `curl -o ~/x`, `dd of=~/x`,
490
+ `touch`/`mkdir` outside, `perl -pi … ~/x`, `cd .. && make`, `cd && make`, and
491
+ whatever the checks can't place: a `$var` (`rm -rf $DIR`, `> "$OUT"`), a `$(…)`,
492
+ `xargs`, a `cd $X`. A path "lands" in the project or a tmp dir every way it
493
+ resolves ([How paths resolve](#how-paths-resolve)) and never through a dangling
494
+ symlink or onto a file with another hard link (`Outside.inside?`,
495
+ `Outside.tmp_write?`, the child boundary's tmp rule): the tmp dirs are
496
+ `Dir.tmpdir`, `/tmp` and `/var/tmp`, except the one the project itself is in.
497
+ `/dev/null` and the like neither ask nor deny. A script file's body (`ruby
498
+ x.rb`, `make`, `sh ./run.sh`) isn't read: it runs as in-project work.
499
+ A command that makes a link asks too, wherever it points (`ln`, `link`, `cp
500
+ -s`/`-l`, `sh -c 'ln …'`, an interpreter's `symlink`/`link(`): a path through
501
+ a link the same command makes is judged before the link exists, so `ln -s ..
502
+ up && cat up/…` can't be read for what it reaches.
503
+ A session whose folder is the home folder or above it has no project to
504
+ keep the turn in (`ReviewPaths.home_root?`, the review boundary's test): every
505
+ write there asks.
436
506
 
437
507
  A participant's ask names them on the owner's card ("Sam's turn asks: approve
438
508
  tool call?", `approval.author`/`author_name`, `approval.asked_by`, the same
@@ -448,7 +518,10 @@ The trust model is a team's (everyone invited is trusted): the boundary is an
448
518
  hostile participant; a review session is. A pair turn can still reach, through
449
519
  the owner's rules: `web_fetch` (an open egress path: what the turn can read it
450
520
  can send out in a URL), plugin and MCP tools, `memory_read` and `context_read`,
451
- `list_sessions` (the owner's other sessions' titles), and anything a script
521
+ `list_sessions` (the owner's other sessions' titles), chi web by a name the
522
+ check doesn't know (a DNS name or a decimal address for 127.0.0.1, a port
523
+ forward, a port spelt in a variable), where a LAN chi web still takes a
524
+ token-less loopback request as the owner's, and anything a script
452
525
  does that the command checks don't read (a script file's body, an interpreter's
453
526
  `-e` code). Share a working session only with people you would let type in it.
454
527
 
@@ -564,7 +637,10 @@ only `run list|view`, `pr list|view|diff|checks`, `issue list|view` and
564
637
  `repo view`; `--web`/`-w`, `gh api` and `pr checks --watch` are not read-only.
565
638
  `$(…)`, backticks, `$VAR` (other than `$HOME`, `$XDG_*`, `$TMPDIR` and `$PWD`
566
639
  at a word's start), an env prefix, `sh -c`, `xargs`, `eval`, `sudo` and the
567
- like make a command not read-only. It errs towards asking.
640
+ like make a command not read-only, as does a `task_create` `env:` that sets `BASH_ENV`, `ENV`, `CDPATH`, `IFS`,
641
+ `SHELLOPTS`, `BASHOPTS`, `BASH_FUNC_…`, `LD_PRELOAD`, `LD_AUDIT`, `DYLD_INSERT_LIBRARIES`, `GIT_EXTERNAL_DIFF`,
642
+ `GIT_CONFIG_PARAMETERS` or `GIT_CONFIG_COUNT` (they change what its words run; `PATH` doesn't count). It errs
643
+ towards asking.
568
644
 
569
645
  A tool name may be a glob, so one rule covers a plugin's tools (an MCP server's,
570
646
  say): `tool: "mcp_*"` or `tool: ["mcp_{git,gh}_*", web_fetch]` (`*`, `?`, `[…]` and
@@ -647,6 +723,41 @@ one folder; a link in the repo that points elsewhere is outside). Never outside:
647
723
  - a tmp folder (`$TMPDIR`, `/tmp`, `/var/tmp`), unless the session's repo is
648
724
  itself in that tmp folder: a sandbox there still gets asked about its siblings.
649
725
 
726
+ A path that is or goes through a dangling symlink (a write would create the
727
+ link's target) or a file with another hard link (a write changes the other
728
+ name too) is always outside, in the repo or a tmp folder alike
729
+ (`Outside.resolve`, which the child boundary's tmp writes read as well).
730
+
731
+ #### How paths resolve
732
+
733
+ Every path check (protected paths, `outside_repo`, `touches`, `rm`, `memory:
734
+ prompt`, the child, review and pair boundaries, System 1's own-files test)
735
+ resolves paths with `Guardrails::ProtectedPaths`:
736
+
737
+ - `..` is taken the kernel's way (`.expand`): after a symlinked folder it
738
+ climbs out of the link's target, so `repo/link/../x` with `link` pointing
739
+ to `/elsewhere/deep` is `/elsewhere/x`, not `repo/x`;
740
+ - symlinks are resolved on the longest part that exists, and a dangling
741
+ symlink is followed to the file a write through it would create (`.real`);
742
+ - a check reads the path both ways, the lexical answer (`File.expand_path`,
743
+ stopping at a dangling link) and where the kernel lands (`.reals`): it
744
+ refuses, asks or votes when either is protected, outside or a secret, so
745
+ the kernel's answer only ever adds to the lexical one. A file tool call's
746
+ paths (`Targets#paths`, what approvals show) list both when they differ.
747
+
748
+ In a shell command a file argument resolves the same way, against the folder
749
+ the command is in by then. A `cd` or `pushd` to such a path may land either
750
+ way: `cd link/..` is back where it started only while that logical folder
751
+ exists (zsh, which chi's shell is on macOS, and bash chdir to the physical one
752
+ when it doesn't), and `cd -P`, `set -P` and zsh's `chase_links`/`chase_dots`
753
+ always take the physical one. So after it the command counts as in both
754
+ folders: each later word is resolved against each (a word that can't be
755
+ resolved from one isn't resolved), and judged both ways as above; past eight
756
+ folders, or after `cd -`, the folder is unknown. After `cd link` a `../x`
757
+ climbs out of the link's target as well. git's `-C`, `--git-dir` and
758
+ `--work-tree` (and `GIT_DIR`/`GIT_WORK_TREE`) are counted both ways, from each
759
+ folder the command may be in.
760
+
650
761
  `git: outside_repo` (shell tools only) reads the command for git subcommands
651
762
  that change a checkout: `commit`, `add`, `reset`, `checkout`, `switch`,
652
763
  `rebase`, `merge`, `push`, `stash` (not `stash list`/`show`), `rm`, `mv`,
@@ -883,12 +994,26 @@ gate below passes, in this order:
883
994
  config dir (config.yml), the hooks dir, the installed bundles dir, the
884
995
  guardrails store (where `approvals.json` and `judgments.jsonl` live) or the
885
996
  attached context dir, and no `execute` or `task_create` command naming any
886
- of them, however it spells them (`ParentApprovals::CHI_TEXT`:
887
- `$XDG_CONFIG_HOME/samagotchi/config.yml`, `~/.config/samagotchi/…`, a
888
- `cd ~/.config` then `samagotchi/config.yml`). The memories dir is
889
- deliberately not one of these: reading or appending a memory file can be
890
- auto-allowed, unless the command has a `..` segment (`memories/../config.yml`
891
- climbs out of it). A protected rule
997
+ of them (or chi's state dir). A command is checked twice: by its text
998
+ (`ParentApprovals::CHI_TEXT`: `$XDG_CONFIG_HOME/samagotchi/config.yml`,
999
+ `~/.config/samagotchi/…`, a `cd ~/.config` then `samagotchi/config.yml`),
1000
+ and by its words resolved as `shell-touches-chi` resolves them
1001
+ (`Guardrails::ShellPaths::Walk`: cd chains, `~`, `$HOME` and the XDG dirs,
1002
+ symlinks, `..`; under the call's `env:` too), so `cd ~/.config; cd
1003
+ samagotchi; cat config.yml` or a symlink into the config dir is refused.
1004
+ A call whose `env:` redefines the shell (`Targets#redefines_shell?`:
1005
+ `BASH_ENV`, `BASH_FUNC_…`, …) is refused whatever it names. The memories
1006
+ dir is deliberately not one of these: reading or appending a memory file
1007
+ can be auto-allowed, by a command none of whose words lands in chi's dirs
1008
+ outside the memories, whose text shows no climb (`SelfProtect::CLIMB`: a
1009
+ `..` segment, any `$` or `$(…)`, backticks, a `~` other than the home one,
1010
+ `cd -`), and whose words the walk can't resolve are only data read while
1011
+ it knew the dir: a script or heredoc body (`python3 -c '…'`, `cat >> x.md
1012
+ <<'EOF'`), a phrase (`echo "- a note"`) or a glob with no `/`. So
1013
+ `cd memories; cd ..`, `cd "${PWD%/*}"`, `cd $(dirname $PWD)` or `cd -`
1014
+ loses the exemption, and the memories text then refuses it. A script body
1015
+ is not looked into beyond its text: an accident guard, not containment.
1016
+ A protected rule
892
1017
  (`Verdict::PROTECTED_RULES`) may be listed by name and System 1 may answer
893
1018
  it, but a call that really writes config.yml or the hooks is refused here;
894
1019
  5. the judgment's `p` is at least `guardrails.system_one.threshold` (0.8);
@@ -176,8 +176,11 @@ Everything else is `:owner`: `/answer`, `/command`, `/images` (an image a prompt
176
176
  to act: the turn only reads (`Guardrails::ReviewBoundary`), and proposes what the owner applies ([Apply](#apply)).
177
177
  - **`:pair`**, a working session's participant invited to act: the turn acts with the session's tools under the
178
178
  owner's guardrails, its asks go to the owner and offer once only, and `Guardrails::PairBoundary` denies secrets,
179
- chi itself, background jobs, the environment, owner-voiced tools and the model's own question, and asks before a
180
- write outside the project ([guardrails](../guardrails.md#a-participants-pair-boundary)). No Apply: it acted itself.
179
+ chi itself and chi web on loopback, background and scheduled jobs, the environment and credential printers, git's
180
+ hooks, owner-voiced tools and the owner's task outputs, and the model's own question, and asks before a command
181
+ that makes a link and before a write that may land outside the project, failing closed (a session at or above
182
+ home has none: every write asks) ([guardrails](../guardrails.md#a-participants-pair-boundary)). No Apply: it
183
+ acted itself.
181
184
 
182
185
  `ParticipantMode.of(session, features:)` is the session's: what a participant invited now gets. No session is
183
186
  `:review`. A worker names `pair_turns` (`Delivery::PAIR_FEATURE`) when it runs pair turns; one without it runs
@@ -230,6 +233,15 @@ worker checks their link (`participants.json`: revoked, expired) and their cap a
230
233
  dequeued (`:turn_dequeued`) and audited (`revoked`, `cap_reached`) instead of run, so a revoke also stops what they
231
234
  had queued; its settled record (`TurnCheckpoint.record_refused`: canceled, `cancel_reason` the reason) ends their
232
235
  wait on it at once, which `ParentReport` reads as status `refused`, exit 1.
236
+
237
+ **The Verify gate.** A worker names `verify` (`VerifyAnchor::FEATURE`, in the Bridge's `FEATURES`) when it understands a
238
+ Verify turn's anchor (`VerifyAnchor`, `{turn_id, quote}`: a UUID and a quote of 1 to 200 characters). A turn carrying an
239
+ anchor, or a Verify fresh check, sent to a connected worker without it is refused 409 `verify_unavailable`
240
+ (`VerifyAnchor::UNAVAILABLE`: "restart this session to use Verify"; `SessionManager` checks `worker_has?` before it
241
+ delivers, on the features of the sidecar its client was made from), since an older worker would run it as a plain prompt. A session with no connected worker is not gated: the
242
+ worker that starts for it is current. The participant page's session summary carries `verify_unavailable`
243
+ (`VerifyAnchor.worker_unavailable?`, from the worker's features; `participant_review.rb`), and the page disables
244
+ Verify and Verify fresh with a "restart this session" hint.
233
245
  **The owner's input during a review turn queues.** While a participant's turn runs, the owner's input never merges
234
246
  into it, from any client (the web, an attached TUI, `chi send` without `--queue`): the Bridge's `POST /turn` makes it
235
247
  `delivery: queue` whatever it asked, so it never steers or cuts (`TurnAccumulator#participant_turn?`, under the event
@@ -264,7 +276,9 @@ queued, as `--turn T` does.
264
276
  **The tool calls.** In a review session `Guardrails::ReviewBoundary` denies a participant's turn everything but
265
277
  reads of the project ([guardrails](../guardrails.md#a-participants-review-boundary)). In a working session
266
278
  `Guardrails::PairBoundary` takes its seat: the owner's checks and rules decide the rest, as for the owner's turns
267
- ([guardrails](../guardrails.md#a-participants-pair-boundary)). Approvals and questions are owner-only: either
279
+ ([guardrails](../guardrails.md#a-participants-pair-boundary)). Both judge a path every way it resolves
280
+ (`ProtectedPaths.reals`: a `..` after a symlinked folder climbs out of its target, as the read opens it;
281
+ [guardrails](../guardrails.md#how-paths-resolve)). Approvals and questions are owner-only: either
268
282
  boundary denies the model's own question, and `/answer` is an owner route. A pair turn's ask reaches the owner's card
269
283
  naming who asks (`approval.author`, `author_name`; `Guardrails::Approval::PARTICIPANT_HEADER`) with Allow once as its
270
284
  only scope, and System 1 never judges it (`GuardrailWiring#system_one_judge`). A grant without `--identity` (a LAN
@@ -202,7 +202,10 @@ yet). Results: `:ok` stamps the device's
202
202
  `last_ok_at`; `:gone` removes it; `:failed` keeps its code in `last_error` and logs `push_failed`; `:bad_jwt` logs
203
203
  one `push_bad_jwt` warning (on chi web's terminal too) and stops all pushes until chi web restarts. Log lines carry
204
204
  `device:` (the short id), `code:`, `ms:`, `kind:`: never the endpoint, keys or text. `#stop` drops pending holds and
205
- joins with a timeout; `Server.start`'s ensure calls it. Specs drive `#pump` with a moved clock
205
+ joins with a timeout; `Server.start`'s ensure calls it. chi web's own restart (`restart_web`, `Server.reexec`) execs
206
+ without either: the shows still in their hold and the retries waiting in `@pending` / `@retries` go with the process,
207
+ unsent and not in `sent.json`. The exec'd chi web's seed brings back only what restart catch-up covers (a question
208
+ still open); a closed-meanwhile or non-question show held then is not pushed. Specs drive `#pump` with a moved clock
206
209
  (`start(hub, sender: false)`), and one example runs the real thread with a client that blocks.
207
210
 
208
211
  ## The routes (`web/push_routes.rb`)
data/docs/sessions.md CHANGED
@@ -21,7 +21,7 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
21
21
 
22
22
  A session is deleted if **expired by age OR overflow by count** (unless `keep_status` or the live-owner guard: a session a worker or `chi` still has open is never pruned). A session's `status` is its turn state (`idle`/`running`), not whether a worker is alive. Orphan dirs without a `*.json` are never deleted, except skeleton-only ones (see *Empty sessions*). Deletion removes both `*.json` and sidecar dir atomically. Set both `DAYS=0` and `MAX=0` to retain forever.
23
23
 
24
- **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered, no background task it watches for a [finish wake](internals/background-tasks.md#finish-wake) still running or waiting for its wake turn (with `session.task_wakes: wake`; not one whose turn you stopped) and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). As its Bridge stops, the worker marks the lock leaving (`OwnerLock#mark_leaving`: `leaving_at`, and `leave_wait`, its `recap.timeout`), since it keeps the lock while it writes the recap on leave: an `--attach` or `--resume` meanwhile (here, or a remote one through chi web's wake) prints `(waiting for the session to finish its recap…)` (`LaunchMode::RECAP_WAIT`) and waits for the release, up to that budget plus `OwnerLock::LEAVE_MARGIN` (5 s), before starting the new worker (`OwnerLock.await_release`). A lock held by a worker that isn't leaving (a live one, a stuck one) is left as before. A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up (for background tasks, which ones and how to stop them). When every hold is one the user can stop — reminders, a watched task still running, a delegate with a live worker — the 409 also carries `holds: [{kind:, items: [{id:, label:, detail:}]}]` (a reminder's detail is its schedule, `every 20m`/`every 1h`, `, due now` when it is already due), and an attached terminal asks what to do with them; its "stop them and exit" posts `{"stop": "all"}`, which clears the reminders, stops those tasks (`TaskRuntime.stop_task`, as `task_stop`) and stops those children (`SessionManager.stop_session`) before leaving, re-checking first that nothing the user can't stop appeared meanwhile (`HoldStop`). A delegate counts only for that ask: it never holds the idle exit, and an exit that deletes or archives the session does not ask about it. The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). Reminders go along: as it leaves, the worker writes them to `<id>/reminders.json` (0600: `session_id`, `written_at`, and each reminder's id, name, description, interval and next fire as wall-clock time; `ReminderCarry`), and its successor reads and deletes the file as it starts, before its idle scheduler runs. A restored reminder keeps its id and schedule; one that came due meanwhile fires once at once, and one further ahead than its interval (a clock that jumped) is due one interval from now. The file counts only for the same session within 2 minutes of `written_at`, and not once the session is stopped: otherwise it is deleted unread (log `reminders_carry_dropped`, at warn unless the session was stopped). A stop that lands while the worker leaves for a restart wins: the worker writes no file, and no successor starts (`SessionManager.run_session_loop`, log `restart_dropped`); an archive deletes the file of each session it archives. Once an exit or restart is taken, a later exit request (a stop-all from another terminal) is answered as that same exit: the first one decides how the worker leaves. A write that fails keeps the worker up with its reminders (log `reminders_carry_failed`), and the caller reports no new worker. A reminder already due when the restart is asked runs its turn first, so the restart's wait (10 s) may report no new worker yet though the hand-off follows. A worker from before this still refuses a restart for its reminders (409 `reminders`). Only an asked restart carries them: the fallback's TERM (a worker that can't be asked, below), a stop, an idle exit (which reminders hold anyway) and a crash drop them as before. What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
24
+ **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered, no background task it watches for a [finish wake](internals/background-tasks.md#finish-wake) still running or waiting for its wake turn (with `session.task_wakes: wake`; not one whose turn you stopped) and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). As its Bridge stops, the worker marks the lock leaving (`OwnerLock#mark_leaving`: `leaving_at`, and `leave_wait`, its `recap.timeout`), since it keeps the lock while it writes the recap on leave: an `--attach` or `--resume` meanwhile (here, or a remote one through chi web's wake) prints `(waiting for the session to finish its recap…)` (`LaunchMode::RECAP_WAIT`) and waits for the release, up to that budget plus `OwnerLock::LEAVE_MARGIN` (5 s), before starting the new worker (`OwnerLock.await_release`). A lock held by a worker that isn't leaving (a live one, a stuck one) is left as before. A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up (for background tasks, which ones and how to stop them). When every hold is one the user can stop — reminders, a watched task still running, a delegate with a live worker — the 409 also carries `holds: [{kind:, items: [{id:, label:, detail:}]}]` (a reminder's detail is its schedule, `every 20m`/`every 1h`, `, due now` when it is already due), and an attached terminal asks what to do with them; its "stop them and exit" posts `{"stop": "all"}`, which clears the reminders, stops those tasks (`TaskRuntime.stop_task`, as `task_stop`) and stops those children (`SessionManager.stop_session`) before leaving, re-checking first that nothing the user can't stop appeared meanwhile (`HoldStop`). A delegate counts only for that ask: it never holds the idle exit, and an exit that deletes or archives the session does not ask about it. The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). Reminders go along: as it leaves, the worker writes them to `<id>/reminders.json` (0600: `session_id`, `written_at`, and each reminder's id, name, description, interval and next fire as wall-clock time; `ReminderCarry`), and its successor reads and deletes the file as it starts, before its idle scheduler runs. A restored reminder keeps its id and schedule; one that came due meanwhile fires once at once, and one further ahead than its interval (a clock that jumped) is due one interval from now. The file counts only for the same session within 2 minutes of `written_at`, and not once the session is stopped: otherwise it is deleted unread (log `reminders_carry_dropped`, at warn unless the session was stopped). A stop that lands while the worker leaves for a restart wins: the worker writes no file, and no successor starts (`SessionManager.run_session_loop`, log `restart_dropped`); an archive deletes the file of each session it archives. Once an exit or restart is taken (claimed before a stop-all stops anything), the first one decides how the worker leaves: a later request of the same kind is answered as that exit, one of another kind (a stop-all from another terminal after a restart, chi web's "Restart workers" after a `/exit`) gets 409 `held` with `reason` `leaving_exit`, `leaving_delete` or `leaving_restart`, and a restart says it was refused rather than "restarted". A write that fails keeps the worker up with its reminders (log `reminders_carry_failed`), and the caller reports no new worker. A reminder already due when the restart is asked runs its turn first, so the restart's wait (10 s) may report no new worker yet though the hand-off follows. A worker from before this still refuses a restart for its reminders (409 `reminders`). Only an asked restart carries them: the fallback's TERM (a worker that can't be asked, below), a stop, an idle exit (which reminders hold anyway) and a crash drop them as before. What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
25
25
 
26
26
  **Empty sessions:** a session nothing happened in is deleted when it is left, so `chi` → `/exit` leaves no trace. Empty means: no messages, no turn tried (a failed turn leaves a `last_prompt` and keeps it), no memory used, no pending question, nothing in `input/`, `notes/` or `images/`, nothing in its dir beyond the worker's skeleton, mode `assist`, and the default model. A session on another model (`/model`, `--model`) counts as prepared and stays (`SessionManager.discardable?`). The worker checks as it idle-exits or leaves on a client's request and deletes it once its lock is free, checking again first. The attached `/exit` then says `Detached; the session is empty: it will be discarded if nothing arrives before its worker stops.` (a note or prompt landing first keeps it). The REPL (`--no-shared`) does the same at `/exit` or Ctrl-D. The retention sweep catches those killed first: empty sessions over an hour old with no live owner, whatever `DAYS`/`MAX`, and skeleton-only dirs with no `*.json`. `session.keep_empty: true` (env `SAMAGOTCHI_SESSION_KEEP_EMPTY`) turns all of it off.
27
27
 
@@ -88,7 +88,8 @@ module Samagotchi
88
88
  def self.for_sidecar(session_id, sidecar, host: HOST, read_timeout: READ_TIMEOUT)
89
89
  return nil unless sidecar
90
90
 
91
- new(session_id: session_id, port: sidecar.port, secret: sidecar.secret, host: host, read_timeout: read_timeout)
91
+ new(session_id: session_id, port: sidecar.port, secret: sidecar.secret, host: host, read_timeout: read_timeout,
92
+ features: sidecar.features)
92
93
  end
93
94
 
94
95
  # How often a wait for a new worker's Bridge looks: a missing sidecar is
@@ -111,6 +112,9 @@ module Samagotchi
111
112
  end
112
113
 
113
114
  attr_reader :session_id, :port, :host
115
+ # @return [Array<String>] what the worker's sidecar named (WorkerSidecar#features)
116
+ # as this client was made from it; empty for one made without a sidecar
117
+ attr_reader :features
114
118
 
115
119
  # Which worker this client talks to, for EventStream: a rediscovered
116
120
  # client with another key is a new worker. A Bridge's port, and a
@@ -122,7 +126,9 @@ module Samagotchi
122
126
  # with every request (nil: a worker from before secrets, which asks for
123
127
  # none); from the worker's sidecar (.for_sidecar), never typed
124
128
  # @param read_timeout [Numeric] see READ_TIMEOUT
125
- def initialize(session_id:, port:, secret:, host: HOST, read_timeout: READ_TIMEOUT)
129
+ # @param features [Array<String>] the sidecar's (#features)
130
+ def initialize(session_id:, port:, secret:, host: HOST, read_timeout: READ_TIMEOUT, features: [])
131
+ @features = Array(features).freeze
126
132
  @session_id = session_id
127
133
  @port = port
128
134
  @secret = secret
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.128.0
3
+ version: 0.129.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:
@@ -314,7 +314,7 @@ module Samagotchi
314
314
  # one that passes every gate is allowed once without reaching them
315
315
  # (docs/guardrails.md, "System 1").
316
316
  judgment = begin
317
- system_one_judge(payload)
317
+ system_one_judge(payload, targets: verdict.targets)
318
318
  rescue LLM::RequestCancelled
319
319
  return turn_stopped(verdict)
320
320
  end
@@ -357,7 +357,7 @@ module Samagotchi
357
357
  # host) means no line at all. A judgment that fails is logged with p nil,
358
358
  # so the failures are in the log too.
359
359
  # @return [Hash, nil] {id:, p:, auto:}, nil when nothing was written
360
- def system_one_judge(payload)
360
+ def system_one_judge(payload, targets: nil)
361
361
  return nil if Steer.participant_author?(turn_author)
362
362
 
363
363
  client = @system_one.call
@@ -385,7 +385,7 @@ module Samagotchi
385
385
  end
386
386
  latency_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
387
387
  p = answers && answers[SystemOne::ApprovalQuestion::ID]&.noul
388
- auto = system_one_auto_allow?(client, approval, p, entries)
388
+ auto = system_one_auto_allow?(client, approval, p, entries, targets: targets)
389
389
  decision = system_one_decision(client, auto)
390
390
 
391
391
  id = log.judgment(session_id: @session_lookup.call&.id,
@@ -417,13 +417,14 @@ module Samagotchi
417
417
  # the rule's record. Any one that fails leaves the ask to the user. The
418
418
  # entries are the judgment log read once for this ask.
419
419
  # @param entries [Array<Hash>] SystemOne::Log#entries
420
- def system_one_auto_allow?(client, approval, p, entries)
420
+ # @param targets [Guardrails::Targets, nil] the call's, for its env:
421
+ def system_one_auto_allow?(client, approval, p, entries, targets: nil)
421
422
  return false unless client.mode == "on"
422
423
 
423
424
  rule = approval[:rule].to_s
424
425
  return false if rule.empty?
425
426
  return false unless system_one_rules.include?(rule)
426
- return false if SystemOne::SelfProtect.refuse?(approval)
427
+ return false if SystemOne::SelfProtect.refuse?(approval, targets: targets)
427
428
 
428
429
  threshold = Config.get("guardrails.system_one.threshold").to_f
429
430
  return false if p.nil? || p.to_f < threshold