samagotchi 0.129.0 → 0.130.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/cli.md +18 -9
  5. data/docs/configuration.md +7 -2
  6. data/docs/guardrails.md +60 -16
  7. data/docs/internals/background-tasks.md +11 -0
  8. data/docs/internals/engine.md +4 -3
  9. data/docs/internals/participants.md +13 -4
  10. data/docs/internals/remote-sessions.md +5 -4
  11. data/docs/sessions.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/engine.rb +20 -4
  14. data/lib/samagotchi/engine_peers.rb +3 -1
  15. data/lib/samagotchi/guardrails/chi_dirs.rb +64 -0
  16. data/lib/samagotchi/guardrails/child_boundary.rb +5 -5
  17. data/lib/samagotchi/guardrails/outside.rb +22 -20
  18. data/lib/samagotchi/guardrails/pair_boundary.rb +14 -3
  19. data/lib/samagotchi/guardrails/pair_command.rb +70 -10
  20. data/lib/samagotchi/guardrails/parent_approvals.rb +8 -11
  21. data/lib/samagotchi/guardrails/path_resolve.rb +231 -0
  22. data/lib/samagotchi/guardrails/protected_paths.rb +17 -116
  23. data/lib/samagotchi/guardrails/review_paths.rb +13 -11
  24. data/lib/samagotchi/guardrails/rm_targets.rb +5 -5
  25. data/lib/samagotchi/guardrails/rules.rb +23 -5
  26. data/lib/samagotchi/guardrails/scratch_writes.rb +3 -2
  27. data/lib/samagotchi/guardrails/shell_git_dirs.rb +17 -12
  28. data/lib/samagotchi/guardrails/shell_paths.rb +18 -29
  29. data/lib/samagotchi/guardrails/targets.rb +11 -13
  30. data/lib/samagotchi/remote_command.rb +13 -3
  31. data/lib/samagotchi/remotes.rb +3 -2
  32. data/lib/samagotchi/review_start.rb +3 -2
  33. data/lib/samagotchi/session_inbox.rb +17 -0
  34. data/lib/samagotchi/system_one/self_protect.rb +34 -19
  35. data/lib/samagotchi/terminal_ui.rb +2 -0
  36. data/lib/samagotchi/tool_declarations.rb +1 -1
  37. data/lib/samagotchi/tools/builtins.rb +10 -1
  38. data/lib/samagotchi/tools/peers.rb +4 -0
  39. data/lib/samagotchi/tools/task_wait.rb +26 -1
  40. data/lib/samagotchi/turn_state.rb +23 -4
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +23 -18
  43. data/lib/samagotchi/web/proxy_gate.rb +8 -6
  44. data/lib/samagotchi/web/server.rb +29 -4
  45. data/lib/samagotchi/worker.rb +9 -1
  46. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91648ee9b8cf9906ded9d6f865f424b10148222e8007516d8d74718a5a4a9c41
4
- data.tar.gz: 0ca9cd428118fd52e4c2dc3f1b5e1750d6d7f4bf41ca0d039c8769660a2db2e9
3
+ metadata.gz: 5830c4028607020136e2f0493d0d365f5d10889fa8ff2a493b979de7b7e164b9
4
+ data.tar.gz: 28f0fda8ba4b824af19381b12274262c0858f879565b58ab4a6a12e18b82f64e
5
5
  SHA512:
6
- metadata.gz: 1fa243881749c0b856bcb0c40f42937a253f33b0a0904d6145950cbf81c52262bfd3bdcf883389041f06383d7796150d7ee74a74bb9b8a8118d90ac3b4807a1c
7
- data.tar.gz: dc1b3dafdca94da6af0338e1a76b3df6e914847514f6eeb9e61359de941cc789955e5a4b04f6e04417402fcdc221d460940051dcde3101737694cb080b038e1a
6
+ metadata.gz: 38f04438ffc54f5569abbbfb5fdfdd7470ca805926f3593969924a5a248b29ff266f2eec6972aff25d29395982b255b6518989f174a1579b227a711ae1aeec68
7
+ data.tar.gz: 3dc45f2617ca67e11f8cf563c83ea1c979142d798d900b000b5f1ae0c12ffb559b692cdec8f8f614da09eeb8cd176a1e7e5af59d47958ddffc838b3faa8f8d9d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,49 @@ 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.130.0] - 2026-10-29
11
+
12
+ ### Changed
13
+
14
+ - A message you send while the model is blocked in `task_wait` ends the wait within half a second instead of
15
+ waiting for the task: the task keeps running, the result says `wait_result: message_waiting`, and the model reads
16
+ your message at once and decides whether to wait again. A queued message, a plugin's steer and a delegate child's
17
+ report don't end it. A worker started before the upgrade keeps the old behaviour until it is restarted.
18
+
19
+ - `chi web --web-host lan` prints a `Local:` link for this machine with an owner's one-time code (10 minutes, once),
20
+ and `--open` opens it; a second `chi web` prints a new one. `chi remote add` without a token refuses a chi web that
21
+ asks for one (through an SSH tunnel to its loopback) instead of saving a remote that would 401.
22
+
23
+ ### Security
24
+
25
+ - chi web on the LAN (`web.host: lan`) now asks this machine's 127.0.0.1 for its access token too: a token-less
26
+ loopback request was the owner, so a participant's pair turn could `curl` it (by a decimal IP or a DNS alias past
27
+ the pair boundary's text check) to mint grants, queue owner turns and read every session. Only chi's token-less
28
+ `GET /api/info` probe (its version, pid, features, LAN address and start folder) is answered without it. A chi web
29
+ on 127.0.0.1 only is unchanged. After the upgrade an open 127.0.0.1 tab (and a push link opened in one) lands on
30
+ the 401 page: open the `Local:` link chi web prints (its one-time code sets the cookie; `chi web --open` opens
31
+ it), the LAN link, or paste the token. A browser left with an ended participant cookie gets the ended page there
32
+ instead of becoming the owner again.
33
+ - A participant's pair turn can no longer open a URL or a file in the owner's apps (`open`, `xdg-open`, a browser,
34
+ `osascript`'s `open location`) or listen on a port (`nc -l`, `socat …LISTEN`, `python -m http.server`, `php -S`, a
35
+ static server): a page opened at a port it listens on would catch the owner's chi web cookie.
36
+ - System 1 no longer auto-allows a `write`/`edit` anywhere in chi's state dir (`state/sessions/x.json`), as it already
37
+ refused a command naming one: a file tool's paths are judged by the same dirs as a command's words.
38
+ - On a disk that folds case (macOS) a write to `<state dir>/CONTEXT/…` before the attached context dir exists is denied
39
+ as one to it (`chi-context`), and a command naming it touches chi: the protected-path checks compared the typed case.
40
+ - `git -C $XDG_CONFIG_HOME …` (an XDG dir or `$TMPDIR`) counts as the folder it names for `git: outside_repo` and the
41
+ delegate child's boundary, as a `cd` there already did; it was an unknown folder that never asked.
42
+ - A guardrail rule's `path:` glob also matches the path as it resolves: `write hl/pre-commit` with `hl` a symlink to
43
+ `.git/hooks` is denied by `git-hooks-write` (a relative glob is read from the repo root as it resolves, `/tmp` being
44
+ `/private/tmp` on macOS). Rules only ask or deny, so this only adds votes.
45
+ - A participant's pair turn can no longer glob or `$var` its way into a secret through an XDG dir: `ls
46
+ $XDG_CONFIG_HOME/samago*` is denied like `ls ~/.config/samago*`. After `$TMPDIR` only what lands in a denied folder
47
+ counts, so `ls $TMPDIR/*` stays allowed when chi's state dir is in tmp (isolated test runs).
48
+ - A `hooks.hooks_dir` configured outside chi's config dir is one of chi's dirs for participants too: neither a review
49
+ nor a pair turn reads it (the owner's own checks already counted it).
50
+ - `outside_repo` and `rm: outside_tmp` compare a path with an existing root in any case on a disk that folds case,
51
+ as the protected-path checks do; a session root that doesn't exist yet keeps the case it was typed in.
52
+
10
53
  ## [0.129.0] - 2026-10-28
11
54
 
12
55
  ### Changed
@@ -4890,7 +4933,8 @@ and long-lived sessions.
4890
4933
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
4891
4934
  hotkey panel that send selected text or the clipboard to your sessions.
4892
4935
 
4893
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.129.0...HEAD
4936
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.130.0...HEAD
4937
+ [0.130.0]: https://github.com/dm1try/samagotchi/compare/v0.129.0...v0.130.0
4894
4938
  [0.129.0]: https://github.com/dm1try/samagotchi/compare/v0.128.0...v0.129.0
4895
4939
  [0.128.0]: https://github.com/dm1try/samagotchi/compare/v0.127.0...v0.128.0
4896
4940
  [0.127.0]: https://github.com/dm1try/samagotchi/compare/v0.126.0...v0.127.0
data/docs/architecture.md CHANGED
@@ -153,7 +153,7 @@ agent logic and can be used without any terminal rendering; the UI is a thin lay
153
153
  | UI | `Samagotchi::TerminalUI` | Interactive REPL (Reline), rendering (ANSI, spinner, status line), REPL commands. Delegates all core work to an `Engine`. |
154
154
  | Model loops and adapters | `KernelLoop`, `LLM::ChatLoop`, `Samagotchi::Client`, `LLM::OpenAIChat`, `LLM::AnthropicMessages`, `LLM::HTTP` | The model↔tool loops and the HTTP adapters they talk through (see "Model loops and adapters"). |
155
155
  | Bridge (SSE/HTTP) | `Samagotchi::Bridge`, `SessionManager` | The **single live client transport**: an SSE read stream + HTTP POST turn/cancel/answer surface that attaches to a worker's existing `Engine` via `Engine#subscribe`. Every session worker starts it (bound `127.0.0.1`, localhost-only), and it answers only a request that names its secret (`X-Chi-Bridge`, from the worker's 0600 `bridge.json`). |
156
- | Web (Rack) | `Samagotchi::Web::App`, `SessionManager` | Single-port `127.0.0.1:4567` control plane via `rack`+`webrick` (serve `index.html` + `/api/*`). With `web.host: lan` (or one of this machine's IPv4 addresses) the server also listens on that address, where every request but this machine's needs the access token (`App#token_gate`: the `chi_token` cookie or `Authorization: Bearer`; [CLI](cli.md#chi-web-on-your-phone)). Experimental: behind an auth proxy (`web.public_url`, `Web::ProxyGate`) every request needs the token, this machine's too, and one that came through the proxy also the configured identity header for the owner ([configuration.md](configuration.md#behind-an-auth-proxy)). A request goes through, in order: the Host check (`local_host_header?`: loopback names, the LAN address, the public host), the cross-site check (`cross_site?`: Origin this server's or the public origin, no `Sec-Fetch-Site` cross-site/same-site on a POST or `/api`), the frame check (`framed?`: a frame/embed/object load from another origin, 403 `framed`; every HTML answer carries `X-Frame-Options: DENY` and `frame-ancestors 'none'`), the token gate (`token_gate` → `principal_for`, `proxied_principal` for a proxied request; the `?token=` / `POST /api/token` trade sets the cookie, `__Host-chi_token` through the proxy; with `web.public_url` a direct request takes Bearer only, and chi's token-less `/api/info` probe gets `probe_info`), then the route's permission (`Principal#permits?`). It talks to each worker's Bridge (`App#relay`, `bridge_get`; `/stream` proxies it) and reads the session files; no `Engine` of its own. Markdown (`web.markdown`) is rendered here only (`Web::MarkdownRenderer`): the answers it serves, card bodies, and `POST /api/render` for a text the page already shows. `App` keeps the gate, `ROUTES` and the helpers the handlers share; its handlers live in mixins it includes, each a `Web::` module with private methods: `SessionAccessRoutes` (`web/session_access_routes.rb`), `ParticipantAccess` (`web/participant_access.rb`), `ParticipantsAdmin` (`web/participants_admin.rb`), `ParticipantReview` (`web/participant_review.rb`), `MessageDisplay` (`web/message_display.rb`), `SessionShow` (`web/session_show.rb`), `SessionCreate` (`web/session_create.rb`), `SessionControl` (`web/session_control.rb`), `PushRoutes` (`web/push_routes.rb`) and `ProxyGate` (`web/proxy_gate.rb`). Exact paths in `GATE_FREE_PATHS` are served before the token gate (the fragment trade's script, and the Home Screen app's manifest, service worker and icons). `bin/chi web` entrypoint. |
156
+ | Web (Rack) | `Samagotchi::Web::App`, `SessionManager` | Single-port `127.0.0.1:4567` control plane via `rack`+`webrick` (serve `index.html` + `/api/*`). With `web.host: lan` (or one of this machine's IPv4 addresses) the server also listens on that address, where every request needs the access token, this machine's on loopback too (`App#token_gate`: the `chi_token` cookie or `Authorization: Bearer`; chi web prints a `Local:` link with a one-time code; [CLI](cli.md#chi-web-on-your-phone)). Experimental: behind an auth proxy (`web.public_url`, `Web::ProxyGate`) every request needs the token, this machine's too, and one that came through the proxy also the configured identity header for the owner ([configuration.md](configuration.md#behind-an-auth-proxy)). A request goes through, in order: the Host check (`local_host_header?`: loopback names, the LAN address, the public host), the cross-site check (`cross_site?`: Origin this server's or the public origin, no `Sec-Fetch-Site` cross-site/same-site on a POST or `/api`), the frame check (`framed?`: a frame/embed/object load from another origin, 403 `framed`; every HTML answer carries `X-Frame-Options: DENY` and `frame-ancestors 'none'`), the token gate (`token_gate` → `principal_for`, `proxied_principal` for a proxied request; the `?token=` / `POST /api/token` trade sets the cookie, `__Host-chi_token` through the proxy; with `web.public_url` a direct request takes Bearer only, and with it or LAN mode chi's token-less `/api/info` probe gets `probe_info`), then the route's permission (`Principal#permits?`). It talks to each worker's Bridge (`App#relay`, `bridge_get`; `/stream` proxies it) and reads the session files; no `Engine` of its own. Markdown (`web.markdown`) is rendered here only (`Web::MarkdownRenderer`): the answers it serves, card bodies, and `POST /api/render` for a text the page already shows. `App` keeps the gate, `ROUTES` and the helpers the handlers share; its handlers live in mixins it includes, each a `Web::` module with private methods: `SessionAccessRoutes` (`web/session_access_routes.rb`), `ParticipantAccess` (`web/participant_access.rb`), `ParticipantsAdmin` (`web/participants_admin.rb`), `ParticipantReview` (`web/participant_review.rb`), `MessageDisplay` (`web/message_display.rb`), `SessionShow` (`web/session_show.rb`), `SessionCreate` (`web/session_create.rb`), `SessionControl` (`web/session_control.rb`), `PushRoutes` (`web/push_routes.rb`) and `ProxyGate` (`web/proxy_gate.rb`). Exact paths in `GATE_FREE_PATHS` are served before the token gate (the fragment trade's script, and the Home Screen app's manifest, service worker and icons). `bin/chi web` entrypoint. |
157
157
  | Sessions | `Samagotchi::Session`, `SessionManager` | File-based `~/.local/state/samagotchi/sessions/<uuid>.json` + sidecar `input/`/`output/`; retention (14d/500) + ordering (`updated_at desc`). |
158
158
 
159
159
  - `bin/chi` in REPL mode (see `LaunchMode` above) builds `TerminalUI`. `TerminalUI#run` is the single
data/docs/cli.md CHANGED
@@ -445,7 +445,7 @@ of UIs at once: the Web UI and attached terminals (`chi`, `--resume`,
445
445
  `--attach`). They all see the same turns as they happen, and any of them can send
446
446
  a prompt, also while a turn runs (it merges into that turn as steering: the model
447
447
  is told it is a steer and who sent it). Steering waits for the model's next step
448
- and cuts nothing. `/cut TEXT` in a terminal or the web composer (or `chi send --cut`) asks for a cut:
448
+ and cuts nothing; a `task_wait` the model is blocked in ends early for it (the task keeps running). `/cut TEXT` in a terminal or the web composer (or `chi send --cut`) asks for a cut:
449
449
  once the model has streamed only thinking for `steer.cut_after` seconds (per host/model: `steer_cut_after`; default
450
450
  20), now or while your message waits, that generation is cut and the step starts again with your message, marked
451
451
  `↪ cut in for your message` (see
@@ -1249,6 +1249,7 @@ chi web --web-host lan
1249
1249
 
1250
1250
  ```
1251
1251
  Chi Web on http://127.0.0.1:4567/?dir=/Users/me/projects/app (public: …)
1252
+ Local: http://127.0.0.1:4567/?dir=/Users/me/projects/app#code=chc_… ← opens chi web as you once, within 10 minutes (…)
1252
1253
  LAN: http://192.168.1.55:4567/?token=… ← anyone with this link can run commands as you
1253
1254
  Plain http: the link and your traffic can be read by anyone on this Wi-Fi.
1254
1255
  <the link's QR code>
@@ -1264,13 +1265,20 @@ web app on iOS has cookies of its own: if it opens on "needs chi web's
1264
1265
  access token", paste the token there (the part of the link after
1265
1266
  `token=`), or open the link once in it.
1266
1267
 
1267
- - Every request from another machine needs the token (the cookie, or
1268
- `Authorization: Bearer <token>` for curl); this Mac (127.0.0.1) needs none.
1269
- Without it the page says how to get in and the API answers 401.
1268
+ - Every request needs the token (the cookie, or `Authorization: Bearer
1269
+ <token>` for curl), this Mac's on 127.0.0.1 too: a participant's pair turn
1270
+ runs commands here. Without it the page says how to get in (with a box to
1271
+ paste the token) and the API answers 401. On this Mac open the `Local:`
1272
+ link (`chi web --open` opens it): its one-time code (10 minutes, once) sets
1273
+ the cookie for 127.0.0.1 without the token in the address; the LAN link
1274
+ works here too. chi's own probe (`GET /api/info` with no token or cookie)
1275
+ gets a few fields (version, pid, features, the LAN address, the start
1276
+ folder), nothing of the sessions.
1270
1277
  - The token lives in `$XDG_STATE_HOME/samagotchi/web-token` (0600).
1271
1278
  `chi web --new-token` replaces it: a running chi web takes the new one at
1272
1279
  once, and every phone has to scan the new QR code.
1273
- - A second `chi web` prints the LAN link and QR code again. A plain
1280
+ - A second `chi web` prints the LAN link and QR code again, and a new
1281
+ `Local:` link (`--open` opens it). A plain
1274
1282
  `chi web --web-host lan` while a chi web without LAN access runs asks you
1275
1283
  to stop that one first. `chi self` says whether chi web runs on the LAN.
1276
1284
  - `lan` picks the first private address (10.x, 172.16–31.x, 192.168.x) on an
@@ -1414,10 +1422,11 @@ chi --remote work sessions list
1414
1422
  - Forward only chi web's port, never a worker's: a session's Bridge answers
1415
1423
  only chi's own clients on its machine (they read its secret from the
1416
1424
  session's folder), so a forwarded worker port is of no use.
1417
- - Without `web.public_url` there, a request that arrives through the tunnel
1418
- looks local and needs no token, so any process here reaching the forwarded
1419
- port acts as you there. With it set, every request needs the token, which
1420
- `chi --remote` sends as Bearer.
1425
+ - A chi web there with `web.host: lan` or `web.public_url` asks every
1426
+ request for the token, one through the tunnel too (`chi remote add` without
1427
+ `--token-file` says so), and `chi --remote` sends it as Bearer. One on
1428
+ 127.0.0.1 only takes a request through the tunnel as local, with no token,
1429
+ so any process here reaching the forwarded port acts as you there.
1421
1430
  - `-L` binds 127.0.0.1 unless you write `-g` or `0.0.0.0:`: don't.
1422
1431
  - Attaching works through the tunnel too (`chi --remote work --attach ab12`),
1423
1432
  plain http, no proxy.
@@ -1133,6 +1133,10 @@ never asks for a cut, nor does a queued one (`/queue TEXT`, `chi send --queue`,
1133
1133
  thinking is lost. A generation that has streamed visible text or a tool call is never cut, and a plugin's steer
1134
1134
  (`ctx.steer`, `ctx.sessions.send`) never cuts.
1135
1135
 
1136
+ A message that would go in at the next step (any but a queued one, one with images or a participant's) also ends a
1137
+ `task_wait` the model is blocked in, within half a second: the task keeps running, the model is told a message waits,
1138
+ reads it at once and decides whether to wait again. A plugin's steer and a delegate child's report don't end one.
1139
+
1136
1140
  The wait resolves per model, in this order: env `SAMAGOTCHI_STEER_CUT_AFTER`, then
1137
1141
  `models.<key>.steer_cut_after`, `hosts.<name>.steer_cut_after`, `steer.cut_after` in config.yml, then 20. `0` means
1138
1142
  never cut (a slow local thinker is left alone); a bad value warns and is ignored, like the other keys here.
@@ -1662,7 +1666,8 @@ web:
1662
1666
  - It opens the file as it is now, not the agent's copy: after later edits a diff line's number can point elsewhere,
1663
1667
  and a file in a worktree that was removed won't open. The row's diff and output are what the agent saw.
1664
1668
  - Only a viewer on this machine opens the editor: a loopback request, or this machine on its own LAN address
1665
- (`web.host: lan`, the LAN URL or its QR code opened here). A phone gets the targets that need no editor (an
1669
+ (`web.host: lan`, the LAN URL or its QR code opened here; with `web.host: lan` the loopback page needs the token
1670
+ too: open the `Local:` link chi web prints). A phone gets the targets that need no editor (an
1666
1671
  answer's ref shows its row in the turn; see the refs in [sessions.md](sessions.md)); a row's title stays plain
1667
1672
  text there. An SSH tunnel or a local reverse proxy (`tailscale serve`) looks like loopback unless `web.public_url`
1668
1673
  is set (then a request through it is never this machine's viewer), so without it a remote viewer behind one gets an
@@ -2071,7 +2076,7 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
2071
2076
  | `execute.on_timeout` | `task` | yes | What happens to a command still running at that limit: `task` keeps it running as a background task (the result names its `task_id`; `task_wait` waits for it, `task_stop` stops it), `kill` stops it (`Error: command timed out after …s`). The `!cmd` bang always kills. See [Background tasks](internals/background-tasks.md). |
2072
2077
  | `execute.description` | `true` | | `execute` offers the model an optional `description` (a few words on what the command does), shown as the tool row's title in `chi web` and in place of the command in the TUI's tool line. `false`: the parameter isn't declared, and rows show the command's first step. |
2073
2078
  | `web.port` | `4567` | `--port` | `chi web`'s port. See [CLI](cli.md). |
2074
- | `web.host` | `127.0.0.1` | yes | `127.0.0.1`, `::1` or `localhost`; `lan` (this machine's private IPv4 address) or one of its IPv4 addresses also opens `chi web` to the network, with an access token (`chi web --new-token` replaces it). Anything else binds `127.0.0.1` with a warning. See [CLI: chi web on your phone](cli.md#chi-web-on-your-phone). |
2079
+ | `web.host` | `127.0.0.1` | yes | `127.0.0.1`, `::1` or `localhost`; `lan` (this machine's private IPv4 address) or one of its IPv4 addresses also opens `chi web` to the network, with an access token, asked of this machine's 127.0.0.1 too (its `Local:` link carries a one-time code; `chi web --new-token` replaces the token). Anything else binds `127.0.0.1` with a warning. See [CLI: chi web on your phone](cli.md#chi-web-on-your-phone). |
2075
2080
  | `web.markdown` | `false` | yes | Render answers as Markdown in `chi web`. |
2076
2081
  | `web.view` | `stage` | yes | How `chi web` draws a turn: `stage` (the running turn pinned above the composer) or `turn` (one block per turn in the history). See [CLI](cli.md#web-views). |
2077
2082
  | `web.working_set` | `true` | yes | The stage view's working set: the files, memories and URLs a turn brought into the model's context, in a rail in the stage, a footprint line under each finished turn and a `set N` chip in place of the mem chip; `false` brings the mem chip back. `?working_set=1` or `?working_set=0` on the page URL overrides it for that page load; the turn view ignores it. See [CLI: the working set](cli.md#the-working-set). |
data/docs/guardrails.md CHANGED
@@ -346,7 +346,8 @@ participant's turn may call only the `review` kit's tools:
346
346
 
347
347
  - `read`, under the session's project root (the work tree top of its folder)
348
348
  and never a secret: chi's state and config dirs (sessions, the grants, the
349
- web token, memories and their `.bundles`, `config.yml`, hooks), `~/.ssh`,
349
+ web token, memories and their `.bundles`, `config.yml`, hooks, and a
350
+ `hooks.hooks_dir` configured outside the config dir), `~/.ssh`,
350
351
  `~/.gnupg`, `~/.aws`, and the project's `.env*`, `.netrc`, `*.pem`, `*.key`,
351
352
  `*.p12`, anything named `credentials` (`.git-credentials` too), `.npmrc`,
352
353
  `.pypirc`, ssh keys (`id_rsa`, `id_ed25519`, …), `*.tfstate`,
@@ -408,7 +409,11 @@ message:
408
409
  each word resolved as `touches: chi_dirs` resolves it; a glob that matches a
409
410
  secret or whose fixed part leads into a denied folder (`~/.ss?/id_*`,
410
411
  `~/.config/*/config.yml`, `.en?`), a `$var` word whose fixed part does
411
- (`~/.ss${d}h`), and a text match for the rest. A prefix that comes through a
412
+ (`~/.ss${d}h`), with a leading `~`, `$HOME`, XDG dir (its default when
413
+ unset) or `$TMPDIR` expanded (`ls $XDG_CONFIG_HOME/samago*`); after `$TMPDIR`
414
+ the fixed part counts only inside a denied folder, not as the start of one's
415
+ name (an isolated run's state dir is in tmp: `ls $TMPDIR/*` isn't denied);
416
+ and a text match for the rest. A prefix that comes through a
412
417
  variable (`x=~/.s; cat ${x}sh/config`) isn't followed: such a command isn't
413
418
  denied, it asks (its `$var` word and the assignment's path place it outside
414
419
  the project);
@@ -441,10 +446,17 @@ message:
441
446
  (`localhost` and its subdomains, `127.x`, `0.0.0.0`, `[::1]`, `::ffff:127.x`,
442
447
  `0:PORT`) or this machine's host name together with chi web's port (the one
443
448
  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;
449
+ `curl`, `wget`, `nc`, `/dev/tcp`, a script's URL or anything else
450
+ (`PairBoundary::CHI_WEB`): defense in depth, as a LAN chi web asks loopback
451
+ for the token too, and a loopback-only one still takes it as the owner. A
452
+ Bridge answers only with its secret, which sits in chi's state dir;
453
+ - opening a URL or a file in the owner's apps (`open`, `xdg-open`, `gio open`,
454
+ a browser, `osascript`'s `open location`, a script's `webbrowser`) and
455
+ listening on a port (`nc -l`, `socat …LISTEN`, `python -m http.server`,
456
+ `ruby -run -e httpd`, `php -S`, a static server, a script's `listen(`/
457
+ `HTTPServer`/`createServer`): the owner's browser sends its cookies (chi
458
+ web's on 127.0.0.1 or the LAN address, kept for every port) to the page
459
+ it opens (`PairBoundary::OPENER`, `PairBoundary::LISTENER`);
448
460
  - a program whose name the shell expands: a `$var`, a `$(…)` or a glob in a
449
461
  command's name (`bin/ch? …`, `e=ch; ${e}i …`, `$CMD x`), whatever it would
450
462
  turn out to be (`PairBoundary::VERB`);
@@ -705,8 +717,14 @@ the toolkit removes stays refused before the gate.
705
717
  ```
706
718
 
707
719
  All the fields a rule gives must match. Absolute and `**/` globs match the
708
- resolved path; other globs match the path relative to the repo root. Paths
709
- resolve the way the tools resolve them (against the cwd; `~` expanded).
720
+ absolute path; other globs match the path relative to the repo root. A glob
721
+ matches the path as the call gives it (against the cwd, `~` expanded) and as it
722
+ resolves (symlinks, `..` the kernel's way: see How paths resolve), a relative
723
+ glob then from the repo root as it resolves (`/tmp` is `/private/tmp` on
724
+ macOS): `write hl/pre-commit` with `hl` a link to `.git/hooks` matches
725
+ `**/.git/hooks/**`. Rules only ask or deny, so the resolved path only ever adds
726
+ a vote; were a verdict ever to let a call through, a symlink into its glob
727
+ would not count.
710
728
 
711
729
  `outside_repo` is measured from the session's repo: the git work tree its
712
730
  folder is in (a linked worktree is its own), or the folder itself outside a
@@ -732,7 +750,7 @@ name too) is always outside, in the repo or a tmp folder alike
732
750
 
733
751
  Every path check (protected paths, `outside_repo`, `touches`, `rm`, `memory:
734
752
  prompt`, the child, review and pair boundaries, System 1's own-files test)
735
- resolves paths with `Guardrails::ProtectedPaths`:
753
+ resolves paths with `Guardrails::PathResolve` (the map below):
736
754
 
737
755
  - `..` is taken the kernel's way (`.expand`): after a symlinked folder it
738
756
  climbs out of the link's target, so `repo/link/../x` with `link` pointing
@@ -756,7 +774,29 @@ resolved from one isn't resolved), and judged both ways as above; past eight
756
774
  folders, or after `cd -`, the folder is unknown. After `cd link` a `../x`
757
775
  climbs out of the link's target as well. git's `-C`, `--git-dir` and
758
776
  `--work-tree` (and `GIT_DIR`/`GIT_WORK_TREE`) are counted both ways, from each
759
- folder the command may be in.
777
+ folder the command may be in, with a leading `~`, `$HOME`, XDG dir or `$TMPDIR`
778
+ expanded as a `cd` expands it (`PathResolve.expand_word`).
779
+
780
+ On a disk that folds case (macOS), a path is in a protected folder however its
781
+ letters are cased, also where the rest doesn't exist yet (`PathResolve.under?`):
782
+ a write to `<state dir>/CONTEXT/x` is a write to the attached context dir once it
783
+ exists.
784
+
785
+ For whoever changes these checks, one shared helper per primitive, each surface
786
+ keeping its own predicate (`spec/guardrails/path_consistency_spec.rb` pins what
787
+ every surface answers for the same paths):
788
+
789
+ | Primitive | Where | Used by |
790
+ |---|---|---|
791
+ | expand (`..` the kernel's way), real, lexical_real, reals | `PathResolve` (`ProtectedPaths` keeps the old names) | every surface |
792
+ | in a dir: `under?` (case-folded on a folding disk; `fold: false` exact), `strictly_under?`, `within?` (root resolved too) | `PathResolve` | `under?`: ShellPaths, ReviewPaths, PairCommand, ChildBoundary, SelfProtect's words; `Outside.within?` and RmTargets' `strictly_under?` fold only when the dir exists (a missing session root keeps the case it was typed in); `within?`: ProtectedPaths, ScratchWrites, ParentApprovals, SelfProtect's paths |
793
+ | tmp dirs and "not one the session is in" | `PathResolve.tmp_roots(session:)` (exact case) | Outside (`Outside.tmp_roots` is the list it and Targets read), RmTargets; `touches_chi?` takes all of them |
794
+ | a shell word's `~`, `$HOME`, XDG dirs, `$TMPDIR` | `PathResolve.expand_word` | ShellPaths::Walk, ShellGitDirs; PairCommand has its own `~`/`$HOME` (`expand_tilde`) |
795
+ | chi's dirs | `ChiDirs.protected` / `.touches` / `.self_protect` / `.review` | ParentApprovals, Targets, SelfProtect, ReviewPaths (`ProtectedPaths` and `ScratchWrites` get theirs from the wiring) |
796
+
797
+ Left apart on purpose: `Targets.prompt_memory_path?` compares in any case on
798
+ every disk, `PairCommand#leads_into_denied?` matches a partial name (`~/.ss`),
799
+ and each surface resolves its root its own way.
760
800
 
761
801
  `git: outside_repo` (shell tools only) reads the command for git subcommands
762
802
  that change a checkout: `commit`, `add`, `reset`, `checkout`, `switch`,
@@ -766,7 +806,8 @@ that change a checkout: `commit`, `add`, `reset`, `checkout`, `switch`,
766
806
  and the call's `cwd:`, and matches when one of them runs outside the session's
767
807
  repo (the same `outside_repo` as above). `cd /other && git status`, `git -C
768
808
  /other log` and `cd /other && bundle exec rspec` don't match. A folder the text
769
- doesn't tell (`cd $REPO`, `cd "$(…)"`, `cd -`) doesn't either; see Limits.
809
+ doesn't tell (`cd $REPO`, `cd "$(…)"`, `cd -`; a `$XDG_CONFIG_HOME` or
810
+ `$TMPDIR` is told) doesn't either; see Limits.
770
811
 
771
812
  To switch off single rules (a bundle's, say) without editing its files, list
772
813
  them under `disable:`. A plain id switches off every rule with that id; `bundle:id`
@@ -989,12 +1030,15 @@ gate below passes, in this order:
989
1030
  are never auto-allowed, even when listed
990
1031
  (`SystemOne::SelfProtect::NEVER_AUTO`);
991
1032
  4. the call does not change chi itself (`SystemOne::SelfProtect.refuse?`), by
992
- the same test that makes an ask one only the user may answer
993
- (`ParentApprovals.chi_files?`): no edit or write whose paths are in chi's
1033
+ its own set of chi's dirs (`Guardrails::ChiDirs.self_protect`: the dirs whose
1034
+ asks only the user may answer, `ParentApprovals.chi_files?`, plus the whole
1035
+ state dir), wider than that test: no edit or write whose paths are in chi's
994
1036
  config dir (config.yml), the hooks dir, the installed bundles dir, the
995
- guardrails store (where `approvals.json` and `judgments.jsonl` live) or the
996
- attached context dir, and no `execute` or `task_create` command naming any
997
- of them (or chi's state dir). A command is checked twice: by its text
1037
+ guardrails store (where `approvals.json` and `judgments.jsonl` live), the
1038
+ attached context dir or anywhere else in chi's state dir (the sessions), and
1039
+ no `execute` or `task_create` command naming any of them: a write's paths and
1040
+ a `cat` of them answer alike (`SelfProtect.chi_dirs`). `chi_files?` itself
1041
+ still applies on top (its text match, the command spelling a dir). A command is checked twice: by its text
998
1042
  (`ParentApprovals::CHI_TEXT`: `$XDG_CONFIG_HOME/samagotchi/config.yml`,
999
1043
  `~/.config/samagotchi/…`, a `cd ~/.config` then `samagotchi/config.yml`),
1000
1044
  and by its words resolved as `shell-touches-chi` resolves them
@@ -30,6 +30,17 @@ Behavior:
30
30
  - Which tasks are a session's own (a cancelled turn's note, a worker's restart hold, the finish wake below, the web's stop-task button): the ids its `task_create` results and its handed-over executes name (`TaskRuntime.created_ids_in`, `created_id`). An execute's id is read only after its first line (`Execute::HANDED_OVER`, through `TaskRuntime.handed_over_id`; the web has a JS twin in `task_stop.js`), not from a `task_id:` line inside a run, which the command can print. (A command that prints the `---` separator chi joins a turn's results with, then a hand-over line, still fakes one: a known gap.) Either result may start with the `ran as: …` line of a gate-corrected call.
31
31
  - `task_get` returns metadata and `output_path`; use `read` for output contents.
32
32
  - `task_wait` accepts `timeout`, `tail_lines` (maximum 100), and `done_pattern` (a regular expression string).
33
+ - `task_wait`'s results: a finished task's status and tail (no `wait_result`), or one still running with
34
+ `wait_result:` `pattern_matched`, `timeout`, `canceled` (the user's Stop of the turn) or `message_waiting`. One poll
35
+ (0.5 s) checks in that order: finished, pattern matched, Stop, a message waiting, the deadline. `message_waiting`
36
+ ends the wait when a person's message waits for the turn's next step (`Engine#input_waiting?`, run_turn's
37
+ `input_waiting:` probe through the kernel's `PeerView`): in a worker an input file that the next boundary merges
38
+ from the user, `chi send` or a delegating parent (`SessionInbox.steer_waiting?`; never a queued, image or
39
+ participant's one, never a plugin's steer or a child's report, never during a participant's turn), in the REPL
40
+ (`--no-shared`) a line in its steering queue. The probe only peeks: the drain still merges the message at the
41
+ next boundary. The result says `note: a message from the user is waiting; the task is STILL RUNNING.`, how to wait
42
+ again or stop it (`TaskWait::MESSAGE_WAITING_NOTE`), and `waited: Ns`; the task keeps running and stays watched
43
+ (below). A task that finished meanwhile gives its finished result, as after a Stop. A `chi -p` turn has no probe.
33
44
  - `task_create` accepts `env` as a JSON object string for deterministic overrides such as `PATH`; use an absolute interpreter path when that is simpler. Ruby/Bundler isolation variables remain protected.
34
45
  - `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.
35
46
  - 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.
@@ -16,13 +16,14 @@ Engine#run_turn → backend.complete(...) → KernelLoop#run (native) or ChatLoo
16
16
 
17
17
  ```
18
18
  Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:, max_tool_output_chars:,
19
- pending_input:, continue:, origin:, images:, id:, resumed:, resume_from:)
19
+ pending_input:, continue:, origin:, images:, id:, resumed:, resume_from:, input_waiting:)
20
20
  │
21
- ├── begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id:)
21
+ ├── begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id:, input_waiting:)
22
22
  │ # (then, on a continue, turn.resumed = resumed (a step-limit Continue) and
23
23
  │ # turn.resume_from = resume_from (its run's start; nil: it starts cold))
24
24
  │ # @session = session, session.status = RUNNING, used memories absorbed,
25
- │ # TurnState#begin!(controller:, sink:) (a new CancellationController when none is given),
25
+ │ # TurnState#begin!(controller:, sink:, input_waiting:) (a new CancellationController when none
26
+ │ # is given; input_waiting: the caller's peek, Engine#input_waiting?, which task_wait polls),
26
27
  │ # a recap in flight invalidated, GuardrailWiring#begin_turn(origin)
27
28
  │ # Returns a Turn {session, prompt (nil on a continue), continue, on_event, controller,
28
29
  │ # origin, started_at, id (the caller's id:, else a new uuid), resumed, resume_from,
@@ -126,7 +126,15 @@ participant** and **one role**, until it expires or is revoked.
126
126
  2. A `chp_` token (`Grants.token?`) whose grant is active: a **participant** (`Principal.new(kind: :participant,
127
127
  grant:)`), from any peer: loopback and a chi web not on the LAN too. An ended or unknown one is refused
128
128
  (below), never the owner.
129
- 3. No `chp_` token from a loopback peer, or on a chi web not on the LAN: the **owner** (unchanged).
129
+ 3. No `chp_` token on a chi web on loopback only (no `web.host: lan`, no `web.public_url`): the **owner**. On the LAN
130
+ this machine's loopback needs the owner's token too (`App#token_required?`; a pair participant's shell runs here,
131
+ and a token-less `curl 127.0.0.1` was the owner, finding A1); only `GATE_FREE_PATHS` and chi's token-less
132
+ `GET /api/info` probe (`ProxyGate#probe_info?`: no Authorization, no Cookie, its checked fields and `start_dir`)
133
+ are answered without it. `chi web` prints and opens a `Local:` link for 127.0.0.1 with an owner's one-time code
134
+ (`Server.local_owner_link`), the fragment trade sets the cookie there. `PairCommand` still denies a pair command
135
+ naming chi web on a loopback name (defense in depth), and URL openers and listeners: a page opened in the owner's
136
+ browser at a port the shell listens on would catch the owner's cookie (one on 127.0.0.1 or the LAN address goes
137
+ to every port).
130
138
  4. Anything else: nil. `#token_gate` answers a bound grant's token without its identity **401 `token_bound`**, an
131
139
  ended grant's token **401 `token_revoked`** or **401
132
140
  `token_expired`** (the page says the same words as a sentence: "This review link was revoked by the session's
@@ -138,8 +146,9 @@ The `?token=` trade (and `POST /api/token`, with a link's one-time `{code}` or t
138
146
  presented, the owner's or the participant's
139
147
  (one at a time per browser: a participant link opened in the owner's browser replaces the owner's cookie there, and
140
148
  makes that browser the participant, on loopback too). A grant's token is traded on any peer; the owner's only where
141
- the gate asks for it. On a peer the gate doesn't ask, the 401 for a grant's cookie that no longer works clears the
142
- cookie, so that browser is the owner's again at its next request.
149
+ the gate asks for it (on the LAN, this machine's loopback too). On a chi web the gate doesn't ask (loopback only), the
150
+ 401 for a grant's cookie that no longer works clears the cookie, so that browser is the owner's again at its next
151
+ request; on the LAN that browser gets the ended page and opens the owner's link again.
143
152
 
144
153
  Then `#route`, for each matching `ROUTES` row:
145
154
 
@@ -277,7 +286,7 @@ queued, as `--turn T` does.
277
286
  reads of the project ([guardrails](../guardrails.md#a-participants-review-boundary)). In a working session
278
287
  `Guardrails::PairBoundary` takes its seat: the owner's checks and rules decide the rest, as for the owner's turns
279
288
  ([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;
289
+ (`PathResolve.reals`: a `..` after a symlinked folder climbs out of its target, as the read opens it;
281
290
  [guardrails](../guardrails.md#how-paths-resolve)). Approvals and questions are owner-only: either
282
291
  boundary denies the model's own question, and `/answer` is an owner route. A pair turn's ask reaches the owner's card
283
292
  naming who asks (`approval.author`, `author_name`; `Guardrails::Approval::PARTICIPANT_HEADER`) with Allow once as its
@@ -195,10 +195,11 @@ with a "Machine-local" comment; the rest branch on `@access.remote?` (`SendComma
195
195
 
196
196
  ## Trust model
197
197
 
198
- - **The token is the owner.** In LAN mode (`web.host: lan`) every non-loopback request needs the `Web::Token` access
199
- token, as the `chi_token` cookie or `Authorization: Bearer` (`App#token_gate`). This runs over plain http, which is
200
- fine on a home LAN, the same as the phone page. A loopback chi web without LAN mode needs no token
201
- (`Remotes::Entry#token` is nil, and no header is sent). Behind an auth proxy (`web.public_url`, experimental) every
198
+ - **The token is the owner.** In LAN mode (`web.host: lan`) every request needs the `Web::Token` access token,
199
+ loopback too (but chi's token-less `/api/info` probe, `ProxyGate#probe_info?`), as the `chi_token` cookie or
200
+ `Authorization: Bearer` (`App#token_gate`). This runs over plain http, which is fine on a home LAN, the same as the
201
+ phone page. A loopback chi web without LAN mode needs no token (`Remotes::Entry#token` is nil, and no header is
202
+ sent); `chi remote add` with no token refuses one that answers only the probe (no `cwd`: `RemoteCommand#check`). Behind an auth proxy (`web.public_url`, experimental) every
202
203
  request needs it, loopback too, and a proxied owner also the configured identity header
203
204
  ([configuration.md: Behind an auth proxy](../configuration.md#behind-an-auth-proxy), `Web::ProxyGate`); `chi
204
205
  --remote` through such a proxy isn't supported yet (the CLI can't pass its sign-in, and streams need TLS).
data/docs/sessions.md CHANGED
@@ -6,7 +6,7 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
6
6
 
7
7
  **Verify verdicts:** a web Verify turn stamps its opening user message with `verify: {turn_id, quote}` (the selected source turn and quote; metadata only, never added to the prompt). When that turn completes and the final assistant reply's last non-empty line is a valid `Verdict: confirmed|refuted|unclear — <reason>`, the session file appends `{anchor: {turn_id, quote}, verdict, reason, by, check_turn_id, at}` to its optional `verdicts` array. `by` is the turn's author (nil for the owner), `check_turn_id` names the Verify turn, and `at` is its timestamp. The array is append-only; older files without it have no saved verdicts. An `unclear` verdict can be checked again by **Verify fresh** (a delegate child that gets only the claim; see *Delegating*): its verdict is appended to the parent's `verdicts` with `fresh: true`, `of` (the verdict it checked again) and `child_id`, from the parent's `fresh_verdicts/` dir, which its worker takes between turns. Verdicts are display state: none enters a prompt.
8
8
 
9
- **Input files:** a message for a session's worker waits in `input/<timestamp>.json` (`SessionInbox.write_input`: `prompt`, `client_id`, `enqueued_id`, and `images`, `no_interrupt` and `delivery` when they apply; `delivery` is written only for `"queue"`). While a turn runs, the worker merges them at each step boundary in name order, except a queued one or one with images: those wait for the turn's end and run one turn each, in name order, and a file after them still merges (`Worker#pending_input_drain`). A queue is sent only to a worker whose `bridge.json` names the `delivery` feature; an older one would steer it, so `SessionManager.deliver_turn` refuses it (409 `delivery_unavailable`), and any other 4xx the Bridge answers is a refusal too, never a fallback file (except a 413, a body over its size limit: that one still goes in as a file, its `delivery` kept). A session command recognized in a file runs without a turn; its outcome is kept at `input/settled/<enqueued_id>` (`completed`, `failed` for a command error, `canceled` with `cancel_reason` `busy` or `dropped` for one refused mid-turn or dropped as the worker left; `origin: command`) so `chi send --wait` ends instead of waiting for a turn that will not run, its detail saying `the command failed` or `the command was refused (busy)` rather than a turn's words. **Reply files:** a turn that ends with visible text leaves its reply in `output/<timestamp>.txt`, or `output/<timestamp>-<enqueued_id>.txt` when the turn ran an input file with an id (`SessionInbox.write_output`), and the session's `last_turn` keeps that `enqueued_id`; so a wait for one queued message (`chi send --queue --wait`, `ReplyWait`'s `turn:`) finds its own turn's reply or end, never another turn's. The names still sort by time, and every reader takes `*.txt`.
9
+ **Input files:** a message for a session's worker waits in `input/<timestamp>.json` (`SessionInbox.write_input`: `prompt`, `client_id`, `enqueued_id`, and `images`, `no_interrupt` and `delivery` when they apply; `delivery` is written only for `"queue"`). While a turn runs, the worker merges them at each step boundary in name order, except a queued one or one with images: those wait for the turn's end and run one turn each, in name order, and a file after them still merges (`Worker#pending_input_drain`). Such a file, with a prompt, from the user, `chi send` or a delegating parent, also ends a running `task_wait` (`SessionInbox.steer_waiting?`, a peek the worker's turn polls through `Engine#input_waiting?`; never in a participant's turn): the file stays for the drain. A queue is sent only to a worker whose `bridge.json` names the `delivery` feature; an older one would steer it, so `SessionManager.deliver_turn` refuses it (409 `delivery_unavailable`), and any other 4xx the Bridge answers is a refusal too, never a fallback file (except a 413, a body over its size limit: that one still goes in as a file, its `delivery` kept). A session command recognized in a file runs without a turn; its outcome is kept at `input/settled/<enqueued_id>` (`completed`, `failed` for a command error, `canceled` with `cancel_reason` `busy` or `dropped` for one refused mid-turn or dropped as the worker left; `origin: command`) so `chi send --wait` ends instead of waiting for a turn that will not run, its detail saying `the command failed` or `the command was refused (busy)` rather than a turn's words. **Reply files:** a turn that ends with visible text leaves its reply in `output/<timestamp>.txt`, or `output/<timestamp>-<enqueued_id>.txt` when the turn ran an input file with an id (`SessionInbox.write_output`), and the session's `last_turn` keeps that `enqueued_id`; so a wait for one queued message (`chi send --queue --wait`, `ReplyWait`'s `turn:`) finds its own turn's reply or end, never another turn's. The names still sort by time, and every reader takes `*.txt`.
10
10
 
11
11
  **Removing a queued message:** a message queued for after the running turn (`chi send --queue`, the web's Alt+Enter, `/queue TEXT` in a terminal), or one with images (it waits for the turn's end too), can be taken back while it still waits: the worker's Bridge (`POST /session/:id/turn/dequeue {enqueued_id}`, the `unqueue` feature) deletes its input file and every UI drops it (`:turn_dequeued`). It is a race with the turn's end, decided by the same rename the worker uses to take the file: once a turn took it the answer is `409 already_started` (it runs), and an id the worker no longer knows is `404 unknown_enqueued_id`. A plain message sent without `--queue` is not removable (`409 not_queued`): a running turn's next step may take it at any moment. A wait for its turn (`chi send --queue --wait`, a delegate follow-up's wait) ends at once with `removed` (the tombstone `input/removed/<enqueued_id>` says so), and a `chi -p` run whose queued prompt went ends with exit 1. The `200` carries the removed `prompt`: the web and the attached terminal put it back into the composer to edit (not its images). The images a removed message named stay in the session's `images/`. Not removable: a queued session command (`/queue /model x`: it waits in the worker, not in `input/`), a message in a stopped worker's inbox (the next worker runs it), and the plain REPL's (`--no-shared`) own queue.
12
12
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.129.0
3
+ version: 0.130.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:
@@ -664,6 +664,19 @@ module Samagotchi
664
664
  @turn_state.controller
665
665
  end
666
666
 
667
+ # Whether a message a person sent waits for the running turn's next
668
+ # boundary (run_turn's input_waiting: probe): a task_wait returns on it
669
+ # (PeerView#input_waiting?). False outside a turn, for a turn given no
670
+ # probe, and when the probe raises (warned once a turn; the tool never
671
+ # fails for it).
672
+ def input_waiting?
673
+ probe = @turn_state.input_waiting
674
+ !!probe&.call
675
+ rescue StandardError => e
676
+ Log.warn(:turn, "input_waiting_failed", error: e.class.name, msg: e.message) if @turn_state.input_waiting_failed!
677
+ false
678
+ end
679
+
667
680
  # Cancel the currently running turn, if any.
668
681
  # @param reason [Symbol] cancellation reason
669
682
  # @return [Boolean] whether a cancellation was triggered
@@ -2132,6 +2145,9 @@ module Samagotchi
2132
2145
  # @param pending_input [#call, nil] optional drain proc returning
2133
2146
  # Array<String> of steering messages queued while the turn runs; drained
2134
2147
  # by the agentic loop at iteration boundaries (see KernelLoop#run).
2148
+ # @param input_waiting [#call, nil] optional probe: true while a message
2149
+ # a person sent waits for the next boundary (it peeks, never drains);
2150
+ # a running task_wait returns on it (#input_waiting?). nil: never.
2135
2151
  # @param continue [Boolean] resume the conversation without appending a
2136
2152
  # user prompt (continue after the iteration limit, reminder turns);
2137
2153
  # +prompt+ is ignored and :turn_started carries `continue: true`
@@ -2170,9 +2186,9 @@ module Samagotchi
2170
2186
  # re-raises; a provider error adds error_kind:, retryable:, host: and a
2171
2187
  # one-line summary:.
2172
2188
  def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false,
2173
- origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil, verify: nil)
2189
+ origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil, verify: nil, input_waiting: nil)
2174
2190
  turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
2175
- continue: continue, id: id, verify: verify)
2191
+ continue: continue, id: id, verify: verify, input_waiting: input_waiting)
2176
2192
  if continue
2177
2193
  turn.resumed = resumed
2178
2194
  turn.resume_from = resume_from
@@ -2220,7 +2236,7 @@ module Samagotchi
2220
2236
  # The turn's state before anything can fail it: the session running,
2221
2237
  # the turn flag, its cancel controller and sink. The cross-thread state
2222
2238
  # is set here and cleared in #release_turn only.
2223
- def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil, verify: nil)
2239
+ def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil, verify: nil, input_waiting: nil)
2224
2240
  origin = TurnOrigin.of(origin)
2225
2241
  # A participant's Verify fresh child: every turn is theirs (their
2226
2242
  # author, boundary and cap), whoever started it.
@@ -2240,7 +2256,7 @@ module Samagotchi
2240
2256
  # invalidated recap) while the model is working. A hook's notice goes
2241
2257
  # where the turn's events go (the REPL renders only its sink; a
2242
2258
  # worker's observers carry it to the bridge).
2243
- @turn_state.begin!(controller: effective_controller, sink: on_event)
2259
+ @turn_state.begin!(controller: effective_controller, sink: on_event, input_waiting: input_waiting)
2244
2260
  # Drop a recap already in flight: the turn makes it stale.
2245
2261
  @recap&.invalidate!
2246
2262
  # The gate's context: who queued this turn, and git asked afresh.
@@ -8,7 +8,8 @@ module Samagotchi
8
8
  # The kernel's Tools::Peers, following the current session. cancelled?
9
9
  # is the running turn's cancel, for a tool that waits (delegate_result):
10
10
  # the controller is set from another thread and a tool gets no other
11
- # way to see it.
11
+ # way to see it. input_waiting? is whether a person's message waits for
12
+ # the running turn's next boundary (task_wait returns on it).
12
13
  PeerView = Struct.new(:engine) do
13
14
  def session_id = engine.session&.id
14
15
  def cwd = engine.session&.working_directory
@@ -16,6 +17,7 @@ module Samagotchi
16
17
  def state_dir = engine.peer_state_dir
17
18
  def cancelled? = !!engine.active_cancel_controller&.cancelled?
18
19
  def cancel_reason = engine.active_cancel_controller&.reason
20
+ def input_waiting? = engine.input_waiting?
19
21
  def relay = engine.relay_peer
20
22
  def model_ref = engine.effective_model_ref
21
23
  def thinking = engine.thinking_override
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../config"
4
+ require_relative "../paths"
5
+ require_relative "../memory_paths"
6
+ require_relative "../hooks/loader"
7
+ require_relative "path_resolve"
8
+
9
+ module Samagotchi
10
+ module Guardrails
11
+ # chi's own dirs, the sets the guardrails judge a path or a command's
12
+ # words against, as this process's environment and config.yml name them
13
+ # (read each call). Each surface keeps its set:
14
+ # .protected the config dir, the hooks dir, the approval store's
15
+ # dir, the installed bundles' dir, attached context: an
16
+ # ask only the user may answer (ParentApprovals.chi_files?)
17
+ # .touches .protected and the session repo's git hooks dir: a
18
+ # rule's touches: chi_dirs (Targets#touches_chi?)
19
+ # .self_protect .protected and the whole state dir, resolved: what
20
+ # System 1 never answers for (SelfProtect)
21
+ # .review the state dir, the config dir and a hooks dir
22
+ # configured outside it: what a participant's turn never
23
+ # reads (ReviewPaths)
24
+ # ProtectedPaths' deny/ask lists and ScratchWrites' memories dir are
25
+ # built by the wiring from the same places.
26
+ module ChiDirs
27
+ module_function
28
+
29
+ # chi's config dir, the hooks dir, the approval store's dir, the
30
+ # installed bundles' dir and attached context.
31
+ def protected
32
+ hooks = begin Hooks::Loader.hooks_dir rescue nil end
33
+ bundles = begin MemoryPaths.bundles_dir rescue nil end
34
+ [ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails"), bundles, File.join(Paths.state_dir, "context")]
35
+ .compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
36
+ end
37
+
38
+ # .protected (as ParentApprovals.chi_dirs, the name its callers and
39
+ # specs know) and the git hooks dir of +base+'s repo (+context+).
40
+ def touches(context, base)
41
+ require_relative "parent_approvals"
42
+ ParentApprovals.chi_dirs + [context.hooks_dir(base)].compact
43
+ end
44
+
45
+ # .protected (as ParentApprovals.chi_dirs) and the whole state dir,
46
+ # resolved.
47
+ def self_protect
48
+ require_relative "parent_approvals"
49
+ [*ParentApprovals.chi_dirs, Paths.state_dir]
50
+ .map { |dir| PathResolve.real(dir.to_s.chomp("/")) }.reject(&:empty?).uniq
51
+ end
52
+
53
+ # chi's state dir and config dir, as Paths has them now, and the hooks
54
+ # dir when config.yml puts it outside the config dir (hooks run as the
55
+ # owner: a participant reads none of them).
56
+ def review
57
+ config = File.join(Paths.config_home, Paths::APP_DIR)
58
+ hooks = begin Hooks::Loader.hooks_dir rescue nil end
59
+ hooks = nil if hooks.to_s.empty? || PathResolve.reals(hooks).any? { |real| PathResolve.within?(real, config) }
60
+ [Paths.state_dir, config, hooks].compact
61
+ end
62
+ end
63
+ end
64
+ end