samagotchi 0.128.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +126 -1
  3. data/docs/architecture.md +13 -6
  4. data/docs/cli.md +20 -10
  5. data/docs/configuration.md +7 -2
  6. data/docs/guardrails.md +202 -33
  7. data/docs/internals/background-tasks.md +11 -0
  8. data/docs/internals/engine.md +4 -3
  9. data/docs/internals/participants.md +29 -6
  10. data/docs/internals/remote-sessions.md +5 -4
  11. data/docs/internals/web-push.md +4 -1
  12. data/docs/sessions.md +2 -2
  13. data/lib/samagotchi/bridge_client.rb +8 -2
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/engine.rb +20 -4
  16. data/lib/samagotchi/engine_peers.rb +3 -1
  17. data/lib/samagotchi/guardrail_wiring.rb +6 -5
  18. data/lib/samagotchi/guardrails/chi_dirs.rb +64 -0
  19. data/lib/samagotchi/guardrails/child_boundary.rb +26 -16
  20. data/lib/samagotchi/guardrails/outside.rb +68 -17
  21. data/lib/samagotchi/guardrails/pair_boundary.rb +89 -26
  22. data/lib/samagotchi/guardrails/pair_command.rb +327 -72
  23. data/lib/samagotchi/guardrails/parent_approvals.rb +8 -10
  24. data/lib/samagotchi/guardrails/path_resolve.rb +231 -0
  25. data/lib/samagotchi/guardrails/protected_paths.rb +18 -24
  26. data/lib/samagotchi/guardrails/read_only_shell.rb +191 -8
  27. data/lib/samagotchi/guardrails/review_paths.rb +30 -14
  28. data/lib/samagotchi/guardrails/rm_targets.rb +7 -6
  29. data/lib/samagotchi/guardrails/rules.rb +23 -5
  30. data/lib/samagotchi/guardrails/scratch_writes.rb +4 -1
  31. data/lib/samagotchi/guardrails/shell_git_dirs.rb +75 -38
  32. data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
  33. data/lib/samagotchi/guardrails/shell_paths.rb +85 -48
  34. data/lib/samagotchi/guardrails/targets.rb +131 -29
  35. data/lib/samagotchi/remote_command.rb +13 -3
  36. data/lib/samagotchi/remotes.rb +3 -2
  37. data/lib/samagotchi/review_start.rb +3 -2
  38. data/lib/samagotchi/session_inbox.rb +17 -0
  39. data/lib/samagotchi/session_manager.rb +9 -15
  40. data/lib/samagotchi/system_one/self_protect.rb +127 -20
  41. data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
  42. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  43. data/lib/samagotchi/terminal_ui.rb +2 -0
  44. data/lib/samagotchi/tool_declarations.rb +1 -1
  45. data/lib/samagotchi/tools/builtins.rb +10 -1
  46. data/lib/samagotchi/tools/peers.rb +4 -0
  47. data/lib/samagotchi/tools/task_wait.rb +26 -1
  48. data/lib/samagotchi/turn_state.rb +23 -4
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +32 -25
  51. data/lib/samagotchi/web/proxy_gate.rb +8 -6
  52. data/lib/samagotchi/web/public/app.js +8 -1
  53. data/lib/samagotchi/web/public/participant.js +2 -0
  54. data/lib/samagotchi/web/public/verdict_fresh.js +12 -5
  55. data/lib/samagotchi/web/server.rb +29 -4
  56. data/lib/samagotchi/worker.rb +91 -33
  57. data/lib/samagotchi/worker_idle_exit.rb +17 -0
  58. data/lib/samagotchi/worker_link.rb +13 -5
  59. metadata +3 -1
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
@@ -331,13 +346,16 @@ participant's turn may call only the `review` kit's tools:
331
346
 
332
347
  - `read`, under the session's project root (the work tree top of its folder)
333
348
  and never a secret: chi's state and config dirs (sessions, the grants, the
334
- 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`,
335
351
  `~/.gnupg`, `~/.aws`, and the project's `.env*`, `.netrc`, `*.pem`, `*.key`,
336
352
  `*.p12`, anything named `credentials` (`.git-credentials` too), `.npmrc`,
337
353
  `.pypirc`, ssh keys (`id_rsa`, `id_ed25519`, …), `*.tfstate`,
338
354
  `secrets.yml`/`.json`, `.docker/config.json`, `.kube/config` and any config
339
355
  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
356
+ resolves, so a symlink in the project to `~/.ssh` is `~/.ssh`, and
357
+ `node_modules/foo/../../.config/gh/hosts.yml` with `foo` a link to `~/src/foo`
358
+ is `~/.config/gh/hosts.yml` ([How paths resolve](#how-paths-resolve)). A session
341
359
  whose folder is the home folder or above it reads nothing.
342
360
  - `memory_read` with `scope: project` (a read without a scope would fall back
343
361
  to the system memories). The prompt's preloaded memories and the memory
@@ -391,31 +409,67 @@ message:
391
409
  each word resolved as `touches: chi_dirs` resolves it; a glob that matches a
392
410
  secret or whose fixed part leads into a denied folder (`~/.ss?/id_*`,
393
411
  `~/.config/*/config.yml`, `.en?`), a `$var` word whose fixed part does
394
- (`~/.ss${d}h`), and a text match for the rest;
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
417
+ variable (`x=~/.s; cat ${x}sh/config`) isn't followed: such a command isn't
418
+ denied, it asks (its `$var` word and the assignment's path place it outside
419
+ the project);
395
420
  - sweeping a folder that holds secrets: `find`, `tar`, `cp`, `rsync`, `scp`,
396
421
  `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
422
+ a denied one, and linking to one (`ln -s ~/.config g`, `ln -s ../.. up`);
423
+ - the environment, the keychain and stored credentials: `env` or `printenv`
424
+ alone, `set`, `export`, `declare` or `typeset` that list, `launchctl
425
+ getenv`, `ps e`/`ps -E`, `security`, `/proc/*/environ`, and the commands
426
+ that print a credential (`gh auth token`, `git credential fill` and the
427
+ `git credential-*`/`docker-credential-*` helpers, `gcloud … print-access-token`,
428
+ `op`, `aws configure get`/`export-credentials`, `az account
429
+ get-access-token`, `heroku auth:token`, `kubectl config view --raw`;
430
+ `PairCommand::CREDENTIAL_PRINTERS`). On top of it, a pair turn's `execute` runs
401
431
  without every variable whose name matches `KEY|TOKEN|SECRET|PASS|CREDENTIAL|AUTH`
402
432
  (`PairBoundary::SECRET_ENV_NAME`, case-insensitive), and a command still
403
433
  running at the limit is killed, never handed over as a background task;
404
434
  - git settings that make git run a command (`core.hooksPath`,
405
435
  `core.fsmonitor`, `core.sshCommand`, `core.pager`, `core.editor`, a `!`
406
436
  alias) by `git config` or `git -c`, and `git config --global`/`--system`;
437
+ and writing git's hooks: a `write` or `edit` of a path in a `.git/hooks`
438
+ folder (as given or as it resolves), or a command that isn't read-only and
439
+ names one (`printf … > .git/hooks/pre-commit`, `cp x .git/hooks/`);
407
440
  - running chi, by any wrapper (`bin/chi`, `bundle exec chi`, `ruby … bin/chi`,
408
441
  `env … chi`, `watch`, `npx`, `sh -c 'chi …'`, `$(chi …)`), in an assignment's
409
442
  value or an alias, a function, after `-exec`/`-c`/`-e`, but `chi --version` and
410
443
  `chi --help`: its commands act in the owner's name (shares, caps, sends,
411
444
  answers, sessions);
445
+ - calling chi web on this machine: a command whose text names a loopback name
446
+ (`localhost` and its subdomains, `127.x`, `0.0.0.0`, `[::1]`, `::ffff:127.x`,
447
+ `0:PORT`) or this machine's host name together with chi web's port (the one
448
+ the running chi web recorded in `web.json`, config's `web.port`, 4567), by
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`);
460
+ - a program whose name the shell expands: a `$var`, a `$(…)` or a glob in a
461
+ command's name (`bin/ch? …`, `e=ch; ${e}i …`, `$CMD x`), whatever it would
462
+ turn out to be (`PairBoundary::VERB`);
412
463
  - leaving something running: `&` at a command's end, `nohup`, `setsid`,
413
- `disown`, `screen -d…`, `tmux new -d`;
464
+ `disown`, `screen -d…`, `tmux new -d`, and scheduling it: `crontab`, `at`,
465
+ `batch`;
414
466
  - owner-voiced tools, which act in the owner's name beyond the turn:
415
467
  `delegate` and any `delegate_*` tool, `register_reminder`,
416
468
  `cancel_reminder`, `send_note`, `forget_outputs`, `task_create`,
417
469
  `task_stop`, `find_tools` (it loads tools into the session), and
418
- `memory_write` (a memory reaches the owner's later sessions);
470
+ `memory_write` (a memory reaches the owner's later sessions); and the tools
471
+ that read the owner's background tasks and their output, `task_wait`,
472
+ `task_get`, `task_list` (a pair turn starts none);
419
473
  - `ask_user_question`, with the advice to ask in the reply: nobody on the
420
474
  participant's side answers a card.
421
475
 
@@ -426,13 +480,41 @@ own, one another interpreter runs (`ruby -e`, `python - <<EOF`) by its text
426
480
  `tee`, `git commit -F -`) is not looked into: where it goes is checked. A
427
481
  command the checks can't read is refused, never let through on a text match.
428
482
 
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.
483
+ And it **asks the owner** before a `write` or `edit` that may land outside the
484
+ session's project root, and before a command that may write there, every time:
485
+ the ask is fresh (`Verdict#fresh_ask`: no stored approval answers it, and no
486
+ rule lowers it), so it comes even in auto mode. A command fails closed
487
+ (`PairCommand#outside_writes?`): it runs unasked only when it
488
+
489
+ - only reads (`ReadOnlyShell.read_only?`, wherever it reads), or
490
+ - only reads but for writes it can name (a `>`/`>>` target, `cp`'s
491
+ destination, `tee`'s files) into the project or a tmp dir
492
+ (`ReadOnlyShell.read_only_but_writes?`), or
493
+ - stays inside the project: every part of it runs in the project, and every
494
+ path it names lands in the project or a tmp dir, an option's or an
495
+ assignment's value (`of=`, `--output=`, `GIT_DIR=`), a glob's fixed part
496
+ (`~/oth*`), `git -C`'s folder and the paths in a script it hands on (`sh -c`
497
+ read as a command, `python -c`/`ruby -e`/a heredoc by the paths in its text)
498
+ among them. The program's own word doesn't count (`/usr/bin/sed`), nor does
499
+ `PATH=…`, nor a message's heredoc (`git commit -m "$(cat <<'EOF' … EOF)"`).
500
+
501
+ Anything else asks: `rm -rf ~/x`, `sed -i … ~/x`, `curl -o ~/x`, `dd of=~/x`,
502
+ `touch`/`mkdir` outside, `perl -pi … ~/x`, `cd .. && make`, `cd && make`, and
503
+ whatever the checks can't place: a `$var` (`rm -rf $DIR`, `> "$OUT"`), a `$(…)`,
504
+ `xargs`, a `cd $X`. A path "lands" in the project or a tmp dir every way it
505
+ resolves ([How paths resolve](#how-paths-resolve)) and never through a dangling
506
+ symlink or onto a file with another hard link (`Outside.inside?`,
507
+ `Outside.tmp_write?`, the child boundary's tmp rule): the tmp dirs are
508
+ `Dir.tmpdir`, `/tmp` and `/var/tmp`, except the one the project itself is in.
509
+ `/dev/null` and the like neither ask nor deny. A script file's body (`ruby
510
+ x.rb`, `make`, `sh ./run.sh`) isn't read: it runs as in-project work.
511
+ A command that makes a link asks too, wherever it points (`ln`, `link`, `cp
512
+ -s`/`-l`, `sh -c 'ln …'`, an interpreter's `symlink`/`link(`): a path through
513
+ a link the same command makes is judged before the link exists, so `ln -s ..
514
+ up && cat up/…` can't be read for what it reaches.
515
+ A session whose folder is the home folder or above it has no project to
516
+ keep the turn in (`ReviewPaths.home_root?`, the review boundary's test): every
517
+ write there asks.
436
518
 
437
519
  A participant's ask names them on the owner's card ("Sam's turn asks: approve
438
520
  tool call?", `approval.author`/`author_name`, `approval.asked_by`, the same
@@ -448,7 +530,10 @@ The trust model is a team's (everyone invited is trusted): the boundary is an
448
530
  hostile participant; a review session is. A pair turn can still reach, through
449
531
  the owner's rules: `web_fetch` (an open egress path: what the turn can read it
450
532
  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
533
+ `list_sessions` (the owner's other sessions' titles), chi web by a name the
534
+ check doesn't know (a DNS name or a decimal address for 127.0.0.1, a port
535
+ forward, a port spelt in a variable), where a LAN chi web still takes a
536
+ token-less loopback request as the owner's, and anything a script
452
537
  does that the command checks don't read (a script file's body, an interpreter's
453
538
  `-e` code). Share a working session only with people you would let type in it.
454
539
 
@@ -564,7 +649,10 @@ only `run list|view`, `pr list|view|diff|checks`, `issue list|view` and
564
649
  `repo view`; `--web`/`-w`, `gh api` and `pr checks --watch` are not read-only.
565
650
  `$(…)`, backticks, `$VAR` (other than `$HOME`, `$XDG_*`, `$TMPDIR` and `$PWD`
566
651
  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.
652
+ like make a command not read-only, as does a `task_create` `env:` that sets `BASH_ENV`, `ENV`, `CDPATH`, `IFS`,
653
+ `SHELLOPTS`, `BASHOPTS`, `BASH_FUNC_…`, `LD_PRELOAD`, `LD_AUDIT`, `DYLD_INSERT_LIBRARIES`, `GIT_EXTERNAL_DIFF`,
654
+ `GIT_CONFIG_PARAMETERS` or `GIT_CONFIG_COUNT` (they change what its words run; `PATH` doesn't count). It errs
655
+ towards asking.
568
656
 
569
657
  A tool name may be a glob, so one rule covers a plugin's tools (an MCP server's,
570
658
  say): `tool: "mcp_*"` or `tool: ["mcp_{git,gh}_*", web_fetch]` (`*`, `?`, `[…]` and
@@ -629,8 +717,14 @@ the toolkit removes stays refused before the gate.
629
717
  ```
630
718
 
631
719
  All the fields a rule gives must match. Absolute and `**/` globs match the
632
- resolved path; other globs match the path relative to the repo root. Paths
633
- 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.
634
728
 
635
729
  `outside_repo` is measured from the session's repo: the git work tree its
636
730
  folder is in (a linked worktree is its own), or the folder itself outside a
@@ -647,6 +741,63 @@ one folder; a link in the repo that points elsewhere is outside). Never outside:
647
741
  - a tmp folder (`$TMPDIR`, `/tmp`, `/var/tmp`), unless the session's repo is
648
742
  itself in that tmp folder: a sandbox there still gets asked about its siblings.
649
743
 
744
+ A path that is or goes through a dangling symlink (a write would create the
745
+ link's target) or a file with another hard link (a write changes the other
746
+ name too) is always outside, in the repo or a tmp folder alike
747
+ (`Outside.resolve`, which the child boundary's tmp writes read as well).
748
+
749
+ #### How paths resolve
750
+
751
+ Every path check (protected paths, `outside_repo`, `touches`, `rm`, `memory:
752
+ prompt`, the child, review and pair boundaries, System 1's own-files test)
753
+ resolves paths with `Guardrails::PathResolve` (the map below):
754
+
755
+ - `..` is taken the kernel's way (`.expand`): after a symlinked folder it
756
+ climbs out of the link's target, so `repo/link/../x` with `link` pointing
757
+ to `/elsewhere/deep` is `/elsewhere/x`, not `repo/x`;
758
+ - symlinks are resolved on the longest part that exists, and a dangling
759
+ symlink is followed to the file a write through it would create (`.real`);
760
+ - a check reads the path both ways, the lexical answer (`File.expand_path`,
761
+ stopping at a dangling link) and where the kernel lands (`.reals`): it
762
+ refuses, asks or votes when either is protected, outside or a secret, so
763
+ the kernel's answer only ever adds to the lexical one. A file tool call's
764
+ paths (`Targets#paths`, what approvals show) list both when they differ.
765
+
766
+ In a shell command a file argument resolves the same way, against the folder
767
+ the command is in by then. A `cd` or `pushd` to such a path may land either
768
+ way: `cd link/..` is back where it started only while that logical folder
769
+ exists (zsh, which chi's shell is on macOS, and bash chdir to the physical one
770
+ when it doesn't), and `cd -P`, `set -P` and zsh's `chase_links`/`chase_dots`
771
+ always take the physical one. So after it the command counts as in both
772
+ folders: each later word is resolved against each (a word that can't be
773
+ resolved from one isn't resolved), and judged both ways as above; past eight
774
+ folders, or after `cd -`, the folder is unknown. After `cd link` a `../x`
775
+ climbs out of the link's target as well. git's `-C`, `--git-dir` and
776
+ `--work-tree` (and `GIT_DIR`/`GIT_WORK_TREE`) are counted both ways, from each
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.
800
+
650
801
  `git: outside_repo` (shell tools only) reads the command for git subcommands
651
802
  that change a checkout: `commit`, `add`, `reset`, `checkout`, `switch`,
652
803
  `rebase`, `merge`, `push`, `stash` (not `stash list`/`show`), `rm`, `mv`,
@@ -655,7 +806,8 @@ that change a checkout: `commit`, `add`, `reset`, `checkout`, `switch`,
655
806
  and the call's `cwd:`, and matches when one of them runs outside the session's
656
807
  repo (the same `outside_repo` as above). `cd /other && git status`, `git -C
657
808
  /other log` and `cd /other && bundle exec rspec` don't match. A folder the text
658
- 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.
659
811
 
660
812
  To switch off single rules (a bundle's, say) without editing its files, list
661
813
  them under `disable:`. A plain id switches off every rule with that id; `bundle:id`
@@ -878,17 +1030,34 @@ gate below passes, in this order:
878
1030
  are never auto-allowed, even when listed
879
1031
  (`SystemOne::SelfProtect::NEVER_AUTO`);
880
1032
  4. the call does not change chi itself (`SystemOne::SelfProtect.refuse?`), by
881
- the same test that makes an ask one only the user may answer
882
- (`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
883
1036
  config dir (config.yml), the hooks dir, the installed bundles dir, the
884
- guardrails store (where `approvals.json` and `judgments.jsonl` live) or the
885
- 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
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
1042
+ (`ParentApprovals::CHI_TEXT`: `$XDG_CONFIG_HOME/samagotchi/config.yml`,
1043
+ `~/.config/samagotchi/…`, a `cd ~/.config` then `samagotchi/config.yml`),
1044
+ and by its words resolved as `shell-touches-chi` resolves them
1045
+ (`Guardrails::ShellPaths::Walk`: cd chains, `~`, `$HOME` and the XDG dirs,
1046
+ symlinks, `..`; under the call's `env:` too), so `cd ~/.config; cd
1047
+ samagotchi; cat config.yml` or a symlink into the config dir is refused.
1048
+ A call whose `env:` redefines the shell (`Targets#redefines_shell?`:
1049
+ `BASH_ENV`, `BASH_FUNC_…`, …) is refused whatever it names. The memories
1050
+ dir is deliberately not one of these: reading or appending a memory file
1051
+ can be auto-allowed, by a command none of whose words lands in chi's dirs
1052
+ outside the memories, whose text shows no climb (`SelfProtect::CLIMB`: a
1053
+ `..` segment, any `$` or `$(…)`, backticks, a `~` other than the home one,
1054
+ `cd -`), and whose words the walk can't resolve are only data read while
1055
+ it knew the dir: a script or heredoc body (`python3 -c '…'`, `cat >> x.md
1056
+ <<'EOF'`), a phrase (`echo "- a note"`) or a glob with no `/`. So
1057
+ `cd memories; cd ..`, `cd "${PWD%/*}"`, `cd $(dirname $PWD)` or `cd -`
1058
+ loses the exemption, and the memories text then refuses it. A script body
1059
+ is not looked into beyond its text: an accident guard, not containment.
1060
+ A protected rule
892
1061
  (`Verdict::PROTECTED_RULES`) may be listed by name and System 1 may answer
893
1062
  it, but a call that really writes config.yml or the hooks is refused here;
894
1063
  5. the judgment's `p` is at least `guardrails.system_one.threshold` (0.8);
@@ -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
 
@@ -176,8 +185,11 @@ Everything else is `:owner`: `/answer`, `/command`, `/images` (an image a prompt
176
185
  to act: the turn only reads (`Guardrails::ReviewBoundary`), and proposes what the owner applies ([Apply](#apply)).
177
186
  - **`:pair`**, a working session's participant invited to act: the turn acts with the session's tools under the
178
187
  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.
188
+ chi itself and chi web on loopback, background and scheduled jobs, the environment and credential printers, git's
189
+ hooks, owner-voiced tools and the owner's task outputs, and the model's own question, and asks before a command
190
+ that makes a link and before a write that may land outside the project, failing closed (a session at or above
191
+ home has none: every write asks) ([guardrails](../guardrails.md#a-participants-pair-boundary)). No Apply: it
192
+ acted itself.
181
193
 
182
194
  `ParticipantMode.of(session, features:)` is the session's: what a participant invited now gets. No session is
183
195
  `:review`. A worker names `pair_turns` (`Delivery::PAIR_FEATURE`) when it runs pair turns; one without it runs
@@ -230,6 +242,15 @@ worker checks their link (`participants.json`: revoked, expired) and their cap a
230
242
  dequeued (`:turn_dequeued`) and audited (`revoked`, `cap_reached`) instead of run, so a revoke also stops what they
231
243
  had queued; its settled record (`TurnCheckpoint.record_refused`: canceled, `cancel_reason` the reason) ends their
232
244
  wait on it at once, which `ParentReport` reads as status `refused`, exit 1.
245
+
246
+ **The Verify gate.** A worker names `verify` (`VerifyAnchor::FEATURE`, in the Bridge's `FEATURES`) when it understands a
247
+ Verify turn's anchor (`VerifyAnchor`, `{turn_id, quote}`: a UUID and a quote of 1 to 200 characters). A turn carrying an
248
+ anchor, or a Verify fresh check, sent to a connected worker without it is refused 409 `verify_unavailable`
249
+ (`VerifyAnchor::UNAVAILABLE`: "restart this session to use Verify"; `SessionManager` checks `worker_has?` before it
250
+ 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
251
+ worker that starts for it is current. The participant page's session summary carries `verify_unavailable`
252
+ (`VerifyAnchor.worker_unavailable?`, from the worker's features; `participant_review.rb`), and the page disables
253
+ Verify and Verify fresh with a "restart this session" hint.
233
254
  **The owner's input during a review turn queues.** While a participant's turn runs, the owner's input never merges
234
255
  into it, from any client (the web, an attached TUI, `chi send` without `--queue`): the Bridge's `POST /turn` makes it
235
256
  `delivery: queue` whatever it asked, so it never steers or cuts (`TurnAccumulator#participant_turn?`, under the event
@@ -264,7 +285,9 @@ queued, as `--turn T` does.
264
285
  **The tool calls.** In a review session `Guardrails::ReviewBoundary` denies a participant's turn everything but
265
286
  reads of the project ([guardrails](../guardrails.md#a-participants-review-boundary)). In a working session
266
287
  `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
288
+ ([guardrails](../guardrails.md#a-participants-pair-boundary)). Both judge a path every way it resolves
289
+ (`PathResolve.reals`: a `..` after a symlinked folder climbs out of its target, as the read opens it;
290
+ [guardrails](../guardrails.md#how-paths-resolve)). Approvals and questions are owner-only: either
268
291
  boundary denies the model's own question, and `/answer` is an owner route. A pair turn's ask reaches the owner's card
269
292
  naming who asks (`approval.author`, `author_name`; `Guardrails::Approval::PARTICIPANT_HEADER`) with Allow once as its
270
293
  only scope, and System 1 never judges it (`GuardrailWiring#system_one_judge`). A grant without `--identity` (a LAN
@@ -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).
@@ -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
@@ -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
 
@@ -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.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: