samagotchi 0.133.0 → 0.135.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/bin/chi +2 -3
  4. data/docs/architecture.md +6 -3
  5. data/docs/cli.md +50 -34
  6. data/docs/configuration.md +9 -6
  7. data/docs/guardrails.md +146 -13
  8. data/docs/internals/background-tasks.md +1 -0
  9. data/docs/internals/engine.md +6 -3
  10. data/docs/internals/participants.md +7 -6
  11. data/docs/internals/tool-guardrails.md +2 -0
  12. data/docs/internals/web-frontend.md +262 -0
  13. data/docs/plugins.md +2 -1
  14. data/docs/sessions.md +5 -2
  15. data/docs/testing.md +34 -4
  16. data/lib/samagotchi/bridge.rb +11 -6
  17. data/lib/samagotchi/bridge_client.rb +8 -5
  18. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  19. data/lib/samagotchi/chi_home.rb +12 -2
  20. data/lib/samagotchi/config.rb +0 -2
  21. data/lib/samagotchi/desktop/macos.rb +19 -4
  22. data/lib/samagotchi/engine.rb +82 -12
  23. data/lib/samagotchi/guardrail_wiring.rb +8 -6
  24. data/lib/samagotchi/guardrails/approval.rb +41 -5
  25. data/lib/samagotchi/guardrails/implicit_writers.rb +158 -0
  26. data/lib/samagotchi/guardrails/pair_boundary.rb +11 -5
  27. data/lib/samagotchi/guardrails/pair_command.rb +828 -86
  28. data/lib/samagotchi/guardrails/parent_approvals.rb +13 -16
  29. data/lib/samagotchi/guardrails/read_only_shell.rb +58 -4
  30. data/lib/samagotchi/guardrails/shared_git.rb +1 -1
  31. data/lib/samagotchi/guardrails/shell_git_dirs.rb +7 -2
  32. data/lib/samagotchi/guardrails/shell_lex.rb +45 -0
  33. data/lib/samagotchi/guardrails/shell_paths.rb +3 -2
  34. data/lib/samagotchi/guardrails/targets.rb +8 -3
  35. data/lib/samagotchi/guardrails_command.rb +13 -10
  36. data/lib/samagotchi/home_command.rb +22 -6
  37. data/lib/samagotchi/home_doctor.rb +36 -3
  38. data/lib/samagotchi/home_notice.rb +8 -1
  39. data/lib/samagotchi/hooks/loader.rb +5 -0
  40. data/lib/samagotchi/llm/chat_loop.rb +4 -3
  41. data/lib/samagotchi/log_line.rb +12 -0
  42. data/lib/samagotchi/message_parts.rb +1 -1
  43. data/lib/samagotchi/project_env.rb +3 -11
  44. data/lib/samagotchi/revise_anchor.rb +33 -0
  45. data/lib/samagotchi/self_report.rb +3 -1
  46. data/lib/samagotchi/session_inbox.rb +20 -12
  47. data/lib/samagotchi/session_manager.rb +13 -11
  48. data/lib/samagotchi/system_one/approval_question.rb +21 -16
  49. data/lib/samagotchi/system_one/self_protect.rb +70 -16
  50. data/lib/samagotchi/testing/waiting.rb +13 -0
  51. data/lib/samagotchi/toolkit_binding.rb +16 -0
  52. data/lib/samagotchi/tools/delegate_relay.rb +6 -5
  53. data/lib/samagotchi/tools/delegate_wait.rb +5 -5
  54. data/lib/samagotchi/tools/execute.rb +6 -6
  55. data/lib/samagotchi/tools/output_guardrails.rb +9 -2
  56. data/lib/samagotchi/tools/read.rb +5 -5
  57. data/lib/samagotchi/turn_anchors.rb +49 -0
  58. data/lib/samagotchi/update_command.rb +7 -3
  59. data/lib/samagotchi/update_hint.rb +6 -1
  60. data/lib/samagotchi/verify_anchor.rb +5 -5
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/app.rb +2 -14
  63. data/lib/samagotchi/web/message_display.rb +17 -1
  64. data/lib/samagotchi/web/participant_access.rb +2 -2
  65. data/lib/samagotchi/web/participant_view.rb +8 -0
  66. data/lib/samagotchi/web/public/activity.js +1 -1
  67. data/lib/samagotchi/web/public/annotations.js +1 -1
  68. data/lib/samagotchi/web/public/answer_fold.js +262 -0
  69. data/lib/samagotchi/web/public/answer_refs.js +2 -0
  70. data/lib/samagotchi/web/public/app.js +462 -338
  71. data/lib/samagotchi/web/public/bubbles.js +3 -3
  72. data/lib/samagotchi/web/public/cards_place.js +9 -4
  73. data/lib/samagotchi/web/public/chip_popover.js +73 -11
  74. data/lib/samagotchi/web/public/claim_link.js +4 -2
  75. data/lib/samagotchi/web/public/composer.css +11 -1
  76. data/lib/samagotchi/web/public/context_chips.js +9 -6
  77. data/lib/samagotchi/web/public/data.js +4 -2
  78. data/lib/samagotchi/web/public/dock_reserve.js +13 -15
  79. data/lib/samagotchi/web/public/feature_gates.js +33 -0
  80. data/lib/samagotchi/web/public/history_reveal.js +27 -0
  81. data/lib/samagotchi/web/public/index.html +82 -37
  82. data/lib/samagotchi/web/public/latest.js +46 -0
  83. data/lib/samagotchi/web/public/lists_view.js +15 -13
  84. data/lib/samagotchi/web/public/live_timing.js +2 -0
  85. data/lib/samagotchi/web/public/llm_context_chip.js +5 -13
  86. data/lib/samagotchi/web/public/markdown_place.js +1 -1
  87. data/lib/samagotchi/web/public/method_api.js +14 -0
  88. data/lib/samagotchi/web/public/notifications.js +4 -11
  89. data/lib/samagotchi/web/public/participant.js +11 -6
  90. data/lib/samagotchi/web/public/participant_names.js +1 -1
  91. data/lib/samagotchi/web/public/participants_panel.js +4 -2
  92. data/lib/samagotchi/web/public/project_picker.js +18 -22
  93. data/lib/samagotchi/web/public/question_cards.js +2 -2
  94. data/lib/samagotchi/web/public/refs.js +6 -7
  95. data/lib/samagotchi/web/public/restore.js +2 -0
  96. data/lib/samagotchi/web/public/review_start.js +1 -1
  97. data/lib/samagotchi/web/public/revise_stack.js +372 -0
  98. data/lib/samagotchi/web/public/scope.js +12 -20
  99. data/lib/samagotchi/web/public/send_turn.js +29 -16
  100. data/lib/samagotchi/web/public/session_actions.js +15 -14
  101. data/lib/samagotchi/web/public/session_fields.js +152 -0
  102. data/lib/samagotchi/web/public/session_payload.js +35 -0
  103. data/lib/samagotchi/web/public/session_state.js +2 -0
  104. data/lib/samagotchi/web/public/session_store.js +191 -0
  105. data/lib/samagotchi/web/public/stage_model.js +4 -4
  106. data/lib/samagotchi/web/public/stage_view.js +11 -16
  107. data/lib/samagotchi/web/public/start_page.js +4 -4
  108. data/lib/samagotchi/web/public/start_settings.js +29 -18
  109. data/lib/samagotchi/web/public/start_toolkit.js +5 -4
  110. data/lib/samagotchi/web/public/stream_handlers.js +88 -90
  111. data/lib/samagotchi/web/public/task_stop.js +1 -1
  112. data/lib/samagotchi/web/public/timing.js +7 -3
  113. data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +46 -101
  114. data/lib/samagotchi/web/public/turn_events.js +2 -2
  115. data/lib/samagotchi/web/public/turn_html.js +17 -9
  116. data/lib/samagotchi/web/public/turn_model.js +6 -6
  117. data/lib/samagotchi/web/public/turn_range.js +32 -0
  118. data/lib/samagotchi/web/public/verdict_card.js +5 -13
  119. data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
  120. data/lib/samagotchi/web/public/verify_target.js +7 -10
  121. data/lib/samagotchi/web/public/working_set_view.js +3 -1
  122. data/lib/samagotchi/web/server.rb +4 -4
  123. data/lib/samagotchi/web/session_control.rb +2 -2
  124. data/lib/samagotchi/worker.rb +9 -9
  125. data/lib/samagotchi/worker_inbound.rb +6 -5
  126. metadata +16 -3
  127. data/lib/samagotchi/web/public/hold.js +0 -78
data/docs/guardrails.md CHANGED
@@ -54,6 +54,11 @@ changed: `diff +3 −1` under the row on the web (closed, it survives a
54
54
  reload) and ` +3 −1` at the end of the terminal's tool line. The model never
55
55
  sees these diffs.
56
56
 
57
+ The card also carries the ask's facts as `approval:` (tool, command or paths, where, rule, scopes, preview…):
58
+ `Guardrails::Approval.payload` builds them as a `Guardrails::ApprovalFacts` value, which System 1 and the parent
59
+ checks read in the worker; the card and the session file get its `to_h`, the same hash every UI reads, and a saved
60
+ card comes back through `ApprovalFacts.from_h` (string or symbol keys, missing fields nil).
61
+
57
62
  Who answers:
58
63
 
59
64
  - REPL (`chi --no-shared`, with or without `-p`): at the `? ` prompt.
@@ -433,16 +438,46 @@ message:
433
438
  project env ([Project env](sessions.md#project-env)); a command that runs
434
439
  `mise` or `direnv` asks the owner (`ENV_LOADER`: they load the project's env
435
440
  files, and `.envrc`, `mise.local.toml` and its kin are secrets like `.env`),
436
- and so does a write (by a file tool or a command) to mise's config
437
- (`MISE_CONFIG`: the owner's next worker start runs its `[env]`);
441
+ and so does a write (by a file tool or a command) to mise's config, its
442
+ `.tool-versions` included (`MISE_CONFIG`: the owner's next worker start runs
443
+ its `[env]` and installs what it pins; a command asks when a part of it that
444
+ names one may write: `rm`, `mv`, `find … -delete` and `find … | xargs rm`
445
+ do, a read-only part such as `find . -name mise.toml && make` doesn't, the
446
+ list split at `;`, `&&`, `||` and newlines outside `( … )`; mise's own
447
+ writes, `mise use`/`mise set`, ask as runs of mise; a config that changes
448
+ without being named, by `git checkout`, `git stash pop`, `tar x`, `patch` or
449
+ `cp -r dir/. .`, isn't asked about: the owner's next restart runs it);
438
450
  and a command still
439
451
  running at the limit is killed, never handed over as a background task;
440
- - git settings that make git run a command (`core.hooksPath`,
441
- `core.fsmonitor`, `core.sshCommand`, `core.pager`, `core.editor`, a `!`
442
- alias) by `git config` or `git -c`, and `git config --global`/`--system`;
452
+ - git config writes, whatever the key (any may make git run a command later
453
+ in the owner's name: hooks, `core.fsmonitor`, a filter, diff or merge
454
+ driver, a credential or gpg helper, `hook.<name>.command`, an include): a
455
+ pair turn's `git config` only reads, parsed strictly
456
+ (`PairCommand#git_config_read?`: `--get`, `--get-all`, `--get-regexp`,
457
+ `--get-urlmatch`, `--get-color(bool)`, `-l`/`--list`, `get`, `list` or a lone
458
+ key, with `--show-origin`, `--show-scope`, `--name-only`, `-z`, `--type=…`,
459
+ `--default`, `--file F` and the like, every word accounted for; any other
460
+ option, an abbreviation git would take (`--glob`, `--comm`), a short form
461
+ (`-t`) or a word too many is a write). Denied too: `git -c`, `--config-env`,
462
+ a clone's, init's or submodule's `-c`/`--config`, an assignment of
463
+ `GIT_CONFIG_*` or `GIT_TEMPLATE_DIR`, `git` under an alias, `git config` after
464
+ `xargs` or in `find -exec`, and an interpreter's script naming `git config`
465
+ or `git -c`, behind any of git's own options (`--attr-source X`,
466
+ `--shallow-file X`; one chi doesn't know is read both with and without the
467
+ next word as its value, `PairCommand.git_command_starts`), and by a
468
+ builtin's dashed name (`git-config`, `…/git-core/git-submodule foreach`:
469
+ judged as `git config`, `git submodule`). A command git runs now from its text (`git submodule foreach`,
470
+ `rebase --exec`, `bisect run`, a `filter-branch` filter, `--upload-pack`,
471
+ `--receive-pack`, `--extcmd`, an `ext::` URL) is read as a command of its
472
+ own and asks as an outside write. A `.gitattributes` write isn't asked
473
+ about: it only selects a driver, and defining one is what's denied;
443
474
  and writing git's hooks: a `write` or `edit` of a path in a `.git/hooks`
444
- folder (as given or as it resolves), or a command that isn't read-only and
445
- names one (`printf … > .git/hooks/pre-commit`, `cp x .git/hooks/`);
475
+ folder, a submodule's `.git/modules/<name>/hooks` too (as given or as it
476
+ resolves), or a command whose part that names one isn't read-only
477
+ (`printf … > .git/hooks/pre-commit`, `cp x .git/hooks/`), and installing
478
+ them: `git init`/`clone --template`, and the hook installers
479
+ (`PairCommand::HOOK_INSTALLERS`: `pre-commit install`, `husky`, `lefthook
480
+ install`, `simple-git-hooks`, `overcommit --install`);
446
481
  - running chi, by any wrapper (`bin/chi`, `bundle exec chi`, `ruby … bin/chi`,
447
482
  `env … chi`, `watch`, `npx`, `sh -c 'chi …'`, `$(chi …)`), in an assignment's
448
483
  value or an alias, a function, after `-exec`/`-c`/`-e`, but `chi --version` and
@@ -482,9 +517,40 @@ message:
482
517
  A script a command hands on is read too (`Guardrails::PairCommand`): one a
483
518
  shell runs (`sh -c`, `eval`, a heredoc or here-string) as a command of its
484
519
  own, one another interpreter runs (`ruby -e`, `python - <<EOF`) by its text
485
- (chi, secrets, the environment, detaching). A heredoc written as data (`cat`,
520
+ (chi, secrets, the environment, detaching). So is a command handed through a
521
+ variable a program runs through a shell (`PairCommand::COMMAND_ENV`:
522
+ `GIT_EDITOR`, `GIT_SEQUENCE_EDITOR`, `EDITOR`, `VISUAL`, `GIT_EXTERNAL_DIFF`,
523
+ `GIT_SSH_COMMAND`, `GIT_SSH`, `GIT_PROXY_COMMAND`, `GIT_PAGER`, `PAGER`,
524
+ `MANPAGER`, `GIT_ASKPASS`, `SSH_ASKPASS`, bash's `PROMPT_COMMAND`), as a prefix,
525
+ `env`'s or `export`'s: read as `sh -c` would run it, for the refusals and for
526
+ an outside write. An assigned value that keeps a `$(…)`, `${ …; }` or backticks
527
+ as text (`PS4='$(…)' bash -x`, `PS0`, `PS1`, `$'\x24(…)'`: the shell expands it
528
+ later), a prefix's, a bare assignment's or `export`'s (not `grep 'X=$(…)'`), is
529
+ refused for what its `$(…)` runs (`PS4='$(git config …)'`), and otherwise the
530
+ owner approves it (`PairBoundary::NET`: `CFLAGS='$(BASE)' make`).
531
+ The program is found behind the wrappers (`env`, `sudo`, `timeout`, `xargs`,
532
+ `nice`, `exec`, `command`, `builtin`, `xcrun`, `arch`, `script`'s file, `npx`,
533
+ `bunx`, `uvx`, and two-word ones: `uv`/`poetry`/`pdm`/`hatch`/`pipx run`,
534
+ `pnpm`/`yarn exec`/`dlx`, `npm exec`/`x`, `bun x`, `mise exec`/`x`) and their
535
+ value options (`env -u X`, `sudo -u root`, `timeout -s KILL 5`; `xcrun -r`,
536
+ `sudo -n` are flags). A command line a program takes as text is read as a
537
+ command: `env -S S` (`-vS S` too), `script -c S`, `npx`/`npm exec -c S`
538
+ (`--call`), `rsync -e S` (`--rsh`), ssh's (scp's, sftp's) `-o ProxyCommand=S`
539
+ (`LocalCommand`, `KnownHostsCommand`: they run here), `hyperfine`'s arguments,
540
+ `parallel`'s and `watch`'s. And as a net for a runner no list knows, any word
541
+ but the program's (a wrapper's option value too) that names git is read as git
542
+ from there, one naming chi before a chi command counts (`chronic chi share x`;
543
+ not `cp bin/chi /tmp/x`), and a command line starting with git or chi is read
544
+ as a command (`some-runner 'git config k v'`): a hit asks the owner
545
+ (`PairBoundary::NET`), it doesn't refuse, since chi can't tell whether that
546
+ word runs (`gh pr create --title 'git config fix'` asks too). A read stays a
547
+ read, and a command that only reads (`rg 'git config'`) isn't netted. A heredoc written as data (`cat`,
486
548
  `tee`, `git commit -F -`) is not looked into: where it goes is checked. A
487
549
  command the checks can't read is refused, never let through on a text match.
550
+ A program is told by its name in any case, on every disk (`GIT config`, `Chi
551
+ share`, `/bin/SH -c`, `NOHUP`: a disk that folds case, macOS's default, runs
552
+ them as git, chi, sh, nohup; `ShellLex.program_name`), and so is chi or git
553
+ config in a script's text.
488
554
 
489
555
  And it **asks the owner** before a `write` or `edit` that may land outside the
490
556
  session's project root, and before a command that may write there, every time:
@@ -502,7 +568,51 @@ rule lowers it), so it comes even in auto mode. A command fails closed
502
568
  (`~/oth*`), `git -C`'s folder and the paths in a script it hands on (`sh -c`
503
569
  read as a command, `python -c`/`ruby -e`/a heredoc by the paths in its text)
504
570
  among them. The program's own word doesn't count (`/usr/bin/sed`), nor does
505
- `PATH=…`, nor a message's heredoc (`git commit -m "$(cat <<'EOF' … EOF)"`).
571
+ `PATH=…`, nor a message's heredoc (`git commit -m "$(cat <<'EOF' … EOF)"`);
572
+ and it runs no program that writes to its own place outside with no word
573
+ naming it (`Guardrails::ImplicitWriters`, examples rather than a closed
574
+ set: `npm`/`pnpm`/`bun`/`deno` global in any spelling (`-g`, `--global=true`,
575
+ `--location global`, `npm_config_global=true`), `npm`/`yarn`/`bun link`,
576
+ `npm config set`/`login` (`~/.npmrc`), `yarn global`, `pip install`/`pip
577
+ config` by any interpreter (`python3.12 -m pip`, `pypy3 -mpip`; with or
578
+ without `--user`: its site-packages is wherever its Python is; a project
579
+ venv's `.venv/bin/pip` and `-t`/`--target`/`--prefix` don't count), `uv pip
580
+ install --system`, `gem install`, `bundle config set` without `--local`,
581
+ `brew` but its reading verbs, `defaults write`, `cargo install`, `go
582
+ install`/`go env -w`, `pipx`, `uv tool`/`uv python install`, `dotnet tool
583
+ install -g`, `corepack enable`, `rustup`/`pyenv`/`rbenv`/`asdf`/`volta`/`nvm`
584
+ installs and globals, `code --install-extension`, `gh auth`/`config
585
+ set`/`extension install`, `launchctl` but its reading verbs, `git maintenance register`/`start`, `git
586
+ lfs install`/`update` (git's words from its subcommand on: not `git add
587
+ maintenance start`), `scalar register`/`clone`; a project
588
+ install such as `npm install` or `bundle install` and build caches don't
589
+ count), and no shell or interpreter that reads its script from stdin: one
590
+ on the receiving end of a pipe whatever its options (`… | sh`, `| bash -x`,
591
+ `| sh -e`, `| python3 -c 'exec(input())'`), `< file`, `bash -s`, `python3 -`,
592
+ or with no script at all: chi can't see that script. A shell's `-e`, `-x`
593
+ and `-o …` are flags, only `-c` (or a cluster holding it, `-ec`, `-cx`)
594
+ hands it a script: the first word after its options (`sh -c -- S`, `bash -c
595
+ -x S`), and fish's `--command S` (`--command=S`) or `-C`/`--init-command S`,
596
+ a command it runs first (also attached, `-C'S'`, in a cluster, `-lC S`, and
597
+ by a prefix fish takes, `--comm`, `--init`); an interpreter's own code flag does (`python -c`, `ruby -e`,
598
+ `node -e`/`-p`/`--eval=`, `perl -e`/`-E`, `php -r`/`-B`/`-R`/`-E`, `jruby`/`raku`/`julia -e`), alone or in a cluster
599
+ (`ruby -we`, `perl -le`, `node -pe`, `python3 -Ic`, `python3 -c'…'`;
600
+ `PairCommand.cluster_code_at`: a letter that takes the rest of the word,
601
+ `ruby -rset`, holds no code). A script file it names (`sh
602
+ run.sh`, `ruby x.rb`) is not read.
603
+
604
+ Accepted gaps (an accident guard for a trusted team): a script file the turn
605
+ wrote and then runs; inline scripts read only by their text for paths
606
+ (`find … -exec sh -c '…'`, `flock … sh -c`, `awk 'BEGIN{system(…)}'`,
607
+ `deno eval`); a `GIT_CONFIG_*` or a `COMMAND_ENV` variable set by `printf -v`
608
+ or `read` and exported (`printf -v GIT_PAGER …; export GIT_PAGER; git log`);
609
+ a variable outside `COMMAND_ENV` whose value a program runs (`LESSOPEN`,
610
+ `BROWSER`, a tool's own `*_EDITOR`; `GIT_EXEC_PATH` runs only a file the
611
+ turn wrote); an interpreter no list names (as `awk`); `npx -y`,
612
+ `npm exec`, `pnpm dlx`, `uvx`: they fetch into a cache, which doesn't count,
613
+ and run the package (judged as the program) as in-project work; a wrapper's
614
+ value option no list names (`uv run --with x git …` reads `x` as the program;
615
+ the net still reads the git or chi after it).
506
616
 
507
617
  Anything else asks: `rm -rf ~/x`, `sed -i … ~/x`, `curl -o ~/x`, `dd of=~/x`,
508
618
  `touch`/`mkdir` outside, `perl -pi … ~/x`, `cd .. && make`, `cd && make`, and
@@ -629,7 +739,8 @@ repo is in count as outside.
629
739
  `touches: chi_dirs` (shell tools) matches a command that names a path in chi's
630
740
  own folders: the config folder (`config.yml`, `memories/`), the hooks folder,
631
741
  the approval store, installed bundles (`memories/.bundles`), the session repo's
632
- git hooks (`core.hooksPath` honoured) or any `.git/hooks` outside a tmp folder.
742
+ git hooks (`core.hooksPath` honoured) or any `.git/hooks` (a submodule's `.git/modules/<name>/hooks` too)
743
+ outside a tmp folder.
633
744
  Each word is read as a path against the folder the command is in by then
634
745
  (`cd`, `pushd`/`popd` and `( … )` followed), with `~`, `$HOME` and
635
746
  `$XDG_CONFIG_HOME`/`$XDG_STATE_HOME` expanded and symlinks resolved. So
@@ -653,11 +764,18 @@ checked even after the database name, and only its read/display options are
653
764
  allowed (unknown options and dot-command arguments are rejected). `gh` allows
654
765
  only `run list|view`, `pr list|view|diff|checks`, `issue list|view` and
655
766
  `repo view`; `--web`/`-w`, `gh api` and `pr checks --watch` are not read-only.
767
+ `test` and `[ … ]` (with its closing `]`) are read-only as one check, optionally after `!`: a file or string
768
+ test (`-e`, `-f`, `-d`, `-s`, `-r`, `-w`, `-x`, `-L`, `-h`, `-b`, `-c`, `-p`, `-S`, `-g`, `-u`, `-k`, `-O`, `-G`,
769
+ `-n`, `-z` with one operand) or a comparison (`=`, `==`, `!=`, `-eq`, `-ne`, `-lt`, `-le`, `-gt`, `-ge`), so
770
+ `test -f x && cat x` and `[ -d dir ] && ls dir` only read; `-a`/`-o`, `( … )`, any other operator or a missing
771
+ operand, and `[[ … ]]` are not.
656
772
  `$(…)`, backticks, `$VAR` (other than `$HOME`, `$XDG_*`, `$TMPDIR` and `$PWD`
657
773
  at a word's start), an env prefix, `sh -c`, `xargs`, `eval`, `sudo` and the
658
774
  like make a command not read-only, as does a `task_create` `env:` that sets `BASH_ENV`, `ENV`, `CDPATH`, `IFS`,
659
775
  `SHELLOPTS`, `BASHOPTS`, `BASH_FUNC_…`, `LD_PRELOAD`, `LD_AUDIT`, `DYLD_INSERT_LIBRARIES`, `GIT_EXTERNAL_DIFF`,
660
- `GIT_CONFIG_PARAMETERS` or `GIT_CONFIG_COUNT` (they change what its words run; `PATH` doesn't count). It errs
776
+ or a git config variable (`GIT_CONFIG`, `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`, `GIT_CONFIG_PARAMETERS`,
777
+ `GIT_CONFIG_COUNT`, `GIT_CONFIG_KEY_n`/`VALUE_n`: `ShellGitDirs::CONFIG_ENV`) (they change what its words run; `PATH`
778
+ doesn't count; a pair turn's command that assigns a git config variable is denied like `git -c`). It errs
661
779
  towards asking.
662
780
 
663
781
  A tool name may be a glob, so one rule covers a plugin's tools (an MCP server's,
@@ -798,6 +916,7 @@ every surface answers for the same paths):
798
916
  | 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 |
799
917
  | 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 |
800
918
  | a shell word's `~`, `$HOME`, XDG dirs, `$TMPDIR` | `PathResolve.expand_word` | ShellPaths::Walk, ShellGitDirs; PairCommand has its own `~`/`$HOME` (`expand_tilde`) |
919
+ | a program word's name: basename, case-folded on every disk (`GIT`, `/bin/SH`, `ſh`) | `ShellLex.program_name` / `.program?` | PairCommand, ImplicitWriters, SelfProtect's file runners, ShellGitDirs, SharedGit |
801
920
  | chi's dirs | `ChiDirs.protected` / `.touches` / `.self_protect` / `.review` | ParentApprovals, Targets, SelfProtect, ReviewPaths (`ProtectedPaths` and `ScratchWrites` get theirs from the wiring) |
802
921
 
803
922
  Left apart on purpose: `Targets.prompt_memory_path?` compares in any case on
@@ -1061,8 +1180,22 @@ gate below passes, in this order:
1061
1180
  it knew the dir: a script or heredoc body (`python3 -c '…'`, `cat >> x.md
1062
1181
  <<'EOF'`), a phrase (`echo "- a note"`) or a glob with no `/`. So
1063
1182
  `cd memories; cd ..`, `cd "${PWD%/*}"`, `cd $(dirname $PWD)` or `cd -`
1064
- loses the exemption, and the memories text then refuses it. A script body
1065
- is not looked into beyond its text: an accident guard, not containment.
1183
+ loses the exemption, and the memories text then refuses it. So does a
1184
+ script a shell or interpreter runs that climbs by its own calls
1185
+ (`SelfProtect::SCRIPT_CLIMB`: `os.pardir`, `dirname`, `chdir`, `Dir.pwd`,
1186
+ `Path.parent`, `rindex`, a slice from the end), and one chi can't see: a
1187
+ script file (`cd memories && python3 x.py`) or stdin (`… | python3`,
1188
+ `sh < f`, any interpreter or shell a pipe feeds, `| sh -x` too), by any
1189
+ version's name (`python3.12`, `pypy3`, `Rscript`, `tclsh`), or a program
1190
+ that runs a file (`SelfProtect::FILE_RUNNERS`: `make`, `rake`, `awk -f`,
1191
+ `deno run`, `npm run`). A heredoc only written (`cat >> x.md <<'EOF'`) is
1192
+ data whatever it says. A script body is not looked into beyond its text:
1193
+ an accident guard, not containment (a script can build a path some other
1194
+ way: `p.replace("memories", "config.yml")`, `p[0:len(p)-8]`, `.sub`,
1195
+ `.slice` aren't caught). The price is some harmless commands the user now
1196
+ answers: `python3 scripts/lint.py <memories>/notes.md`, `cat notes.md |
1197
+ python3 -c …` from the memories folder, a climb word written as data by a
1198
+ script (`File.write(".../notes.md", "dirname")`).
1066
1199
  A protected rule
1067
1200
  (`Verdict::PROTECTED_RULES`) may be listed by name and System 1 may answer
1068
1201
  it, but a call that really writes config.yml or the hooks is refused here;
@@ -45,6 +45,7 @@ Behavior:
45
45
  - `execute` and `task_create` both spawn with chi's own Ruby/Bundler setup scrubbed (`TaskRuntime.sanitized_spawn_env`): `RUBYOPT` loses its `-r…bundler/setup` flags (others stay) and `RUBYLIB`, `BUNDLE_GEMFILE`, `BUNDLE_BIN_PATH`, `BUNDLER_VERSION` are unset, so a project's `ruby -e` or `bundle exec` uses its own Gemfile when chi runs under `bundle exec`. `task_create` refuses those keys in the model's `env`; `execute`'s `env` comes from chi's callers (`Builtins`, `!cmd`), not the model, and is set over the scrub. An `execute` handed over at its limit keeps the process it spawned, so the task runs with the same env.
46
46
  - `execute` and `task_create` export chi's own markers into the command, set over the model's `env` (which can't set them: `Error: env key is reserved`): `SAMAGOTCHI_PARENT_SESSION` (the session's id, `chi` without one; a chi started there answers as a parent agent), `SAMAGOTCHI_SESSION_MODEL` (the resolved ref the session runs on, read at each call so it follows `/model`; unset when unknown) `SAMAGOTCHI_SESSION_THINKING` (the session's own thinking level; unset when it has none) and `SAMAGOTCHI_SESSION_REASONING_HISTORY` (its own reasoning_history level, the same way). `chi self` reads the last three for its model, thinking level and reasoning history. The four are `ChildEnv::SESSION_MARKERS` (`TaskRuntime::MARKER_ENV_KEYS`), which a spawned worker unsets (`ChildEnv.unset`, merged into its spawn env). A session's worker never inherits them from whoever started it (a `chi send` run by another session's `execute`): its other commands (a user's `!cmd`, an MCP server, a context command) have none.
47
47
  - They also pass on a set `XDG_STATE_HOME` / `XDG_CONFIG_HOME` made absolute (`Builtins.xdg_absolute`, via `Paths`), so a chi the command runs in another `cwd` (`chi send`) reads this chi's state and config. These aren't reserved: an XDG dir the model sets in `task_create`'s `env` stays its own.
48
+ - Every runner starts from the session's project env, `ProjectEnv.tools_env`: the overlay a spawned worker computed once at boot (`SessionManager.apply_project_env` → `ProjectEnv.compute` → `ProjectEnv.install!`; its folder's `mise env`, and the chi shim's dir first on PATH so `chi` is the chi home's), less `ProjectEnv::TOOLS_DROP` (`RUBYOPT`, `RUBYLIB`, Bundler's: the scrub above sets those). Outside a worker (`ProjectEnv::NONE`) it is `{}`. The sites: `execute` and `task_create` (`Tools::Builtins#project_env`, under the markers and the model's `env`; a participant's turn gets `{}`), the user's `!cmd` (`SessionCommands#shell`) and a context source's command (`ContextFetch#env_for`). MCP servers take `ProjectEnv.current.env` whole under the server's own keys (`bundles/mcp/plugin.rb` `server_env`: no `TOOLS_DROP`, no participant rule). chi's own process env is never changed, and the project env never enters the prompt. User docs: [Project env](../sessions.md#project-env); the launch side: [How chi launches itself](../architecture.md#how-chi-launches-itself).
48
49
 
49
50
  ## Finish wake
50
51
 
@@ -76,7 +76,10 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
76
76
  │ # messages, goes IDLE, records last_turn (enqueued_id: the turn's input, from its origin;
77
77
  │ # a continue turn's origin carries its offer turn's, ContinueOffer#run_continue_turn),
78
78
  │ # and :turn_completed {result, turn_summary, display_pending} or :turn_canceled
79
- │ # {cancellation_reason, cancelled_by, duration_ms} is emitted
79
+ │ # {cancellation_reason, cancelled_by, duration_ms} is emitted; before the replace,
80
+ │ # stamp_opening puts a Verify turn's `verify` and a completed Revise turn's `revises`
81
+ │ # (re-targeted to its stack's top) on the opening message, metadata never sent, and
82
+ │ # :turn_completed carries `verdict` / `revises` when there are
80
83
  │ # then the :after_turn hook (status, messages, present: the AnswerDisplay presenter,
81
84
  │ # refs: the AnswerRefs adder)
82
85
  │ # store_answer_display (one replace of the answer: `display` and/or `display_refs`;
@@ -330,7 +333,7 @@ One iteration with tool calls:
330
333
  stored under the real path; at most `RowRefs::MAX_REFS` and `MAX_BYTES`). `ToolRowFields.for` can't build it (no plugins on a reload), so the run carries it
331
334
  as `refs:`, saved with the result as `tool_refs` (`ToolResponse::SAVED_KEYS`, never sent), and
332
335
  `MessageParts#tool_part` reads it back through `RowRefs.for_display` (checked again). The page binds them to the
333
- row's title (`refs.js bindRowRefs`, from `turn_view.js` live and on a reload): the first ref on it, the rest in its
336
+ row's title (`refs.js bindRowRefs`, from `turn_block.js` live and on a reload): the first ref on it, the rest in its
334
337
  menu.
335
338
  - Settles an ask verdict after `:tool_call_started`, so a UI shows the tool line and then the approval.
336
339
  - Dispatches through `KernelLoop#dispatch_tool_call`, or writes the denial text. Unknown tools are answered in
@@ -545,7 +548,7 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
545
548
  | `:working_set_updated` | after `:generation_started` (the running turn's conversation, the turn's strategy) and once in `end_turn` before the end event (the kept messages), only when the set differs from the last one emitted for the session and is not empty, except one empty event when the last one had items (a rollback emptied the set): a tool-free turn emits none (`Engine#emit_working_set`); between turns `Engine#announce_working_set` announces it under the next turn's strategy (after a `/working-set` op, held behind the command's `command_ran`) | `{items: [{kind, key, state (live\|stale\|forgotten\|dropped), tokens, ops, count, first_id, pinned, has_drop}], at, adds?: [{kind, key, label}]}` (`WorkingSet`, internals/working-set.md; `adds` while the user's adds are pending) |
546
549
  | `:hook_notice` | a hook's or plugin's notify during a turn | `{hook, text, level}` (announced with `between_turns: true` outside a turn) |
547
550
  | `:guardrail_warning` | a guardrails line during a turn | `{message}` |
548
- | `:turn_completed` | normal end | Engine event `{result, turn_summary, display_pending}`; the Bridge's SSE frame omits `result` (answer remains in `turn_summary.output`) |
551
+ | `:turn_completed` | normal end | Engine event `{result, turn_summary, display_pending}`, plus `verdict` (a Verify turn's recorded verdict) and `revises` (a Revise turn's stamp, `{turn_id}`) when there are; the Bridge's SSE frame omits `result` (answer remains in `turn_summary.output`) |
549
552
  | `:turn_canceled` | Stop, hook stop, Ctrl-C | `{cancellation_reason, cancelled_by?, duration_ms}` |
550
553
  | `:turn_failed` | an error | `{error_class, message, duration_ms, error_kind?, retryable?, host?, summary?}` |
551
554
  | `:answer_display` | after the after_turn hooks (observers only, not the sink) | `{display, refs?}` (display a string, or nil; refs the count of the answer's refs, only when a hook attached some) |
@@ -195,11 +195,12 @@ Everything else is `:owner`: `/answer`, `/command`, `/images` (an image a prompt
195
195
  to act: the turn only reads (`Guardrails::ReviewBoundary`), and proposes what the owner applies ([Apply](#apply)).
196
196
  - **`:pair`**, a working session's participant invited to act: the turn acts with the session's tools under the
197
197
  owner's guardrails, its asks go to the owner and offer once only, and `Guardrails::PairBoundary` denies secrets,
198
- chi itself and chi web on loopback, background and scheduled jobs, the environment and credential printers, git's
199
- hooks, owner-voiced tools and the owner's task outputs, and the model's own question, and asks before a command
200
- that makes a link and before a write that may land outside the project, failing closed (a session at or above
201
- home has none: every write asks) ([guardrails](../guardrails.md#a-participants-pair-boundary)). No Apply: it
202
- acted itself.
198
+ chi itself and chi web on loopback, background and scheduled jobs, the environment and credential printers, git
199
+ config writes (reads only) and git's hooks, owner-voiced tools and the owner's task outputs, and the model's own question, and asks before a command
200
+ that makes a link and before a write that may land outside the project, failing closed (a global install or a
201
+ user config write that names no path, `Guardrails::ImplicitWriters`, and a script read from stdin count; a
202
+ session at or above home has none: every write asks) ([guardrails](../guardrails.md#a-participants-pair-boundary)).
203
+ No Apply: it acted itself.
203
204
 
204
205
  `ParticipantMode.of(session, features:)` is the session's: what a participant invited now gets. No session is
205
206
  `:review`. A worker names `pair_turns` (`Delivery::PAIR_FEATURE`) when it runs pair turns; one without it runs
@@ -582,7 +583,7 @@ message's `author_name`, `turn_started`'s, or the panel's list (`participant_nam
582
583
  A review turn proposes; the owner applies (D4, §3 Apply-as-quote). A pair turn acted itself: no Apply under it
583
584
  (`ReviewApply.review_turn?`: a participant's opening message not stamped `pair`; the route answers it
584
585
  `not_review_turn`, and its "latest" skips it). Under each participant's review turn that has a
585
- reply, the owner's page shows **Apply** (`review_apply.js`; drawn by the history render, `turn_view.js`, and at a live
586
+ reply, the owner's page shows **Apply** (`review_apply.js`; drawn by the history render, `turn_block.js`, and at a live
586
587
  turn's end from its `turn_started`'s turn id and author, in the stage while the stage holds the turn; a full-width
587
588
  button on a phone). It reads `GET …/apply?turn=T` and puts the answer in the composer, after whatever is typed there,
588
589
  never sent: the owner reads it, edits it, sends it or not. While the composer holds it (`deliveryControl.hold()`,
@@ -50,6 +50,8 @@ Configuration (config.yml `execute.*` keys and CLI flags too, as for `read`):
50
50
  - `SAMAGOTCHI_EXECUTE_TIMEOUT_SEC` (`execute.timeout_sec`, default `120`): what a command still running then gets is `execute.on_timeout`.
51
51
  - `SAMAGOTCHI_EXECUTE_ON_TIMEOUT` (`execute.on_timeout`, default `task`): `task` hands the command over as a background task, not killed; the result starts `Still running at the 120s limit: moved to a background task, not killed.` (`Execute::HANDED_OVER`), then its `task_id:` and the tails of stdout and stderr so far, and `task_wait` waits for it. `kill` stops it and answers `Error: command timed out after …s` (`Execute::TIMEOUT_PREFIX`) with the hint to use `task_create`. The `!cmd` bang always kills. See [Background tasks](background-tasks.md).
52
52
 
53
+ The env a command runs in (the session's project env, `ProjectEnv.tools_env`, then the Ruby/Bundler scrub and chi's markers) is the same for `execute`, `task_create`, `!cmd` and context sources: see [Background tasks](background-tasks.md).
54
+
53
55
  Implementation note:
54
56
 
55
57
  - Shared logic lives in `lib/samagotchi/tools/output_guardrails.rb` and is used by `read` and `execute`, and by `edit` (its env switches) and `delegate_wait` (the head+tail preview of a child's long reply).
@@ -0,0 +1,262 @@
1
+ # The web frontend: where state lives
2
+
3
+ A map of `lib/samagotchi/web/public/` for whoever changes it: where the page keeps what it knows, how a change
4
+ reaches the screen, and the guards every new piece should use. The modules' own header comments say what each one
5
+ does; this page says how they hang together. The server side is [architecture.md](../architecture.md) (Web);
6
+ the working set's overlay is [working-set.md](working-set.md), participants are [participants.md](participants.md).
7
+
8
+ No build step: `index.html` and `participant.html` load ES modules as they are, with lit-html (`vendor/`) as the
9
+ only library. Pure modules (`*_model.js`, `session_state.js`, `sessions_list.js`, `turn_range.js`, …) touch no DOM
10
+ and have `node --test` specs under `spec/web/public/` (happy-dom for the DOM ones); the pages as a whole are covered
11
+ by `npm run e2e` (`spec/e2e/`). `app.js` has no unit spec.
12
+
13
+ **New exports go in new modules.** The static JS is served at unversioned URLs. Today every load revalidates
14
+ (app.rb `serve_static` sends `Cache-Control: no-cache` with a content ETag, and `sw.js` has no fetch handler), but an
15
+ open tab still holds the modules it loaded, and a proxy that ignores `no-cache` can serve an old one: a new export
16
+ on an existing module, imported by a new version of another, then finds the old module without it and blanks the
17
+ page. A factory dep a new version stops passing is the same hazard the other way round: the store's redraws took
18
+ `updateInfoBar` and the chip controls out of `stream_handlers.js`'s deps (and `updateInfoBar`/`updateComposerMode`
19
+ out of `send_turn.js`, `session_actions.js` and `lists_view.js`); a page loaded before keeps all its old modules
20
+ until a reload, so the risk is a mixed cache, not an open tab. `answer_fold.js`,
21
+ `turn_range.js`, `history_reveal.js`, `latest.js`, `session_payload.js`, `method_api.js`, `session_store.js`,
22
+ `session_fields.js`, `feature_gates.js` and `revise_stack.js` are new modules for that reason.
23
+
24
+ ## Two pages, two models
25
+
26
+ | Page | Entry | Model |
27
+ |---|---|---|
28
+ | **Owner page**: the start page and a session page | `index.html` → `app.js` | **Render once, then patch.** A session's open (`select` → `resync` → `renderSessionView`) draws the history from `GET /api/sessions/:id`; from then on each stream event changes the DOM in place (`stream_handlers.js`). The DOM *is* the log. The start page is the same app with `store.selected` null. |
29
+ | **Participant page**: a review or pair link | `participant.html` → `participant.js` | **Re-read at a turn boundary, then a keyed redraw.** It reads `GET …/review`, draws it, and streams; an event that ends or changes a turn (`reloads`) marks the data stale and the page reads it again. `drawnMessages` keeps a message's node while its signature is unchanged; a small `live` overlay (`participant_model.js applyLive`) draws the running turn on top. |
30
+
31
+ The participant page is **deliberately separate**: it shares leaf modules with the owner page (the annotate bar,
32
+ claim links, `composer_mount.js`, the verdict card and Verify fresh, `verify_target.js`, `verify_preset.js`,
33
+ `auth_lost.js`, `token_fragment.js`, `format.js`) but none of the data layer, the stream handling or the message
34
+ rendering. It has its own `api` (`{status, ok, body}`, never throws, its own ended / auth-lost handling) and its own
35
+ `EventSource`. Its parallel copies of the owner's Verify and claim helpers (`verifyFreshState`, `verifyAnchorFor`,
36
+ `showPendingClaim`, `CLAIM_ROOTS`) differ on purpose in places (the source kind `reply` vs `answer`, the server's
37
+ `fresh_running` vs `verify_fresh`, no home/cwd in its claim options). Don't converge them as a side effect of another
38
+ change.
39
+
40
+ ## Where state lives (owner page)
41
+
42
+ | Holder | What | Who writes | How a change reaches the screen |
43
+ |---|---|---|---|
44
+ | **The session store** (`session_store.js`; app.js `store`) | `store.selected` (the open session's id), `store.current` and `store.sessions` (below) | through the store only (see The session store); other modules get getters (`selected: () => store.selected`, `state: () => store.current`) or the store itself (`stream_handlers.js`, `send_turn.js`, `session_actions.js`) | the store runs the redraws that read what changed (below); nobody calls them by hand |
45
+ | **app.js module scope**: about 20 top-level `let`s (`streamControl`, `eventsControl`, `pageCapabilities`/`pageEditorUrl`, `pendingVerifyAnchor`, `pendingClaim`, `resyncsInFlight`/`resyncAgain`, `resyncAttempts`, `scopeError`…) | the page's mode and its streams | app.js only; other modules get getters and hooks | the writer touches the redraw that reads it (`store.touch("infoBar")`: the stream, versions, `restartingIds`), or calls a redraw only it feeds |
46
+ | **`store.current`** (`session_state.js`: `emptySessionState` / `sessionStateFrom`; per field: `session_fields.js FIELDS`) | the open session's fields, about 40 (info bar, chips, verdicts, timing, worker, stream cursor) | `store.update` / `setStatus` from app.js, `stream_handlers.js`, `send_turn.js` (`firstPreview`, `owner`), `session_actions.js` (`status`); `forgetSession`'s `replaceCurrent`. Two writers in place, on purpose (D8 in `FIELDS`): live_timing.js's `activeTurn`, verdict_fresh.js's running list | the store runs the field's redraws (`FIELDS[key].redraws`) |
47
+ | **`store.sessions`** (the hub's projection) | every card | `store.update({sessions})` from app.js: `openSessionEvents` (`applySessionEvent`), `refresh` (the list fallback), `addCreatedSession`, `applyView` (re-sorted), `replaceSelectedCard` (`setLiveStatus` through `setStatus`, `readTurnEnd`, `setUsedMemories`), the stream's close (`withoutWorker`); `session_actions.js` (`setSessions`) | the store: `lists` and `infoBar`. **Always a new array**; an entry is replaced, never changed in place. `followSelectedSession` copies the open one's owner and worker (`fieldsFromCard`) and, when not streaming, its status (`setStatus(…, "hub")`) into `store.current` |
48
+ | **Factory closures**: the `create*` factories (popovers, panels, views) | each component's own state: open-for, busy, error, its own fetched list | the factory | an `onChange` hook back into app.js, or a getter the factory reads at draw time |
49
+ | **The history DOM** | the conversation as drawn: which turn a bubble belongs to (`data-turn-id`, `data-answer-turn`), kept command bubbles, which texts were shown, unnamed badges, folded answers (`.folded`), Revise stamps (`data-revises` on a prompt; a Verify turn's `data-verify`) and the stacks' folded versions (`.stack-hidden`, `.stack-stub.open`) | `bubbles.js`, `cards_place.js`, `question_cards.js`, `turn_block.js`/`stage_view.js`, `answer_fold.js`, `revise_stack.js`, `stream_handlers.js` (a live `turn_completed`'s `revises` onto its prompt) | read back as state: `turn_range.js turnElements` (one walker for a turn's elements), `keptCommandBubbles`, `seedSeenFromDom`, `nameUnnamedBadges`/`nameVerdictCards`, `answer_fold.js` and `revise_stack.js` (their observers decorate every path that fills `#history`; the stacks are `stacks()` of the stamps, read again on each change, never a store field), `history_reveal.js revealInHistory` (the one way a jump unfolds what hides its target) |
50
+ | **localStorage** (`storage.js` `readStored`/`writeStored`, guarded) | per-browser choices only | the module that owns the choice | read at start (see Storage keys) |
51
+ | **SSE** | see Streams | the server | owner page: events go into the DOM directly; participant page: an event marks the data stale |
52
+
53
+ The start page's own choices (the model, the ⚙ panel, the 🧰 list) live in `start_page.js`'s closure, never in
54
+ `store.current`. A fresh chain link's picker reads both.
55
+
56
+ ## The session store
57
+
58
+ `session_store.js` holds the open session's state; `session_fields.js` says what each field is. **Every write goes
59
+ through the store**, never `current.X = …`:
60
+
61
+ - `store.update({selected?, current?: {field: value}, sessions?})`: `current`'s fields are written into the same
62
+ object (it is replaced only by a session switch); `sessions` is always a new array (one changed in place isn't
63
+ seen).
64
+ - `store.setStatus(value, source, {streaming})`: the one way `status` changes (below).
65
+ - `store.replaceCurrent(state)`: `forgetSession` only; always a new object, so a stale async writer still holding the
66
+ old one writes there alone.
67
+ - `store.batch(fn)`, `store.touch(name)`, `store.on(name, fn)`: the redraws (below).
68
+
69
+ The ESLint block `samagotchi/session-writes` (eslint.config.js) enforces it in the six modules that hold the state
70
+ (app.js, `stream_handlers.js`, `send_turn.js`, `session_actions.js`, `start_page.js`, `working_set_view.js`): an
71
+ assignment, `++`, `delete` or `Object.assign` into `current`, `store.current` or `state()` is an error
72
+ (`spec/web/public/lint_session_writes.test.js` runs it on a sample). An alias (`const st = state(); st.x = …`) isn't
73
+ caught: don't write through one.
74
+
75
+ **Read `store.current` fresh after every await.** A `const cur = store.current` held across an await pins the object
76
+ from before it, which a session switch (`forgetSession`) may have replaced: app.js's `readTurnEnd`,
77
+ `mergeTurnTiming`, `resync` and `refreshTimingFromSession` read it again after each await. Keep `store.sessions`
78
+ reads fresh too.
79
+
80
+ ### Fields and their redraws
81
+
82
+ `session_fields.js FIELDS` has one entry per `emptySessionState` key: its payload source (a partial re-read takes a
83
+ field only when the payload carries it: `session_payload.js` reads its sources from here), its hub card source
84
+ (`fieldsFromCard`: owner, workerVersion, workerFeatures, bridgeUp; never status), its redraws (store `ORDER` names)
85
+ and its writers. A new field starts in `emptySessionState` and gets an entry (its spec checks both).
86
+
87
+ A changed value (`!==`) marks its field's redraws; `sessions` marks `lists` and `infoBar` (the bar reads the list:
88
+ the chain's ← → links, the children chip, the archived button, the card's title, the fresh link's picker); `selected`
89
+ marks `paintActive`, `composer` and `infoBar`; `touch(name)` marks one redraw for an input the store doesn't hold
90
+ (the clock, versions, the stream, `liveTurn.running`). They run synchronously, once each, at the end of the outermost
91
+ `batch()` (at once for a bare write), in one `ORDER`: `lists`, `paintActive`, the chip popovers (`llmContextChip`,
92
+ `thinkingChip`, `reasoningChip`, `workingSetStrategy`), `featureGates`, `composer`, `infoBar`. `lists` comes before
93
+ the bar because `updateSessionTitle` reads the strip's active card; `composer` before the bar as `setTurnRunning`
94
+ runs them. Not a rAF (a background tab runs none, and the title badge tests run there; the e2e per-frame checks would
95
+ see the composer move a frame late) and not a microtask (code that reads the page right after a write would see it
96
+ stale).
97
+
98
+ **The redraws are registered, not called.** app.js registers each name once (`store.on`, at the bottom, once every
99
+ piece a redraw calls exists): `lists` → `lists.render()`, `paintActive` → `lists.paintActive()`, the chips →
100
+ `xControl.refresh()`, `workingSetStrategy` → `workingSet.strategyChanged()`, `featureGates` (below), `composer` →
101
+ `updateComposerMode()`, `infoBar` → `updateInfoBar()`. A writer writes and stops there: no `updateInfoBar()` after
102
+ a store write. A value written unchanged redraws nothing (a chunk with the same ctx %).
103
+
104
+ An input the store doesn't hold calls `store.touch(name)` where it changes: `infoBar` for the clock (liveTurn's
105
+ `onTick`), the versions (`noticeVersions`), `restartingIds`, the stream (`startStream`, `workerGone`), the working
106
+ set's and the participants' `onChange`, the copy-attach timer, the continue and review in-flight sets
107
+ (`session_actions.js`); `composer` and `infoBar` for `liveTurn.running` (`setTurnRunning`) and at the end of
108
+ `renderSessionView` (a whole view drawn). Left as they are, on purpose: the `narrowScreen` listener calls
109
+ `updateComposerMode()` (the bar doesn't read it), `applyStripHidden`/`applyView` call `updateSessionTitle()`, and
110
+ `closeStream`/`stream.restart` set `streamControl = null` without a redraw (a resync or a workerGone follows).
111
+
112
+ A function that writes more than once batches its synchronous part, so each redraw runs once: `select` (before its
113
+ `await resync()`), `clearSelection`, the hub's `snapshot` and `session` events, `workerGone`, `setTurnRunning`,
114
+ `turn_started` (inside `keepFollowing`, so the composer and the bar redraw before its scroll), `readTurnEnd` (after
115
+ its timing merge), `setUsedMemories`, and `renderSessionView`'s field write only: the replay's own
116
+ `setTurnRunning` redraws where they run, before `turnDom.flush()`. A stream handler writes one patch.
117
+
118
+ `batch(fn)`:
119
+ - `fn` is synchronous: one that returns a promise is refused (`console.error`, the redraws run at once). Batch an
120
+ async function's synchronous parts only, never across an await.
121
+ - The redraws run in `finally`, also when `fn` throws (the page shows what was written), then `fn`'s error is
122
+ rethrown; a redraw error meanwhile goes to `console.error`. A throw never leaves the store inside a batch.
123
+ - During a flush, a write, a touch or a batch's end only enqueues: the next pass runs it, after the current pass's
124
+ `ORDER`. Passes stop at 3 (`console.error` names the redraw). A redraw that throws doesn't stop the others; its
125
+ error is thrown after the flush.
126
+
127
+ ### `status`
128
+
129
+ Six writers, each with its rule, all through `setStatus(value, source)` (`session_fields.js statusWrite`):
130
+
131
+ | source | writer | applies | the open session's card |
132
+ |---|---|---|---|
133
+ | `payload` | `renderSessionView` | always | left alone |
134
+ | `hub` | `followSelectedSession` | only while the stream isn't live | left alone (the card came from the hub) |
135
+ | `stream` | `setLiveStatus` (`turn_*`, `workerGone`'s idle) | always | `withLiveStatus`: status, owner, `updated_at` |
136
+ | `turnEnd` | `readTurnEnd`, the turn_canceled re-read (`refreshTimingFromSession`) | when the payload carries one (the caller skips it otherwise), and **not while a turn runs**: a re-read that lands after the next queued turn's `turn_started` doesn't write its stored "idle" over "running" (the test `mergeTurnTiming` keeps `activeTurn` by) | left alone: `readTurnEnd` writes its card itself after its timing merge, with `used_memory_names` |
137
+ | `stop` | `session_actions.js stoppedWorker` | always | left alone (`workerGone`'s `withoutWorker` changes it) |
138
+
139
+ `setStatus` answers whether it replaced the card; the new list redraws the lists and the bar by itself.
140
+
141
+ ### Feature gates
142
+
143
+ A page action the open session's worker must support (its sidecar features and a live bridge) is a gate in
144
+ `feature_gates.js`, registered as the store's `featureGates` redraw: `{name, unavailable(current), redraw}`. It runs
145
+ when a field it reads changes (`workerFeatures`, `bridgeUp`), and a gate's `redraw` only when its answer flipped: a
146
+ restart's hub event that brings the feature enables it, the worker going (`workerGone`'s `bridgeUp = false`) says
147
+ it's unavailable. `forgetSession` and `renderSessionView` draw the history with the answer already, so they `prime()`
148
+ the gates in the batch of their write, and nothing fires. Today two gates: `verify` (Verify fresh on a verdict's
149
+ card, `redrawFresh`) and `revise` (the answers' Revise buttons and the composer's Revising chip, `redrawRevise`).
150
+ Both refuse only a live worker without the feature: a worker that isn't up is woken by the send as a new one, which
151
+ has it. A new action that needs a worker feature is one more entry.
152
+
153
+ ### Exceptions
154
+
155
+ - **Writers in place (D8)**, named in `FIELDS`: live_timing.js sets `timing().activeTurn` (the running turn's own
156
+ clock; `mergeTurnTiming` keeps it), and verdict_fresh.js `startFresh` pushes onto `fresh.running`, which is
157
+ `store.current.verifyFresh`.
158
+ - **Events, not state (D9)**: these stay direct calls next to their write: a `/model`'s picker sync
159
+ (`syncModelPick`), the working set's `commandRan`, the context chips on a note, the participants reload, a
160
+ verdict's own `renderFresh`. (The working set's `strategyChanged` and the Verify gate's `redrawFresh` follow
161
+ fields: they are registered redraws.)
162
+
163
+ A partial re-read of the open session (a `?tail`/`?recent` read) goes through **`applySessionPayload(data,
164
+ {fields})`** in app.js (`session_payload.js sessionFieldsFrom`): the named fields read as a full load reads them, a
165
+ field whose source the payload doesn't carry left alone; its status goes through `setStatus(…, "turnEnd")`. Their
166
+ redraws follow. Never copy payload keys into `store.current` by hand.
167
+
168
+ The redraws, by their store names:
169
+ - `infoBar` (`updateInfoBar()`): the session bar's buttons and info line (`info_bar_model.js sessionBar` →
170
+ `info_bar.js`), the annotate bar, the fold controls, `updateSessionTitle()` and the fresh link's picker. Cheap: it
171
+ runs on most writes and every clock tick.
172
+ - `composer` (`updateComposerMode()`): the composer's placeholder, buttons and delivery line (selected, running,
173
+ owner).
174
+ - `llmContextChip`, `thinkingChip`, `reasoningChip` (`xControl.refresh()`): an info-bar chip's **open popover** (the
175
+ chip itself is drawn by the bar); `workingSetStrategy`: the working set's open list.
176
+ - `lists` / `paintActive` (`lists.render()` / `lists.paintActive()`): the strip and the all view from
177
+ `store.sessions`.
178
+ - `featureGates`: the gates above.
179
+
180
+ `forgetSession()` is the one reset: `select` and `clearSelection` both call it, so anything per-session that isn't
181
+ in `store.current` (capabilities, the markdown warning and tip, the init row, the Verify claim, the armed Revise,
182
+ the running turn's DOM) is cleared there, and the feature gates are primed for the empty session. A new per-session holder
183
+ (an armed composer anchor like the Verify claim or the Revise) goes into it. The armed Revise
184
+ (`pendingReviseAnchor`) is set only through `setReviseAnchor`, which redraws its chip and the Revise buttons (a
185
+ redraw only it feeds, not a store field).
186
+
187
+ ## Render idioms
188
+
189
+ Three, for one job. Pick by what the piece is:
190
+
191
+ | Idiom | Where | Use it for |
192
+ |---|---|---|
193
+ | **lit `render(template, keptContainer)`** | most components (~26): the info bar, chips and their popovers, panels, the init row, the working set, `start_page.js` | anything redrawn while the user may be in it: lit keeps the nodes, so focus, typed text, an open `<details>` and a tooltip survive. **The default for new UI.** |
194
+ | **lit as a string builder**: `turn_html.js elementOf(template)`, `lists_view.js` (a fresh fragment, then `replaceChildren`) | history bubbles appended once; the session lists | a node built once and then owned by the DOM (history), or a list redrawn whole. `lists_view.js` throws away lit's diffing and binds card listeners per draw, with an `entering` tracker for its animations |
195
+ | **hand-built DOM** (`createElement`) | `question_cards.js`, `verdict_card.js`, `verdict_fresh.js`, `participant.js` (`el()`) and a few small ones | older pieces; don't add new ones this way |
196
+
197
+ Listeners: every factory is a page-lifetime singleton, so `addEventListener` without a remove is fine; a per-render
198
+ listener lands on a fresh node. A popover goes through `chip_popover.js createChipPopover` (its own outside-click,
199
+ Esc, placement and resize); `context_chips.js`, `model_picker.js`, `ref_menu.js`, `command_list.js` and the lists'
200
+ families/chains popovers still hand-roll theirs.
201
+
202
+ ## Stale answers
203
+
204
+ The page switches sessions, reopens a popover or asks again while a fetch is out. An answer for the old ask must be
205
+ dropped. **`latest.js`** is the guard:
206
+
207
+ - `stillOn(get, at = get())`: `() => true` while `get()` is still `at`. For a one-off read about the open session
208
+ (`const still = stillOn(openId); … await …; if (!still()) return;`). app.js has `openId = () => store.selected`.
209
+ - `createLatest(get)`: `begin()` gives a guard that also goes stale when a newer ask begins; `drop()` makes every
210
+ ask out stale (a close, a reset). For a list that is re-read.
211
+
212
+ On it: `context_chips.js`, `project_picker.js`, `participants_panel.js` (`load`), `cards_place.js`
213
+ (`refreshCardBodies`), `stream_handlers.js` (`answer_display`), `working_set_view.js` (`fetchServer`) and app.js's
214
+ turn-end re-reads (`readTurnEnd`, `refreshTimingFromSession`, `mergeTurnTiming`) and init-row timers. Still by hand
215
+ (the id decides more than "drop it"): app.js `resync`/`retryResync` (the re-read budget, `resyncAgain`), the stream's
216
+ close, `session_actions.js` (continue/review open another session on purpose), `send_turn.js` (a failure for another
217
+ session becomes a toast), and `participant.js` (its own `loadSeq`, the separate page). A new await in a component
218
+ uses `latest.js`.
219
+
220
+ ## API clients
221
+
222
+ - `data.js api(path, opts)`: the owner page's. Throws an `Error` with the server's words, `.status`, `.code` (the
223
+ server's error code: match on it, never on the words) and `.body`; a signed-out proxy answer (401, a redirect,
224
+ HTML) or a fetch that fails on a sign-in redirect reports to `auth_lost.js` (`onAuthLost`, set by app.js).
225
+ `cantText(what, error)` is the toast.
226
+ - `method_api.js methodApi(method, path, body)`: the same, as `notifications.js` and `push_devices.js` call it.
227
+ - `participant.js api`: its own (see above).
228
+
229
+ ## Streams
230
+
231
+ | Stream | Opened by | Lifecycle |
232
+ |---|---|---|
233
+ | The hub, `GET /api/events` (`data.js openEvents`) | app.js `openSessionEvents` (scoped by `?dir`) | `snapshot` then `session` / `session_gone` → `applySessionEvent` → `allSessions`; EventSource reconnects itself (a fresh snapshot); `onClosed` (a proxy sign-in on reconnect): probe, reopen after `EVENTS_REOPEN_MS` |
234
+ | A second hub stream, unscoped | `notifications.js` (`allEvents`) on a project's page | the Home Screen app's device tidy-up across projects |
235
+ | The open session, `GET /api/sessions/:id/stream?from_seq=` (`data.js openStream`) | app.js `startStream` from `store.current.streamSeq` | events → `stream_handlers.js`. It closes itself on an error; the page resyncs (`resync`: a fresh render and a new stream from its cursor), once at once when it had opened, else after `STREAM_RETRY_MS` |
236
+ | The participant stream | `participant.js connect` | listens for `STREAM_EVENTS`, which is `Web::ParticipantView::EVENTS` (checked by `spec/web/participant_view_spec.rb`); reopened from fresh data when CLOSED |
237
+
238
+ A new event type for the owner page is a handler in `stream_handlers.js` (the participant view spec makes it decide
239
+ `EVENTS` or `DROPPED`); for the participant page also an entry in `STREAM_EVENTS`.
240
+
241
+ ## Storage keys
242
+
243
+ Through `storage.js` (`readStored`/`writeStored`: storage can throw, a missing value is the default), except
244
+ `notifications.js`, which takes an injected storage for its specs. Per-browser choices only: never anything that must
245
+ reach another device or the server.
246
+
247
+ | Key | Module | What |
248
+ |---|---|---|
249
+ | `chi_model`, `chi_model_recent` | `model_pick.js`, `model_picker.js`, `start_page.js` | the start page's model and its recent list |
250
+ | `chi_composer_collapsed` | `composer_fold.js` | the composer folded |
251
+ | `chi_composer_floor` | `composer_grip.js`, `composer_mount.js` | the composer's dragged height |
252
+ | `chi_strip_hidden` (`chi_zen`: the old name, read once) | app.js | the session strip hidden |
253
+ | `chi_stage_collapsed` | `stage_view.js` | the stage collapsed |
254
+ | `chi_working_set_collapsed` | `working_set_rail.js` | the working set rail collapsed |
255
+ | `chi_markdown_tip_dismissed` | `markdown_tip.js` | the markdown tip dismissed |
256
+ | `chi_update_available_shown` | app.js | update notices already shown |
257
+ | `chi_notify`, `chi_push_device` | `notifications.js` (an injected storage) | the bell's state, this device's push id |
258
+ | `chi_answer_fold:<session>` | `answer_fold.js`, `revise_stack.js` | the answers folded by hand, and the Revise stubs unfolded by hand (`stub:<turn id>`) (swept with the session list) |
259
+ | `chi_review_history:<grant>`, `chi_review_history_session:<session>` | `review_history.js` (participant page) | a participant's composer history |
260
+
261
+ sessionStorage: `chi_client_id` (`turn_events.js`: this tab's client id, kept over a reload mid-turn) and
262
+ `chi.authReloadAt` (`auth_lost.js`: one automatic reload a minute).
data/docs/plugins.md CHANGED
@@ -1310,7 +1310,8 @@ bundle.shutdown
1310
1310
  value, or raises `Samagotchi::Testing::Waiting::TimedOut` ("timed out after 5 s waiting for <what>", plus
1311
1311
  `; last saw: <value>` from the `seen:` callable). `join!(thread, what)` and `pop!(queue, what)` are
1312
1312
  `Thread#join` and `Queue#pop` with that deadline and message; `wait_until { }` returns the block's last value
1313
- instead of raising, for a wait whose timeout is fine; `process_gone?(pid)`. Every deadline is multiplied by
1313
+ instead of raising, for a wait whose timeout is fine; `process_gone?(pid)`; `time_bound(seconds, below: nil)`, a
1314
+ wall-clock upper bound scaled the same way and kept under `below:` (the unscaled deadline it tells apart from). Every deadline is multiplied by
1314
1315
  `SPEC_TIME_SCALE` (default 1, at least 1; chi's CI sets 2), so a slow runner gets headroom.
1315
1316
  - **Model notes:** `sandbox.model_notes_for(model_id, small: nil, cwd: nil)` lists the model notes chi would load
1316
1317
  into the prompt for a bare model id (`deepseek/deepseek-v4.1-flash`, no host prefix), from the sandbox's