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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -1
- data/bin/chi +2 -3
- data/docs/architecture.md +6 -3
- data/docs/cli.md +50 -34
- data/docs/configuration.md +9 -6
- data/docs/guardrails.md +146 -13
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/engine.md +6 -3
- data/docs/internals/participants.md +7 -6
- data/docs/internals/tool-guardrails.md +2 -0
- data/docs/internals/web-frontend.md +262 -0
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +5 -2
- data/docs/testing.md +34 -4
- data/lib/samagotchi/bridge.rb +11 -6
- data/lib/samagotchi/bridge_client.rb +8 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_home.rb +12 -2
- data/lib/samagotchi/config.rb +0 -2
- data/lib/samagotchi/desktop/macos.rb +19 -4
- data/lib/samagotchi/engine.rb +82 -12
- data/lib/samagotchi/guardrail_wiring.rb +8 -6
- data/lib/samagotchi/guardrails/approval.rb +41 -5
- data/lib/samagotchi/guardrails/implicit_writers.rb +158 -0
- data/lib/samagotchi/guardrails/pair_boundary.rb +11 -5
- data/lib/samagotchi/guardrails/pair_command.rb +828 -86
- data/lib/samagotchi/guardrails/parent_approvals.rb +13 -16
- data/lib/samagotchi/guardrails/read_only_shell.rb +58 -4
- data/lib/samagotchi/guardrails/shared_git.rb +1 -1
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +7 -2
- data/lib/samagotchi/guardrails/shell_lex.rb +45 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +3 -2
- data/lib/samagotchi/guardrails/targets.rb +8 -3
- data/lib/samagotchi/guardrails_command.rb +13 -10
- data/lib/samagotchi/home_command.rb +22 -6
- data/lib/samagotchi/home_doctor.rb +36 -3
- data/lib/samagotchi/home_notice.rb +8 -1
- data/lib/samagotchi/hooks/loader.rb +5 -0
- data/lib/samagotchi/llm/chat_loop.rb +4 -3
- data/lib/samagotchi/log_line.rb +12 -0
- data/lib/samagotchi/message_parts.rb +1 -1
- data/lib/samagotchi/project_env.rb +3 -11
- data/lib/samagotchi/revise_anchor.rb +33 -0
- data/lib/samagotchi/self_report.rb +3 -1
- data/lib/samagotchi/session_inbox.rb +20 -12
- data/lib/samagotchi/session_manager.rb +13 -11
- data/lib/samagotchi/system_one/approval_question.rb +21 -16
- data/lib/samagotchi/system_one/self_protect.rb +70 -16
- data/lib/samagotchi/testing/waiting.rb +13 -0
- data/lib/samagotchi/toolkit_binding.rb +16 -0
- data/lib/samagotchi/tools/delegate_relay.rb +6 -5
- data/lib/samagotchi/tools/delegate_wait.rb +5 -5
- data/lib/samagotchi/tools/execute.rb +6 -6
- data/lib/samagotchi/tools/output_guardrails.rb +9 -2
- data/lib/samagotchi/tools/read.rb +5 -5
- data/lib/samagotchi/turn_anchors.rb +49 -0
- data/lib/samagotchi/update_command.rb +7 -3
- data/lib/samagotchi/update_hint.rb +6 -1
- data/lib/samagotchi/verify_anchor.rb +5 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -14
- data/lib/samagotchi/web/message_display.rb +17 -1
- data/lib/samagotchi/web/participant_access.rb +2 -2
- data/lib/samagotchi/web/participant_view.rb +8 -0
- data/lib/samagotchi/web/public/activity.js +1 -1
- data/lib/samagotchi/web/public/annotations.js +1 -1
- data/lib/samagotchi/web/public/answer_fold.js +262 -0
- data/lib/samagotchi/web/public/answer_refs.js +2 -0
- data/lib/samagotchi/web/public/app.js +462 -338
- data/lib/samagotchi/web/public/bubbles.js +3 -3
- data/lib/samagotchi/web/public/cards_place.js +9 -4
- data/lib/samagotchi/web/public/chip_popover.js +73 -11
- data/lib/samagotchi/web/public/claim_link.js +4 -2
- data/lib/samagotchi/web/public/composer.css +11 -1
- data/lib/samagotchi/web/public/context_chips.js +9 -6
- data/lib/samagotchi/web/public/data.js +4 -2
- data/lib/samagotchi/web/public/dock_reserve.js +13 -15
- data/lib/samagotchi/web/public/feature_gates.js +33 -0
- data/lib/samagotchi/web/public/history_reveal.js +27 -0
- data/lib/samagotchi/web/public/index.html +82 -37
- data/lib/samagotchi/web/public/latest.js +46 -0
- data/lib/samagotchi/web/public/lists_view.js +15 -13
- data/lib/samagotchi/web/public/live_timing.js +2 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +5 -13
- data/lib/samagotchi/web/public/markdown_place.js +1 -1
- data/lib/samagotchi/web/public/method_api.js +14 -0
- data/lib/samagotchi/web/public/notifications.js +4 -11
- data/lib/samagotchi/web/public/participant.js +11 -6
- data/lib/samagotchi/web/public/participant_names.js +1 -1
- data/lib/samagotchi/web/public/participants_panel.js +4 -2
- data/lib/samagotchi/web/public/project_picker.js +18 -22
- data/lib/samagotchi/web/public/question_cards.js +2 -2
- data/lib/samagotchi/web/public/refs.js +6 -7
- data/lib/samagotchi/web/public/restore.js +2 -0
- data/lib/samagotchi/web/public/review_start.js +1 -1
- data/lib/samagotchi/web/public/revise_stack.js +372 -0
- data/lib/samagotchi/web/public/scope.js +12 -20
- data/lib/samagotchi/web/public/send_turn.js +29 -16
- data/lib/samagotchi/web/public/session_actions.js +15 -14
- data/lib/samagotchi/web/public/session_fields.js +152 -0
- data/lib/samagotchi/web/public/session_payload.js +35 -0
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/session_store.js +191 -0
- data/lib/samagotchi/web/public/stage_model.js +4 -4
- data/lib/samagotchi/web/public/stage_view.js +11 -16
- data/lib/samagotchi/web/public/start_page.js +4 -4
- data/lib/samagotchi/web/public/start_settings.js +29 -18
- data/lib/samagotchi/web/public/start_toolkit.js +5 -4
- data/lib/samagotchi/web/public/stream_handlers.js +88 -90
- data/lib/samagotchi/web/public/task_stop.js +1 -1
- data/lib/samagotchi/web/public/timing.js +7 -3
- data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +46 -101
- data/lib/samagotchi/web/public/turn_events.js +2 -2
- data/lib/samagotchi/web/public/turn_html.js +17 -9
- data/lib/samagotchi/web/public/turn_model.js +6 -6
- data/lib/samagotchi/web/public/turn_range.js +32 -0
- data/lib/samagotchi/web/public/verdict_card.js +5 -13
- data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
- data/lib/samagotchi/web/public/verify_target.js +7 -10
- data/lib/samagotchi/web/public/working_set_view.js +3 -1
- data/lib/samagotchi/web/server.rb +4 -4
- data/lib/samagotchi/web/session_control.rb +2 -2
- data/lib/samagotchi/worker.rb +9 -9
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +16 -3
- 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
|
|
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
|
|
441
|
-
|
|
442
|
-
|
|
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
|
|
445
|
-
|
|
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).
|
|
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`
|
|
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
|
-
|
|
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.
|
|
1065
|
-
|
|
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
|
|
data/docs/internals/engine.md
CHANGED
|
@@ -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 `
|
|
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}
|
|
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
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
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, `
|
|
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)
|
|
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
|