samagotchi 0.150.0 → 0.152.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/bundle-authoring.md +5 -5
  5. data/docs/cli.md +32 -7
  6. data/docs/configuration.md +1 -1
  7. data/docs/guardrails.md +4 -0
  8. data/docs/internals/block-renderers.md +98 -0
  9. data/docs/internals/bridge.md +53 -17
  10. data/docs/internals/bundles.md +49 -14
  11. data/docs/internals/objectives.md +6 -4
  12. data/docs/internals/web-frontend.md +2 -2
  13. data/docs/memory.md +7 -2
  14. data/docs/plugins.md +91 -14
  15. data/docs/releasing.md +7 -2
  16. data/docs/sessions.md +1 -1
  17. data/lib/samagotchi/block_renderers.rb +116 -0
  18. data/lib/samagotchi/bridge/card_places.rb +93 -0
  19. data/lib/samagotchi/bridge/card_store.rb +219 -122
  20. data/lib/samagotchi/bridge.rb +12 -4
  21. data/lib/samagotchi/bundle_command/check.rb +43 -3
  22. data/lib/samagotchi/bundle_command/installed_check.rb +6 -2
  23. data/lib/samagotchi/bundle_command/try/sandbox.rb +11 -3
  24. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  25. data/lib/samagotchi/bundle_command.rb +88 -10
  26. data/lib/samagotchi/bundle_needs.rb +4 -6
  27. data/lib/samagotchi/bundle_settings_check.rb +14 -13
  28. data/lib/samagotchi/bundles/agents-md/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  33. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/system/memory_guide.md +3 -3
  36. data/lib/samagotchi/child_env.rb +15 -0
  37. data/lib/samagotchi/context_providers.rb +42 -10
  38. data/lib/samagotchi/engine.rb +47 -6
  39. data/lib/samagotchi/extension_load.rb +4 -3
  40. data/lib/samagotchi/guardrail_wiring.rb +28 -19
  41. data/lib/samagotchi/guardrails/approval.rb +2 -2
  42. data/lib/samagotchi/guardrails/rules.rb +1 -0
  43. data/lib/samagotchi/guardrails/verdict.rb +20 -0
  44. data/lib/samagotchi/hooks/bundle_loader.rb +5 -7
  45. data/lib/samagotchi/memory_bundle/asset_installer.rb +18 -9
  46. data/lib/samagotchi/memory_bundle/builder.rb +39 -31
  47. data/lib/samagotchi/memory_bundle/bundle_hook.rb +3 -2
  48. data/lib/samagotchi/memory_bundle/file_digest.rb +31 -0
  49. data/lib/samagotchi/memory_bundle/file_standing.rb +2 -2
  50. data/lib/samagotchi/memory_bundle/installed_bundle.rb +55 -43
  51. data/lib/samagotchi/memory_bundle/installed_copy.rb +38 -14
  52. data/lib/samagotchi/memory_bundle/installed_manifest.rb +224 -0
  53. data/lib/samagotchi/memory_bundle/installer.rb +91 -16
  54. data/lib/samagotchi/memory_bundle/manifest.rb +166 -5
  55. data/lib/samagotchi/memory_bundle/manifest_standing.rb +171 -0
  56. data/lib/samagotchi/memory_bundle/merger.rb +7 -8
  57. data/lib/samagotchi/memory_bundle/profile.rb +3 -2
  58. data/lib/samagotchi/memory_bundle/provenance.rb +220 -19
  59. data/lib/samagotchi/memory_bundle/shas.rb +4 -3
  60. data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -24
  61. data/lib/samagotchi/memory_bundle/status.rb +11 -10
  62. data/lib/samagotchi/memory_bundle/system_bundle.rb +13 -2
  63. data/lib/samagotchi/plugin/loader.rb +1 -1
  64. data/lib/samagotchi/session_chain.rb +2 -7
  65. data/lib/samagotchi/session_commands.rb +15 -9
  66. data/lib/samagotchi/session_config.rb +5 -4
  67. data/lib/samagotchi/session_manager.rb +10 -7
  68. data/lib/samagotchi/steer.rb +17 -0
  69. data/lib/samagotchi/system_one/record.rb +10 -2
  70. data/lib/samagotchi/terminal_ui.rb +4 -4
  71. data/lib/samagotchi/tools/delegate.rb +20 -2
  72. data/lib/samagotchi/turn_checkpoint.rb +11 -5
  73. data/lib/samagotchi/turn_flow.rb +2 -3
  74. data/lib/samagotchi/update_command.rb +13 -2
  75. data/lib/samagotchi/version.rb +1 -1
  76. data/lib/samagotchi/web/app.rb +24 -13
  77. data/lib/samagotchi/web/block_renderer_routes.rb +32 -0
  78. data/lib/samagotchi/web/markdown_renderer.rb +39 -6
  79. data/lib/samagotchi/web/message_display.rb +3 -3
  80. data/lib/samagotchi/web/participant_view.rb +1 -1
  81. data/lib/samagotchi/web/public/app.js +2 -0
  82. data/lib/samagotchi/web/public/block_frame.js +202 -0
  83. data/lib/samagotchi/web/public/block_host.js +502 -0
  84. data/lib/samagotchi/web/public/block_renderers.css +20 -0
  85. data/lib/samagotchi/web/public/block_source.js +37 -0
  86. data/lib/samagotchi/web/public/bubbles.js +9 -5
  87. data/lib/samagotchi/web/public/card.js +17 -10
  88. data/lib/samagotchi/web/public/cards_place.js +2 -2
  89. data/lib/samagotchi/web/public/claim_link.js +4 -2
  90. data/lib/samagotchi/web/public/history_reveal.js +4 -1
  91. data/lib/samagotchi/web/public/index.html +2 -0
  92. data/lib/samagotchi/web/public/markdown_place.js +2 -3
  93. data/lib/samagotchi/web/public/text_roots.js +6 -0
  94. data/lib/samagotchi/web/public/turn_html.js +4 -1
  95. data/lib/samagotchi/web/session_access_routes.rb +2 -1
  96. data/lib/samagotchi/web/session_hub.rb +6 -0
  97. data/lib/samagotchi/web/session_show.rb +1 -1
  98. data/lib/samagotchi/worker.rb +4 -2
  99. metadata +13 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: caeb209690eb9b78ab3977c53ed4c7bbe7388b0a15e51b59b70da653ac9a9bdc
4
- data.tar.gz: 43cd03b5039cc16b588323f82ca0e063053d4b78de83b9785e66dd7dbf8d4a71
3
+ metadata.gz: 6d208a5aecb9745a21107ab97399c7ec4901c24fd781e3ba4ec1658a69764ded
4
+ data.tar.gz: abc21f8bfe39f0bdc614b70b755dd3cd195d016dd305b6d3ba28b018d0b75d54
5
5
  SHA512:
6
- metadata.gz: 84a48999dd02619caa8900b8e2897614c93089519fa5e87d05e5f743b7073546e61b2c4e49a768798f1e612ef29eb768a86c7e69197e1880348ab5b767291ec4
7
- data.tar.gz: 65ff02a943a5d58615976d531b22114c40d015b24098d230b5b389abff7bdacd33cabcb9b5622a3b57d054d2e156878a945a6e83895547ffe6dba8c929e0ab5b
6
+ metadata.gz: 1105443bb7097fd93f0ab4bb1f0f9926fbbc9c89f9d57584d12ae3ac560b726498c607c1e897416e8a17f0e150076fc7de2b2b19b0919b49ad5b40645dd3883a
7
+ data.tar.gz: ba009b4669d67eb24ccd15cf669f84431a1738153b721c13c18633d6b05f32cdd4bfd01d25530b18ef5ab39cbf84e0b19688691f6751884c92e0a8d412935771
data/CHANGELOG.md CHANGED
@@ -5,8 +5,55 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
5
  versions follow [Semantic Versioning](https://semver.org/); before 1.0, config
6
6
  and commands may change between minor versions. How releases are made:
7
7
  [docs/releasing.md](docs/releasing.md).
8
+
8
9
  ## [Unreleased]
9
10
 
11
+ ## [0.152.0] - 2026-11-06
12
+
13
+ ### Added
14
+
15
+ - `chi bundle install` and `chi bundle check` name a manifest key this chi doesn't read (`unknown-key`); `chi bundle check` also warns (`requires-chi-floor`) when a manifest uses a key newer than its `requires_chi` admits. [docs/plugins.md](docs/plugins.md)
16
+ - `chi bundle status NAME` has a `Manifest:` line saying where the bundle's manifest is read from, and why.
17
+
18
+ ### Changed
19
+
20
+ - An installed bundle keeps its `manifest.yml` and chi reads it live, so a newer chi reads keys the installing chi didn't know, with no reinstall. Bundles installed earlier get theirs on `chi update` (shipped bundles, or a folder or archive that is still there; the system bundle at the next start); for one from git, `chi update` names the `chi bundle upgrade NAME` to run. [docs/internals/bundles.md](docs/internals/bundles.md)
21
+ - `chi bundle install` and `chi bundle upgrade` refuse a bundle whose `requires_chi` this chi doesn't meet, and write nothing; `--force` installs it anyway. [docs/plugins.md](docs/plugins.md)
22
+ - `chi bundle upgrade NAME` upgrades a bundle from a folder, archive or git URL from the source its record keeps: the name alone is enough.
23
+ - "Stable for bundles" now covers the manifest schema. [docs/plugins.md](docs/plugins.md)
24
+
25
+ ### Fixed
26
+
27
+ - `chi bundle diff NAME FILE` shows the bundle's new text for a file that stays behind, so the sections the "stays behind" line names can be read.
28
+ - `chi bundle try` no longer passes the user's dropped settings (guardrails, memories, hooks, log…) to its sandboxed chi through the environment.
29
+ - A bundle's `context_providers:` are skipped when this chi is older than the bundle's `requires_chi`.
30
+ - Reinstalling a bundle from the same source says what it did: `Up to date:` for unchanged files, and a last line naming what was rewritten and which of your memories were kept.
31
+ - A `files:` entry with no checksum no longer warns "Checksum mismatch" on every install.
32
+ - Objectives proposal cards no longer appear after a canceled or failed turn.
33
+ - A `delegate` child in a linked worktree is no longer told to re-delegate to the main checkout.
34
+
35
+ Update with `chi update` (agents-md 0.1.2, btw 0.1.4, loop-guard 0.3.10, mcp 0.7.6, skills 0.1.9, slow-commands 0.2.5: their `requires_chi` now names the chi that reads their settings).
36
+
37
+ ## [0.151.0] - 2026-11-05
38
+
39
+ ### Added
40
+
41
+ - A bundle can declare `block_renderers:` in its manifest: a fenced code block's language and the `scripts/` files that draw it in chi web. See docs/plugins.md (Block renderers).
42
+ - chi web draws such a block as a picture in answers and closed steps, once a bundle for its language is installed and `web.markdown` is on. A toggle shows the source; a block that can't be drawn stays source, with one muted line. The renderer runs in a sandboxed frame with no access to the page or its cookie, and a CSP that closes its network but for WebRTC.
43
+ - [chi-mermaid](https://github.com/dm1try/chi-mermaid), a separate bundle, draws ```` ```mermaid ```` blocks this way and tells the model it can write them: `chi bundle install https://github.com/dm1try/chi-mermaid`.
44
+
45
+ ### Changed
46
+
47
+ - After a reload, a card a command showed names it (`from /short`) in the web, since the command's line isn't there any more.
48
+
49
+ ### Fixed
50
+
51
+ - A card or notice keeps its place in the web after a reload, however many turns came after it: a finished task's or an attached context's wake no longer sends it above earlier prompts, and a reminder or a Continue no longer pushes it toward the bottom. A card now stays with the turn it came after, also through a `!rollback` or a restart. A session's existing cards keep the places they have. Going back to an older chi on a session afterwards clears that session's cards.
52
+ - A saved recap sits where it belongs in the web after a task or context wake, or a `!cmd`.
53
+ - A card shown after a failed turn stays below that turn's prompt after a reload, as it was live.
54
+
55
+ Update with `chi update`.
56
+
10
57
  ## [0.150.0] - 2026-11-05
11
58
 
12
59
  ### Changed
@@ -5670,7 +5717,9 @@ and long-lived sessions.
5670
5717
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
5671
5718
  hotkey panel that send selected text or the clipboard to your sessions.
5672
5719
 
5673
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.150.0...HEAD
5720
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.152.0...HEAD
5721
+ [0.152.0]: https://github.com/dm1try/samagotchi/compare/v0.151.0...v0.152.0
5722
+ [0.151.0]: https://github.com/dm1try/samagotchi/compare/v0.150.0...v0.151.0
5674
5723
  [0.150.0]: https://github.com/dm1try/samagotchi/compare/v0.149.0...v0.150.0
5675
5724
  [0.149.0]: https://github.com/dm1try/samagotchi/compare/v0.148.0...v0.149.0
5676
5725
  [0.148.0]: https://github.com/dm1try/samagotchi/compare/v0.147.0...v0.148.0
data/docs/architecture.md CHANGED
@@ -210,7 +210,7 @@ agent logic and can be used without any terminal rendering; the UI is a thin lay
210
210
  | UI | `Samagotchi::TerminalUI` | Interactive REPL (Reline), rendering (ANSI, spinner, status line), REPL commands. Delegates all core work to an `Engine`. |
211
211
  | Model loops and adapters | `KernelLoop`, `LLM::ChatLoop`, `Samagotchi::Client`, `LLM::OpenAIChat`, `LLM::AnthropicMessages`, `LLM::HTTP` | The model↔tool loops and the HTTP adapters they talk through (see "Model loops and adapters"). |
212
212
  | Bridge (SSE/HTTP) ([internals/bridge.md](internals/bridge.md)) | `Samagotchi::Bridge`, `SessionManager` | The **single live client transport**: an SSE read stream + HTTP POST turn/cancel/answer surface that attaches to a worker's existing `Engine` via `Engine#subscribe`. Every session worker starts it (bound `127.0.0.1`, localhost-only), and it answers only a request that names its secret (`X-Chi-Bridge`, from the worker's 0600 `bridge.json`). |
213
- | Web (Rack) | `Samagotchi::Web::App`, `SessionManager` | Single-port `127.0.0.1:4567` control plane via `rack`+`webrick` (serve `index.html` + `/api/*`). With `web.host: lan` (or one of this machine's IPv4 addresses) the server also listens on that address, where every request needs the access token, this machine's on loopback too (`App#token_gate`: the `chi_token` cookie, on loopback a value derived from the token (`Token.loopback_cookie`), or `Authorization: Bearer`; chi web prints a `Local:` link with a one-time code; [CLI](cli.md#chi-web-on-your-phone)). Experimental: behind an auth proxy (`web.public_url`, `Web::ProxyGate`) every request needs the token, this machine's too, and one that came through the proxy also the configured identity header for the owner ([configuration.md](configuration.md#behind-an-auth-proxy)). A request goes through, in order: the Host check (`local_host_header?`: loopback names, the LAN address, the public host), the cross-site check (`cross_site?`: Origin this server's or the public origin, no `Sec-Fetch-Site` cross-site/same-site on a POST or `/api`), the frame check (`framed?`: a frame/embed/object load from another origin, 403 `framed`; every HTML answer carries `X-Frame-Options: DENY` and `frame-ancestors 'none'`), the token gate (`token_gate` → `principal_for`, `proxied_principal` for a proxied request; the `?token=` / `POST /api/token` trade sets the cookie, `__Host-chi_token` through the proxy; with `web.public_url` a direct request takes Bearer only, and with it or LAN mode chi's token-less `/api/info` probe gets `probe_info`), then the route's permission (`Principal#permits?`). It talks to each worker's Bridge (`App#relay`, `bridge_get`; `/stream` proxies it) and reads the session files; no `Engine` of its own. Markdown (`web.markdown`) is rendered here only (`Web::MarkdownRenderer`): the answers it serves, card bodies, and `POST /api/render` for a text the page already shows. `App` keeps the gate, `ROUTES` and the helpers the handlers share; its handlers live in mixins it includes, each a `Web::` module with private methods: `SessionAccessRoutes` (`web/session_access_routes.rb`), `ParticipantAccess` (`web/participant_access.rb`), `ParticipantsAdmin` (`web/participants_admin.rb`), `ParticipantReview` (`web/participant_review.rb`), `MessageDisplay` (`web/message_display.rb`), `SessionShow` (`web/session_show.rb`), `SessionCreate` (`web/session_create.rb`), `SessionControl` (`web/session_control.rb`), `PushRoutes` (`web/push_routes.rb`) and `ProxyGate` (`web/proxy_gate.rb`). Exact paths in `GATE_FREE_PATHS` are served before the token gate (the fragment trade's script, and the Home Screen app's manifest, service worker and icons). `bin/chi web` entrypoint. |
213
+ | Web (Rack) | `Samagotchi::Web::App`, `SessionManager` | Single-port `127.0.0.1:4567` control plane via `rack`+`webrick` (serve `index.html` + `/api/*`). With `web.host: lan` (or one of this machine's IPv4 addresses) the server also listens on that address, where every request needs the access token, this machine's on loopback too (`App#token_gate`: the `chi_token` cookie, on loopback a value derived from the token (`Token.loopback_cookie`), or `Authorization: Bearer`; chi web prints a `Local:` link with a one-time code; [CLI](cli.md#chi-web-on-your-phone)). Experimental: behind an auth proxy (`web.public_url`, `Web::ProxyGate`) every request needs the token, this machine's too, and one that came through the proxy also the configured identity header for the owner ([configuration.md](configuration.md#behind-an-auth-proxy)). A request goes through, in order: the Host check (`local_host_header?`: loopback names, the LAN address, the public host), the cross-site check (`cross_site?`: Origin this server's or the public origin, no `Sec-Fetch-Site` cross-site/same-site on a POST or `/api`), the frame check (`framed?`: a frame/embed/object load from another origin, 403 `framed`; every HTML answer carries `X-Frame-Options: DENY` and `frame-ancestors 'none'; frame-src 'none'`, so a page's own frames can't navigate either), the token gate (`token_gate` → `principal_for`, `proxied_principal` for a proxied request; the `?token=` / `POST /api/token` trade sets the cookie, `__Host-chi_token` through the proxy; with `web.public_url` a direct request takes Bearer only, and with it or LAN mode chi's token-less `/api/info` probe gets `probe_info`), then the route's permission (`Principal#permits?`). It talks to each worker's Bridge (`App#relay`, `bridge_get`; `/stream` proxies it) and reads the session files; no `Engine` of its own. Markdown (`web.markdown`) is rendered here only (`Web::MarkdownRenderer`): the answers it serves, card bodies, and `POST /api/render` for a text the page already shows. `App` keeps the gate, `ROUTES` and the helpers the handlers share; its handlers live in mixins it includes, each a `Web::` module with private methods: `SessionAccessRoutes` (`web/session_access_routes.rb`), `ParticipantAccess` (`web/participant_access.rb`), `ParticipantsAdmin` (`web/participants_admin.rb`), `ParticipantReview` (`web/participant_review.rb`), `MessageDisplay` (`web/message_display.rb`), `SessionShow` (`web/session_show.rb`), `SessionCreate` (`web/session_create.rb`), `SessionControl` (`web/session_control.rb`), `PushRoutes` (`web/push_routes.rb`) and `ProxyGate` (`web/proxy_gate.rb`). Exact paths in `GATE_FREE_PATHS` are served before the token gate (the fragment trade's script, and the Home Screen app's manifest, service worker and icons). `bin/chi web` entrypoint. |
214
214
  | Sessions | `Samagotchi::Session`, `SessionManager` | File-based `~/.local/state/samagotchi/sessions/<uuid>.json` + sidecar `input/`/`output/`; retention (14d/500) + ordering (`updated_at desc`). |
215
215
 
216
216
  - `bin/chi` in REPL mode (see `LaunchMode` above) builds `TerminalUI`. `TerminalUI#run` is the single
@@ -632,7 +632,7 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
632
632
  | LLM context strategies | `llm_context_strategy.rb`, `llm_context_view.rb`, `llm_context_stale.rb`, `llm_context_forget.rb` | What a request sends of the history (none, stale, forget) ([internals/llm-context-forget.md](internals/llm-context-forget.md)). |
633
633
  | Session chains | `session_continue.rb` (`SessionContinue`), `session_chain.rb` (`SessionChain`) | A session that continues an earlier one. `SessionChain.opener_for` gives the text web `continue →` prefills in the new link: the previous link's own first typed message when that link continues one itself, else nil (the 201's `opener`; not saved) ([sessions.md](sessions.md)). |
634
634
  | Review sessions | `review_start.rb` (`ReviewStart`), `session_review.rb` (`SessionReview`, the session's `review` field), `review_command.rb` (`ReviewCommand`, `chi review`), `review_apply.rb` (`ReviewApply`) | A session made to be shared: idle, its subjects project files checked by `Guardrails::ReviewPaths`, a fork of a working session from its recap (`--from`); Apply quotes a participant's review turn into the owner's composer ([sessions.md](sessions.md#review-sessions), [internals/participants.md](internals/participants.md)). |
635
- | chi web's routes | `web/app.rb` (`Web::App`: the gate, `ROUTES`, the shared helpers), `web/message_display.rb` (`Web::MessageDisplay`: saved messages and cards as display items, `POST /api/render`), `web/session_show.rb` (`Web::SessionShow`: `GET /api/sessions/:id` with its `cards`, `tail` and `timing` re-reads, `GET …/output`, the timing payload), `web/session_create.rb` (`Web::SessionCreate`: `POST /api/sessions`, a continue, a review fork), `web/session_control.rb` (`Web::SessionControl`: answer, command, turn and images, cancel, stop, restart, archive, delete), `web/push_routes.rb` (`Web::PushRoutes`: `/api/push`, its subscriptions, prefs, test and presence) | The handlers `App`'s `ROUTES` name, in the file that holds their kind; each mixin is included in `App` and reads its helpers and ivars, so `app.send(:handle_show, …)` and the specs that stub `App` methods work as before. |
635
+ | chi web's routes | `web/app.rb` (`Web::App`: the gate, `ROUTES`, the shared helpers), `web/message_display.rb` (`Web::MessageDisplay`: saved messages and cards as display items, `POST /api/render`), `web/session_show.rb` (`Web::SessionShow`: `GET /api/sessions/:id` with its `cards`, `tail` and `timing` re-reads, `GET …/output`, the timing payload), `web/session_create.rb` (`Web::SessionCreate`: `POST /api/sessions`, a continue, a review fork), `web/session_control.rb` (`Web::SessionControl`: answer, command, turn and images, cancel, stop, restart, archive, delete), `web/push_routes.rb` (`Web::PushRoutes`: `/api/push`, its subscriptions, prefs, test and presence), `web/block_renderer_routes.rb` (`Web::BlockRendererRoutes`: `GET /api/block_renderers` and one renderer's file, [internals/block-renderers.md](internals/block-renderers.md)) | The handlers `App`'s `ROUTES` name, in the file that holds their kind; each mixin is included in `App` and reads its helpers and ivars, so `app.send(:handle_show, …)` and the specs that stub `App` methods work as before. |
636
636
  | Web Push | `web/push.rb` (`Web::Push`: `Runtime`, `preflight!`, the `web.push.*` settings), `web/push/crypto.rb` (`Push::Crypto`: RFC 8291 aes128gcm, RFC 8292 VAPID), `web/push/store.rb` (`Push::Store`, `Store::Sub`: `web-push/vapid.pem`, `subscriptions.json`), `web/push/client.rb` (`Push::Client`, `Client::Result`), `web/push/attention.rb` (`Push::Attention`: notify.js's rules in Ruby, shared cases in `spec/fixtures/attention_cases.json`), `web/push/presence.rb` (`Push::Presence`: the pages' "in front" reports), `web/push/notifier.rb` (`Push::Notifier`, a hub subscriber with a sender thread), `web/push_routes.rb` (`Web::PushRoutes`), `web/private_files.rb` (`Web::PrivateFiles`, shared with the grants), `public/sw.js`, `public/push.js`, `public/manifest.webmanifest` | chi web pushes what the tab notifies for (approvals, questions, step-limit questions, cards, failed turns; done turns to a device that asks) to the owner's Home Screen app over an https public URL: the Notifier turns the hub's new attentions into show pushes after a hold, unless a chi web page is in front, at most 30 an hour, with one retry and a restart catch-up (`sent.json`) (each carries the keys still open, and the badge; no close push: every push shows a notification); the bell (`notifications.js`) subscribes the device, and the service worker and the page close the answered ones. See [internals/web-push.md](internals/web-push.md). |
637
637
  | chi web's updates | `web/update_check.rb` (`Web::UpdateCheck`), `web/update_ops.rb` (`Web::UpdateOps`), `web/auto_update.rb` (`Web::AutoUpdate`) | The RubyGems checker and its `update_check.json`; the install, worker-restart and chi-web-restart operations (one at a time) the owner's routes and AutoUpdate share; `update.auto: install`'s thread (install when no turn runs, then idle workers and chi web). See [configuration](configuration.md#unattended-updates-updateauto). |
638
638
  | Behind an auth proxy | `web/public_url.rb` (`Web::PublicUrl`: `web.public_url`, `web.proxy.identity_header`, `web.proxy.owner`), `web/proxy_gate.rb` (`Web::ProxyGate`, mixed into App; `RawHeaderNames`, prepended to WEBrick's request by `Web::Server`), `web/link_codes.rb` (`Web::LinkCodes`: the links' one-time codes), `proxy_trace.rb` (`ProxyTrace`), `web/public/token_fragment.js`, `web/public/auth_lost.js`, `web/tunnel.rb` + `web/tunnel/tailscale.rb` (`Web::Tunnel`, `Web::Tunnel::Tailscale`: `chi web --tunnel tailscale`, Tailscale Serve set for one run and the PublicUrl it makes) | Experimental: chi web behind an auth proxy. Every request needs the token; a proxied one's owner also needs the identity header (one clean value under one name); the `__Host-` cookie, an absolute `Location` from the public URL, https links with a one-time code in the fragment; the page's lost-sign-in banner. Bridges refuse proxy traces ([configuration.md](configuration.md#behind-an-auth-proxy)). |
@@ -60,7 +60,7 @@ plugin:
60
60
  `bundles: my-bundle:` as a Hash with string keys, once, when the plugin loads. A value a session may set
61
61
  (`chi --set my-bundle.KEY=V`, listed in the manifest's `session_settings:`, typed with `{KEY: number}` so a
62
62
  value of another type is refused) is read from `ctx.settings` at call time. List what config.yml may set in `settings:`
63
- (`[greeting]`, or `{greeting: string}`): a typo there then warns. `chi bundle check --set KEY=VALUE` gives the sandboxed load a setting.
63
+ (`[greeting]`, or `{greeting: string}`): a typo there then warns. `chi bundle check --set KEY=VALUE` gives the sandboxed load a setting. A bundle can also draw a fenced block in chi web's answers, with no plugin code: `block_renderers:` and a script calling `chi.defineRenderer` ([plugins.md](plugins.md#block-renderers)).
64
64
 
65
65
  ## What a hook sees
66
66
 
@@ -129,10 +129,10 @@ class, a hand-built `ctx`), that is a gap in the kit: say so to the user rather
129
129
  - **Shas.** Every file the manifest names has a sha256 line, and an edit makes it stale: the install then warns
130
130
  of a checksum mismatch, and a line with no sha installs unchecked. `chi bundle check --fix` rewrites them; run it
131
131
  after the last edit.
132
- - **requires_chi.** Use the chi the user runs (`chi --version`). `check` warns when the plugin calls something
133
- newer than the floor (`ctx.http` needs 0.80.0, `ctx.cost` 0.83.0, `ctx.usage` 0.100.0, `ctx.sessions.children` 0.34.0). A keyword added
134
- later counts too (`ctx.ask_model(model:)` 0.80.0, `ctx.sessions.send(delivery:)` 0.64.0, `chi.toolkit(prompt:)`
135
- 0.72.0, `chi.toolkit(groups:)` 0.121.0), when the call is written on `ctx` / `chi` itself (not through a variable).
132
+ - **requires_chi.** Use the chi the user runs (`chi --version`): a bundle whose `requires_chi` that chi doesn't
133
+ meet is now **refused at install** (`--force` is the only way past it), not just unloaded. `check` warns when the plugin calls something newer than the floor (`ctx.http` 0.80.0, `ctx.cost` 0.83.0, `ctx.usage` 0.100.0, `ctx.sessions.children` 0.34.0),
134
+ or a keyword added later (`ctx.ask_model(model:)` 0.80.0, `ctx.sessions.send(delivery:)` 0.64.0, `chi.toolkit(prompt:)` 0.72.0, `chi.toolkit(groups:)` 0.121.0), written on `ctx` / `chi` itself rather than through a variable,
135
+ or when the manifest itself uses a key newer than the floor (`block_renderers:` 0.151.0, `settings:` 0.143.0, `session_settings:` 0.84.0, `context_providers:` 0.32.0): an older chi ignores that key.
136
136
  - **A typo in an API call.** `check` warns (`api`) on a call or keyword this chi lacks, on any path; for a newer chi on purpose, guard it with `ctx.respond_to?(:name)`.
137
137
  - **trust_level.** Leave it out. `reviewed` / `trusted` are for someone who reviewed the bundle, not its author.
138
138
  - **Restart.** A session loads bundles when it starts: after an install or a settings change, the user starts a
data/docs/cli.md CHANGED
@@ -36,7 +36,7 @@
36
36
  - `chi self` — print version, source dir (checkout or installed gem), config/memory/session paths, model/host and bundles. Run by a session's `execute`, the `model` row (and host, loop, profile, thinking, served model) is **that session's** model, labelled `(this session <id8>; default …)`; elsewhere it is the default, `(default)`. `model key` is the memory overlay key (`<name>.<key>.md`), and `big memories` the memories over `memory.entry_warn_tokens` ([Memory](memory.md#a-memorys-size)), and `model notes` the model notes a prompt of that model loads (`model_notes_deepseek (system, 612 chars)`, `none`; inside a session its `--mute` list is left out). `chi self --model` prints only the model a new session starts on, an alias resolved to its target (the desktop helper's hint)
37
37
  - `chi update [--dry-run] [--to VERSION] [--json] [--no-gem] [--no-bundles] [--no-desktop]` — update an installed chi: the gem, the system bundle, the shipped bundles you installed and the desktop helper, and check the bundles from elsewhere, in one table (see [Updating](#updating))
38
38
  - `chi home`, `chi home set [--ruby PATH]`, `chi home doctor` — the chi home: the one Ruby (and its gem path) every chi launch boots, whoever launches it. `chi home` shows it (its Ruby, the newest chi in it, gem home and path, `mise`, when and by which chi it was set, and the chi you ran when that is another one; exit 1 with the problem when it can't boot chi), `set` records this chi's Ruby and gem path (or `--ruby PATH`'s own; it must have samagotchi), `doctor` checks it and what should follow it, one row each with `ok`, `warn` or `fail` and what to run (exit 1 on a `fail`; see [Checking it](configuration.md#checking-it-chi-home-doctor)). At a terminal, a chi on another Ruby than the home's says so in one dim line a day. This machine only (see [The chi home](configuration.md#the-chi-home))
39
- - `chi bundle install|upgrade|uninstall|status|diff|list|build|trash|new|test|check` — manage memory bundles (see [Bundle hooks](hooks.md#bundle-hooks-unified-workflow-bundle)); `list` shows the installed ones and the ones shipped with chi, which `install <name>` installs; `core` and `dev` are profiles that install a set of them (see [Bundle profiles](memory.md#bundle-profiles-core-and-dev), [Guardrails](guardrails.md), [Bundles](bundles.md#the-btw-bundle), [the mcp bundle](bundles.md#the-mcp-bundle) [the loop-guard bundle](bundles.md#the-loop-guard-bundle), [the check-in bundle](bundles.md#the-check-in-bundle) and [the skills bundle](bundles.md#the-skills-bundle)). `trash` lists and empties the bundle trash (moved files from uninstalls/upgrades). A usage error (an unknown subcommand or flag, a missing argument, a bad `build --scope`) exits 2, as for the other subcommands. Plain `chi` and `chi web` exit 1 on an unknown flag, a flag missing its value or a stray argument. `new`, `test` and `check` are for writing one: see [Writing a bundle](#writing-a-bundle-new-test-check)
39
+ - `chi bundle install|upgrade|uninstall|status|diff|list|build|trash|new|test|check` — manage memory bundles (see [Bundle hooks](hooks.md#bundle-hooks-unified-workflow-bundle)); `list` shows the installed ones and the ones shipped with chi, which `install <name>` installs; `upgrade <name>` upgrades an installed bundle by name from the source it came from (its folder, archive or git URL), `upgrade <source>` from a new one; `core` and `dev` are profiles that install a set of them (see [Bundle profiles](memory.md#bundle-profiles-core-and-dev), [Guardrails](guardrails.md), [Bundles](bundles.md#the-btw-bundle), [the mcp bundle](bundles.md#the-mcp-bundle) [the loop-guard bundle](bundles.md#the-loop-guard-bundle), [the check-in bundle](bundles.md#the-check-in-bundle) and [the skills bundle](bundles.md#the-skills-bundle)). `trash` lists and empties the bundle trash (moved files from uninstalls/upgrades). `install`/`upgrade` `--force` overwrites existing entries and also installs a bundle whose `requires_chi` this chi doesn't meet; without it such a bundle is refused (exit 1), nothing written. A plain `install` of a bundle that is already installed from the same source rewrites its hooks, rules, plugin, scripts and record: the summary lists a file that already holds the bundle's bytes as `Up to date: …`, one you wrote as `Skipped: …`, and ends with `NAME was already installed from SOURCE: its hooks, rules, plugin and scripts now match it and its record was rewritten` (plus, when a memory was kept, `your own copies of its memories were kept (chi bundle upgrade NAME merges the bundle's changes into them)`). A usage error (an unknown subcommand or flag, a missing argument, a bad `build --scope`) exits 2, as for the other subcommands. Plain `chi` and `chi web` exit 1 on an unknown flag, a flag missing its value or a stray argument. `new`, `test` and `check` are for writing one: see [Writing a bundle](#writing-a-bundle-new-test-check)
40
40
 
41
41
  - `chi guardrails replay [--dry-run] [--threshold T] [--model M]` — seed System 1's record from the approvals this machine's sessions already answered: each ask is judged by the configured model and written to `guardrails/judgments.jsonl` with the answer the user gave, then a per-rule table prints (asks, allowed, denied, would-be auto-allows, false allows, asks with no judgment). A parent agent's answer is not the user's: only its deny is replayed, and only where the parent could have allowed. `--dry-run` judges without writing, `--threshold` overrides `guardrails.system_one.threshold` for the table, `--model` the configured model for this run. Exit 2 when `guardrails.system_one.mode` is off or no `host` is set; a usage error exits 2 (see [System 1](guardrails.md#system-1-experimental))
42
42
  - `chi guardrails rule <id>` — print exactly `asks` when that rule is loaded, not disabled and votes `ask` in this `guardrails.mode` for every model; any other line says why not (no such rule, disabled, another mode, it denies, for some models only, rules that failed to load, `guardrails.enabled: false`). Exit 0 either way, 2 for a usage error. It reads config.yml and the installed bundles as a worker does (`GuardrailWiring#rules`) and takes the rule's id, never a command; the [coordinator skill](bundles.md#the-coordinator-bundle) reads it before it merges without asking
@@ -133,8 +133,10 @@ The loop for a bundle you're writing (the whole path: [bundle-authoring.md](bund
133
133
  chi's Ruby (minitest is one of chi's gems; rspec is optional). The tests run the bundle's code as written, with
134
134
  your environment (the kit sandboxes each test's HOME and XDG dirs, not the process).
135
135
  - `chi bundle install --dev PATH` installs the bundle in the local directory `PATH` (not a git URL, archive or name:
136
- exit 2) as a link to it: its plugin, hooks, rules and scripts are `PATH`'s own files, so an edit applies at the
137
- next worker or `chi web` restart with no re-install, and their sha256 isn't checked, for this bundle only (the
136
+ exit 2) as a link to it: its plugin, hooks, rules, scripts and `manifest.yml` are `PATH`'s own files, so an edit applies at the
137
+ next worker or `chi web` restart with no re-install (a manifest edit to `block_renderers:` shows in chi web's next
138
+ request; a new `scripts:`, `hooks:` or `plugin:` entry needs `chi bundle install --dev PATH` again, since the record
139
+ has no entry for it), and their sha256 isn't checked, for this bundle only (the
138
140
  record says `dev`; every other bundle is checked as before). Its memories are copied at install. `chi bundle
139
141
  status` shows `NAME vX dev → /abs/path`. `chi bundle upgrade NAME` refuses it (it follows its source already; to
140
142
  leave dev mode: `chi bundle uninstall NAME`, then `chi bundle install` without `--dev`), as does a plain install
@@ -147,9 +149,13 @@ The loop for a bundle you're writing (the whole path: [bundle-authoring.md](bund
147
149
  so run it (and `test`) on code you'd install. The codes, in order: `manifest` (a parse error, verbatim; the run
148
150
  stops), `missing-file`, `unlisted` (warn: a file an install leaves out, by `MemoryFiles`' rules, so a README
149
151
  isn't one), `sha` (a stale or placeholder sha, or a plugin / hook / script entry with no sha line; `--fix`
150
- rewrites the first two and prints `fixed sha FILE → sha256:…`), `requires-chi` (this chi doesn't satisfy it:
152
+ rewrites the first two and prints `fixed sha FILE → sha256:…`), `unknown-key` (warn: a manifest key this chi's
153
+ parser never reads, so it ignores it, one row per key at its line; the install keeps the manifest, so a newer
154
+ chi reads the key from it with no reinstall, and `chi update`'s re-check names it too), `requires-chi` (this chi doesn't satisfy it:
151
155
  error; missing on a bundle with code: warn), `requires-chi-floor` (warn: the plugin calls an API newer than the
152
- oldest chi `requires_chi` admits, from `Plugin::Api::SINCE`), `trust` (warn: `trust_level: reviewed|trusted` on a
156
+ oldest chi `requires_chi` admits, from `Plugin::Api::SINCE`, or the manifest itself uses a key newer than it, from
157
+ `Manifest::SINCE`; an installed bundle gets the unknown-key row but never the floor, as it's the author's fix),
158
+ `trust` (warn: `trust_level: reviewed|trusted` on a
153
159
  bundle chi doesn't ship), `clash` (warn: a name chi ships, or one installed from elsewhere; the sandboxed install
154
160
  then forces), `install` (each Installer warning, a `needs` one as warn, and each plugin or hook load failure at
155
161
  the source `file:line`; skipped while a placeholder sha is unfilled), `registered` (info: each command, tool,
@@ -313,7 +319,7 @@ Also shipped, not installed: dev (mcp, skills, source-links) (chi bundle install
313
319
  file in the bundle trash and `merged 0.8.0 into your FILE` in the row. A real
314
320
  conflict, no `git`, or no recorded base keeps your file untouched, and the row
315
321
  says `FILE stays behind 0.8.0:` with why and what it missed (up to three headings
316
- and a count, or `2 places`). `chi bundle diff NAME FILE` shows it; `chi bundle
322
+ and a count, or `2 places`). `chi bundle diff NAME FILE` shows it (the base, your file, then the bundle's new text); `chi bundle
317
323
  upgrade NAME --force` takes the bundle's, with a copy of yours in the bundle
318
324
  trash first. A later run retries such a file at the same version: its row reads
319
325
  `0.8.0 merged 0.8.0 into your FILE` when it merges, and names it as behind when
@@ -367,6 +373,26 @@ Also shipped, not installed: dev (mcp, skills, source-links) (chi bundle install
367
373
  update` again). A failed gem install checks against this chi, since it stays.
368
374
  - `--no-bundles` (`update.bundles: false`) leaves these out too: no check, no row
369
375
  but `bundles skipped`.
376
+ - **Bundles installed by an older chi** have no kept `manifest.yml` (it didn't
377
+ keep it), so chi reads what their install recorded, and a manifest
378
+ key that chi didn't know (`block_renderers:` before 0.151.0) is missing. Where
379
+ chi has the bundle's own manifest at the installed version, the update keeps it,
380
+ once: a shipped bundle from chi's shipped copy (`up to date (kept its manifest)`;
381
+ the system bundle at the next chi start), a bundle from a folder or a zip/tar
382
+ that is still there from it (a `kept its manifest` row; the archive is extracted
383
+ and the extract removed). Only when the source is what's installed: the same
384
+ name and version, the same manifest values as the install recorded (a key the
385
+ older chi didn't know aside), and the same memories, plugin, hooks, scripts and rules. A git source
386
+ isn't cloned: the row names the command,
387
+ `installed by an older chi, which didn't keep its manifest: chi bundle upgrade NAME reads it again from URL`.
388
+ A source that's gone or doesn't match gets its own row (why, and what to run: for
389
+ a gone one, `NAME's recorded source S is gone: chi bundle upgrade <path or URL of a copy>`). The
390
+ same goes for a record an older chi rewrote after a newer one kept the manifest
391
+ (`an older chi rewrote its record after the install, so its manifest.yml may be an
392
+ earlier version's`). `--dry-run` says `would keep its manifest` and writes nothing.
393
+ An edited `manifest.yml` isn't kept over: its row says so (`chi bundle upgrade
394
+ NAME`). These rows are warnings and fail nothing; `chi bundle status NAME` says the
395
+ same on its `Manifest:` line.
370
396
  - **Profiles** (`core`, `dev`): an installed one installs the bundles a new
371
397
  chi added to it (`updated (+ name)`), even at the same version, and retries
372
398
  one that failed before; one you uninstalled stays out. See [Bundle
@@ -907,7 +933,6 @@ the model's `objectives` tool); [Objectives](sessions.md#objectives) has what th
907
933
  - `/objectives done O1 [O2…]`, `/objectives drop O1 [O2…]`: mark items. An id it doesn't have refuses the whole line, and
908
934
  so does `done` on a dropped item. The model is shown a done item as `O1 [done] …` and no dropped one.
909
935
  - `/objectives undo O1 [O2…]`: back to active, after a mistaken `done` or `drop`.
910
-
911
936
  - `/objectives check O1`: an independent read of O1's latest move, one side request; the verdict comes as a card and as
912
937
  `check: …` under the move. Information only: it edits nothing, and it is refused for an objective with no move or a dropped one, and during a participant's turn. See [Objectives](sessions.md#objectives).
913
938
 
@@ -1892,7 +1892,7 @@ What changes once `web.public_url` is set:
1892
1892
  and a crafted link could swap your session): a link's one-time code and the 401 page's paste box do it, the paste
1893
1893
  box's redirect going to the public URL (never to a client-sent `X-Forwarded-Host`). The page accepts `POST`s from the public `https://` origin only (a sibling port on the same
1894
1894
  domain, or `http://`, is refused). No page of chi web's may be framed by another (`X-Frame-Options: DENY`,
1895
- `frame-ancestors 'none'`, in every mode).
1895
+ `frame-ancestors 'none'`, in every mode), and frames inside its pages can't navigate (`frame-src 'none'`).
1896
1896
  - Links carry a **one-time code** in the fragment, never a token: `chi web` prints `Proxied: https://…/#code=chc_…`
1897
1897
  (the owner's: once, within 10 minutes; `chi web` again prints a new one), and a participant's link (`chi share`,
1898
1898
  the Invite dialog) is `https://<public url>/#code=chc_…&s=ID` (once, until the grant expires). A fragment isn't sent
data/docs/guardrails.md CHANGED
@@ -1157,6 +1157,10 @@ the whole call:
1157
1157
 
1158
1158
  `decision` is what the judgment led to: `audit` (audit mode), `asked` (on
1159
1159
  mode, the user was asked), `auto` (on mode, System 1 answered for the user).
1160
+ When two rules ask about one call the line's `rule` is the one the card names
1161
+ and `other_rules` lists the rest (`Verdict#other_ask_rules`), so a second
1162
+ rule's record (`SystemOne::Record`) counts the ask too; a line with one rule
1163
+ has no `other_rules` at all.
1160
1164
  A judgment that never arrived is logged too, with `p` null, so the failures
1161
1165
  count as well. The label line, keyed by the judgment's id, is what the user
1162
1166
  did: `allow_once`, `allow_wide` (any scope wider than once), `deny`, or
@@ -0,0 +1,98 @@
1
+ # Block renderers: bundles that draw fenced blocks in chi web
2
+
3
+ A bundle's JS turns a fenced block in an answer (```` ```mermaid ````) into a picture in the owner's page. No tool, no
4
+ toolkit. The bundle's side is [plugins.md](../plugins.md#block-renderers); this page is the mechanism and the security
5
+ model. The TUI is untouched: it shows a fence as source.
6
+
7
+ ## The path
8
+
9
+ 1. `MarkdownRenderer` keeps the language: syntect drops it from the HTML, so a second parse walks the `code_block` nodes
10
+ in document order and zips them with the fragment's `<pre>`s, setting `data-lang` after sanitize's walk. Only the first
11
+ word, downcased, matching `BlockRenderers::LANG`. A count that differs sets nothing. **`web.markdown` off means no
12
+ `<pre data-lang>`, so nothing draws.**
13
+ 2. `BlockRenderers.for_installed` (manifest `block_renderers:`, read through `InstalledBundle#manifest`: the bundle's
14
+ kept `manifest.yml`, else its record's mirror, so chi web needs no plugins, and a bundle installed by a chi that
15
+ didn't know the key draws once a kept manifest is there) lists
16
+ who draws what: `GET /api/block_renderers`, `:owner`. `GET /api/block_renderers/:bundle/:file` serves a file, `:owner`:
17
+ the URL's names go through `BlockRenderers.script_path` (membership in a listed renderer's `load` before any path is
18
+ made), then `Provenance.sha_matches?`; a changed file is 409 `renderer_changed`. ETag + `no-cache`, as static files.
19
+ 3. `block_host.js` (`createBlockHost`, wired in `app.js`; `participant.js` isn't): one `MutationObserver` per turn root,
20
+ filtered to `TEXTS` (`text_roots.js`), so cards, the asking slot, question replies and delegate reports never draw, and
21
+ every path that adds a block to a root is seen the same way. One `IntersectionObserver` per scroller, with an explicit `root`
22
+ (with `root: null`, `rootMargin` doesn't pass an `overflow: auto` in between). Closed `<details>` fire on open. **A
23
+ move isn't over when it is seen:** a block waiting to come near is watched by its scroller's observer, so one that moves
24
+ to another scroller (the stage's `.ts-scroll` hands a long answer to `#history`) is watched there, or it never draws.
25
+ 4. `block_frame.js` (`createBlockFrame`): one hidden frame per language, made on first need, one render at a time, 15 s per
26
+ call. A timeout, a dead frame or a session switch drops it. Handshake `loaded` → `code {files}` → `ready | failed`,
27
+ then `render {id, source, theme}` → `done {id, svg} | error {id, message}`.
28
+ 5. On success a `<figure class="block-drawn">` goes right before the `<pre>`, which stays in the DOM, `hidden`, so whatever
29
+ reads text (copy, a claim's walk, fold thresholds) sees what it did. **The DOM is the state** (`block_source.js`): a Revise
30
+ clone arrives with both. On failure the `<pre>` stays, marked `data-block-failed`, with one muted line: an attribute CSS
31
+ shows, never markup, and not in the bubble's `textContent`, which the page's dedupe and an annotation read (`copy.js`'s rule). CSS content can't be selected, so a click on the line copies its words.
32
+ The toggle has an icon and an `aria-label`, no text (`copy.js`'s rule). A jump into the source shows it (`revealInHistory`).
33
+ 6. Cache: a `Map` on `lang + theme + source`, about 200 entries, holding the `data:` URL; a later `bubble.innerHTML` wipes the
34
+ figure and the observer redraws in the same pass. No `crypto.subtle`: it's undefined on a plain-http LAN address. A system
35
+ theme change redraws through the figure (a hidden `<pre>` never intersects).
36
+ 7. The view: a viewer at the bottom stays there (`scroll.js keepFollowing`); a block drawn above a reader's view keeps
37
+ what they read in place (checked in Chromium and WebKit).
38
+ 8. **A maximum picture, in the page, for any renderer** (`block_host.js oversize`): 50,000 px a side and 100M px. Mermaid's
39
+ secure keys don't cover diagram sizes, so a model's `%%{init:{"sequence":{"height":50000000}}}%%` otherwise gives an image
40
+ 33M px tall, which breaks the session's scrolling. Eval 0012's real diagrams reach about 2,600 px a side and 3.5M px;
41
+ a chain of 240 edges or a sequence of 1,000 messages still fits. **The decoded and displayed size is what counts.** The root's box comes
42
+ from `DOMParser`, never a regex over model-influenced XML (exponents, a comment holding `<svg`, a `>` in an attribute, entities and
43
+ prefixes all fooled one), and a vast one isn't decoded. Then the decoded size is checked: over the maximum, or a width or
44
+ height of 0 (WebKit's 0 x 150 for a viewBox it can't hold), the block stays source with its line. `.block-img`'s CSS
45
+ `max-height` and natural `max-width` say the same number, a backstop whatever the parsing gets wrong. The decode is bounded too
46
+ (15 s, the mark on until it ends). A very wide picture stays at least 120 px tall and scrolls sideways instead of a sliver.
47
+
48
+ ## Security model
49
+
50
+ The bundle's JS is trusted (installing a bundle already means trusting it, [plugins.md](../plugins.md#trust)). **Its input
51
+ is model-written**, so a renderer bug or an injected diagram must not reach a page that controls sessions.
52
+
53
+ | Layer | What it stops |
54
+ |---|---|
55
+ | `<iframe sandbox="allow-scripts">`, never `allow-same-origin` | reading the parent; reading gated responses |
56
+ | CSP meta in the srcdoc (`block_frame.js FRAME_CSP`) | fetch, POST, beacon, image, CSS url, prefetch/preload. Without it WebKit sent the session cookie on the frame's GET |
57
+ | Page header `frame-src 'none'` (`App::FRAME_HEADERS`; srcdoc frames still load and run under it) | self-navigation, meta refresh, anchor click, which leak past the frame's CSP in both browsers |
58
+ | `delete window.RTCPeerConnection` + `webkitRTCPeerConnection` in the bootstrap, before any bundle code | the plain WebRTC call only |
59
+ | Output only as `<img src="data:…">` | scripts and remote images inside the SVG don't run or load |
60
+
61
+ **Hard rules.**
62
+
63
+ 1. Never `allow-same-origin`. Never `innerHTML` the frame's output. SVG goes only into a `data:` URL for an `<img>`.
64
+ 2. **Never `blob:` for the SVG.** A blob inherits chi web's origin, and the browser's own "Open Image in New Tab" and drag-to-tab
65
+ navigate to it, where its script reads a gated route with chi's origin and cookie. Opened as a document, a `data:` SVG
66
+ has origin `null` and can't read chi's API; WebKit may still attach a same-host cookie to a request it makes (seen with a
67
+ test cookie to another port), so what holds is that the answer stays unreadable, not that no cookie is ever sent.
68
+ 3. The page accepts messages only where `e.source === frame.contentWindow`, of known types, and treats them as data
69
+ (`frameMessage`). No other window `message` listener exists on the page.
70
+ 4. Code enters the frame by postMessage from the page's own authenticated fetch (the frame's CSP closes its network but for
71
+ WebRTC, the residual risk below, and it has no cookie),
72
+ then as `script.text = code`. `eval` doesn't work: a library's `"use strict"` keeps its global local. A syntax error doesn't
73
+ throw: it reaches the frame's error event, as a failed load. `fetchRendererFile` requires `res.ok` and a JS content type, so a
74
+ proxy's sign-in HTML is never passed in as code.
75
+
76
+ **Residual risk, accepted for v1.** Code running in the frame can send what the frame holds over WebRTC: the diagram sources it
77
+ has rendered. A nested `srcdoc` frame gets a fresh `RTCPeerConnection` and still sends packets, in both browsers, and no CSP
78
+ stops WebRTC. It can't reach the page, cookies or sessions, and it needs a renderer compromised through its input. To limit
79
+ what it holds, the frame is dropped on a session switch.
80
+
81
+ **The hang.** The frame shares the page's thread, so a timer can't fire during a hang, and the model's input drives the
82
+ renderer's runtime. So a source over 64 KB stays source, and before each render a `sessionStorage` mark (`chi_block_rendering:`,
83
+ per block) is written and cleared after; a block whose mark is still there on load stays source with its muted line. A timeout
84
+ keeps the mark too. It is per tab: a new tab tries again. A reload during a render isn't a hang: `pagehide` clears the marks of renders
85
+ under 15 s old. A page truly hung never runs it. A render that ends after holding on over 10 s (`SLOW_RENDER_MS`) draws now and keeps its
86
+ mark, so it stalls the page once and not on every load; several such blocks still stall once each, and one under the bound stalls every
87
+ load. Real Mermaid's worst was about 1 s, so that takes a bad renderer. A mark of a render still running isn't a leftover: a bubble
88
+ rewritten or cloned meanwhile has its blocks wait for that render (`coming`), and each draws into whichever is connected.
89
+ No real hang has been produced; this is a guard.
90
+
91
+ A navigation a hostile renderer tries is blocked, but in Chromium it leaves the frame on an error page, so that render times out at
92
+ 15 s and the frame is dropped. Not a concern for a working renderer, and the page stays usable meanwhile.
93
+
94
+ ## Not done
95
+
96
+ Firefox isn't tested: if its CSP blocks srcdoc under `frame-src 'none'`, the frame never answers and blocks stay source.
97
+ Interactive output (a visible frame running the model's own code) isn't built: nothing secret could be in such a frame, and a busy
98
+ loop would stall the page.
@@ -15,7 +15,8 @@ rescues its own errors: an observer must never break the running turn.
15
15
  |-------|------------------|-------|
16
16
  | `RingBuffer` | `ring_buffer.rb` | The last `ring_capacity` (256) raw events by `event_seq`, in memory, for a reconnect's replay. The capture observer feeds it. |
17
17
  | `TurnAccumulator` | `turn_accumulator.rb` | The turn in progress, folded from events. |
18
- | `CardStore` | `card_store.rb` | The last cards and notices, saved in `cards.json`. |
18
+ | `CardStore` | `card_store.rb` | The last cards and notices, saved in `cards.json`, each anchored to its turn. |
19
+ | `CardPlaces` | `card_places.rb` | Pure: where `CardStore`'s entries sit in the messages the page draws (`turns_since`). |
19
20
  | `PendingCard` | `pending_card.rb` | `pending_card.json` `{id:, bundle:}`: the running turn's open card with actions (check-in's Nudge), written and removed with that card, so chi web's hub, which watches files and not workers, can notify. |
20
21
 
21
22
  Per connection there is an `SSEWriter` (with a `BoundedQueue`); `ClientEvent` and `EventId` are pure helpers. The
@@ -49,25 +50,60 @@ The cards a plugin or the Engine shows (`Engine#show_card`), hook notices, load
49
50
  the "asking again" and steer-cut rows, ✂ rows and questions (with how they were answered), for a UI that joins later:
50
51
  `snapshot[:cards]` and `GET /tail`'s `cards`.
51
52
 
52
- - **Placement is relative.** An entry carries `turns` and lists with `turns_since` (turns completed after it) and
53
- `current` (a card of the running turn). A card that comes mid-turn but isn't the turn's (an anytime command's, such as
54
- `/btw`) goes after that turn (`during`). A card with an earlier card's id replaces it in place.
55
- - **A turn that leaves nothing.** `!rollback` (`TurnFlow#rollback!`, `:turn_rolled_back`) takes a kept turn's count
56
- back. Of a rolled-back turn, and a failed one with nothing kept, the cards and hook notices become between-turns entries
57
- at its place (the cards lose their actions); its other rows go, having no step to sit in.
53
+ - **Anchored to its turn.** The store keeps a **turn log**, every id it saw at `:turn_started`, oldest first. An entry
54
+ holds `turn_id`: the running turn's, else the log's last (nil before any); a load warning, which the Engine announces
55
+ inside the first turn, takes the one before it. `list(messages:)` computes `turns_since` from the messages the page draws
56
+ (`CardPlaces`, the snapshot's own list; a stopped session's file for `.saved`): the turn openings (`Steer.turn_opening?`, as
57
+ the page's `TURN_PROMPT` and the recap count) after the last one whose id is in the log at or before the entry's, else from
58
+ the first one whose id is later in the log, else 0. The log only orders; the messages count. So a turn with no opening (a
59
+ reminder, a Continue, a failed or rolled-back one, a prompt stopped before the model) moves nothing, a wake's note does, and
60
+ an opening with no id (`/continue no`'s reason, Qwen's recovery line) counts as on the page. Nothing orders such an opening
61
+ against an entry, so the store reads the session's messages (its `messages:` lambda) when an entry is shown between turns, and
62
+ at the turn's end for what a running turn showed that isn't its own: `past_idless` is how many of them, right after the opening
63
+ it counts from, the entry already came after, and they don't count for it. A `!rollback` usually only shortens that run, and the count
64
+ is capped at the run still there, so a stale number doesn't eat a later turn. It can also move an entry's counting point back to a
65
+ run the entry never recorded (T1, `/continue no, too slow`, a card, a held T2 stopped, a second card, `!rollback`: the second card,
66
+ anchored to T2, now counts from T1 and reads above the reason and the first card). That rare inversion is accepted. A turn's own rows don't read it: they count such a line, which sits below them. `current` is a card of the running turn. A card that
67
+ comes mid-turn but isn't the turn's (an anytime command's, such as `/btw`) goes after that turn (`during`), and so does a
68
+ between-turns notice. A card with an earlier card's id replaces it in place and keeps its anchor. A `list` with no messages
69
+ (`?cards=1`'s bodies, `chi bundle try`'s trace) has no `turns_since`.
70
+ - **A turn that leaves nothing** is read, not told: an in-turn entry not `current` whose turn has no opening in the messages
71
+ (a `!rollback`, a failed turn with nothing kept, a prompt stopped before the model) lists as a between-turns entry at its
72
+ place, a card without its actions and a hook's notice without its step; its other rows, having no step to sit in, aren't
73
+ listed. Nothing in the entry changes. A kept Continue or reminder turn never has an opening, so its entries are `continued`
74
+ and stay rows of the turn before; a failed one loses the mark at its end. The page draws a failed prompt at the history's
75
+ end, so an entry pushed idle after it lists `after_failed` (the store's saved `failed_turn`) and goes below it, until the
76
+ next turn starts.
77
+ - **A command's card.** `SessionCommands#run` wraps its dispatch in `Engine#running_command`, so a card shown on that thread
78
+ carries `command:` (the event's field, `show_card` adds it). The store keeps the first show's (a replacement keeps it, even
79
+ none) and lists `label: "from /short"`, never `command`. The page draws it after a reload, where the command's line is gone.
58
80
  - **Caps.** `CAPACITY` (20) each for notices, ✂ rows (`LLM_CONTEXT_TYPES`) and cards, so one burst cannot push out
59
81
  another kind. An open question (an approval too) is never pushed out.
60
82
  - **Saved.** With a `path` (`cards.json` in the session's folder) the store rewrites the file after each event in
61
- `SAVED_ON`, and a later worker's store starts from it, so a turn's cards outlive the worker; `CardStore.saved(dir)` is
62
- what chi web reads for a session no worker runs. Load warnings are the worker's own (each start announces them)
63
- and an unanswered question waits for no one: neither is saved or seeded. An entry from an earlier worker lists with
64
- `earlier: true`; one shown during a turn comes back **without its actions** (their turn no longer runs), while a
65
- between-turns card keeps its buttons.
66
- - **A killed turn counts.** A worker killed mid-turn never saw the turn end, but `TurnCheckpoint` keeps its prompt (the
67
- next worker settles it as canceled). The Bridge's store (`killed_turn_stays:`) saves `"running": true` at
68
- `:turn_started`, and a store started from that adds the one turn. An ordered end clears it, so a graceful stop's
69
- cancel counts once; a file without the field counts none. A REPL's store never saves it: it keeps no running turn in
70
- the session, so a kill takes the prompt too.
83
+ `SAVED_ON`, and a later worker's store starts from it, so a turn's cards outlive the worker; `CardStore.saved(dir, messages:)`
84
+ is what chi web reads for a session no worker runs. The file is `{version: 2, turn_log:, entries:}`. The log is pruned at
85
+ a turn's start and end and at an idle show (a running turn's rows reuse the start's, as finding it walks the messages) to start at the opening the earliest anchor counts from (all of it while an entry has none, or for a store with no
86
+ `messages:`), so a restart reads an entry as the live store did; an old card therefore keeps it whole. `seed` drops an entry
87
+ whose `turn_id`, `base_since` or `past_idless` isn't an id or a non-negative integer. Load warnings
88
+ are the worker's own (each start announces them) and an unanswered question waits for no one: neither is saved or seeded. An
89
+ entry from an earlier worker lists with `earlier: true`; one shown during a turn comes back **without its actions** (their
90
+ turn no longer runs), while a between-turns card keeps its buttons.
91
+ - **Version 1.** A file from before the log (top-level `turns`, each entry its own, maybe `running`) keeps its places, drift
92
+ included: `seed` turns each count into `base_since`, new openings add to it, and such an entry never reads as having left
93
+ nothing (no id to check). The next save writes version 2 with `base_since` kept. `SessionManager.cards_empty?` reads both.
94
+ - **An older chi on a version 2 file.** Its `seed` wants an integer `turns`, finds none, and starts with no entries. A chi web not
95
+ yet restarted only reads: it lists no cards for that session until restarted, and the file stays whole. An older **writer** does
96
+ damage: at its first saved event, `:turn_started`, it writes `{"version":1,"turns":0,"running":true,"entries":[]}` over the file
97
+ (`{"version":1,"turns":1,"entries":[]}` after that first turn), so that session's cards go, and with them the answered approvals
98
+ `chi guardrails replay` reads. A plain upgrade doesn't reach this: a worker already running seeded before and keeps its own version 1
99
+ file, and a spawn runs the newest chi installed (`SessionManager.worker_command`), unless that activation raises LoadError: the boot
100
+ line then falls back to the spawner's or the home's pinned version, which can be older. Otherwise it takes an older process starting
101
+ on a session a newer one wrote: a downgrade, a chi home whose newest chi is older (`home_older_than_spawner`), or a REPL or `bin/chi`
102
+ from an older gem or checkout on the same state dir. Nothing on this side stops
103
+ it: an older chi can't be changed, and having it read the file would mean writing fake `turns` counters into every file.
104
+ - **A killed turn needs nothing special.** `TurnCheckpoint` keeps a running turn's prompt in the session file, with its id from
105
+ the start (`Worker#run_engine_turn` settles it first), so it counts as any opening. A REPL keeps no running turn in the
106
+ session, so a kill takes the prompt along and there's nothing to count.
71
107
 
72
108
  ## SSEWriter
73
109