samagotchi 0.151.0 → 0.153.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/docs/bundle-authoring.md +5 -5
  4. data/docs/bundles.md +84 -0
  5. data/docs/cli.md +35 -10
  6. data/docs/configuration.md +19 -14
  7. data/docs/guardrails.md +11 -5
  8. data/docs/hooks.md +4 -3
  9. data/docs/internals/anthropic.md +3 -3
  10. data/docs/internals/attached-context.md +3 -0
  11. data/docs/internals/block-renderers.md +3 -1
  12. data/docs/internals/bundles.md +48 -14
  13. data/docs/internals/engine.md +9 -5
  14. data/docs/internals/objectives.md +6 -4
  15. data/docs/internals/participants.md +1 -1
  16. data/docs/internals/prompt-caching.md +7 -3
  17. data/docs/internals/web-frontend.md +20 -17
  18. data/docs/memory.md +6 -1
  19. data/docs/plugins.md +80 -29
  20. data/docs/releasing.md +8 -3
  21. data/docs/sessions.md +2 -2
  22. data/docs/testing.md +2 -2
  23. data/lib/samagotchi/block_renderers.rb +9 -8
  24. data/lib/samagotchi/bundle_command/check.rb +43 -3
  25. data/lib/samagotchi/bundle_command/installed_check.rb +6 -2
  26. data/lib/samagotchi/bundle_command/try/sandbox.rb +11 -3
  27. data/lib/samagotchi/bundle_command.rb +88 -10
  28. data/lib/samagotchi/bundle_needs.rb +4 -6
  29. data/lib/samagotchi/bundle_settings_check.rb +14 -13
  30. data/lib/samagotchi/bundles/agents-md/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/system/memory_guide.md +3 -3
  38. data/lib/samagotchi/bundles/web-search/manifest.yml +13 -0
  39. data/lib/samagotchi/bundles/web-search/plugin.rb +174 -0
  40. data/lib/samagotchi/child_env.rb +15 -0
  41. data/lib/samagotchi/context_providers.rb +42 -10
  42. data/lib/samagotchi/engine.rb +37 -9
  43. data/lib/samagotchi/extension_load.rb +4 -3
  44. data/lib/samagotchi/guardrail_wiring.rb +28 -19
  45. data/lib/samagotchi/guardrails/approval.rb +2 -2
  46. data/lib/samagotchi/guardrails/review_boundary.rb +4 -3
  47. data/lib/samagotchi/guardrails/rules.rb +1 -0
  48. data/lib/samagotchi/guardrails/verdict.rb +20 -0
  49. data/lib/samagotchi/hooks/bundle_loader.rb +5 -7
  50. data/lib/samagotchi/llm_context_stale.rb +2 -2
  51. data/lib/samagotchi/memory_bundle/asset_installer.rb +11 -8
  52. data/lib/samagotchi/memory_bundle/builder.rb +36 -30
  53. data/lib/samagotchi/memory_bundle/bundle_hook.rb +3 -2
  54. data/lib/samagotchi/memory_bundle/file_digest.rb +31 -0
  55. data/lib/samagotchi/memory_bundle/file_standing.rb +2 -2
  56. data/lib/samagotchi/memory_bundle/installed_bundle.rb +55 -46
  57. data/lib/samagotchi/memory_bundle/installed_copy.rb +38 -15
  58. data/lib/samagotchi/memory_bundle/installed_manifest.rb +224 -0
  59. data/lib/samagotchi/memory_bundle/installer.rb +90 -16
  60. data/lib/samagotchi/memory_bundle/manifest.rb +146 -6
  61. data/lib/samagotchi/memory_bundle/manifest_standing.rb +171 -0
  62. data/lib/samagotchi/memory_bundle/merger.rb +7 -8
  63. data/lib/samagotchi/memory_bundle/profile.rb +3 -2
  64. data/lib/samagotchi/memory_bundle/provenance.rb +217 -18
  65. data/lib/samagotchi/memory_bundle/shas.rb +4 -3
  66. data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -24
  67. data/lib/samagotchi/memory_bundle/status.rb +11 -10
  68. data/lib/samagotchi/memory_bundle/system_bundle.rb +13 -2
  69. data/lib/samagotchi/message_parts.rb +3 -2
  70. data/lib/samagotchi/plugin/loader.rb +1 -1
  71. data/lib/samagotchi/session_config.rb +5 -4
  72. data/lib/samagotchi/system_one/record.rb +10 -2
  73. data/lib/samagotchi/toolkit.rb +8 -5
  74. data/lib/samagotchi/toolkit_binding.rb +30 -5
  75. data/lib/samagotchi/toolkit_report_command.rb +9 -5
  76. data/lib/samagotchi/toolkit_suggest.rb +12 -6
  77. data/lib/samagotchi/tools/builtin_calls.rb +14 -10
  78. data/lib/samagotchi/tools/deferred_tools.rb +2 -2
  79. data/lib/samagotchi/tools/delegate.rb +20 -2
  80. data/lib/samagotchi/tools/registry.rb +44 -20
  81. data/lib/samagotchi/tools/tool_index.rb +27 -21
  82. data/lib/samagotchi/update_command.rb +13 -2
  83. data/lib/samagotchi/version.rb +1 -1
  84. data/lib/samagotchi/web/app.rb +2 -1
  85. data/lib/samagotchi/web/public/app.js +61 -357
  86. data/lib/samagotchi/web/public/composer_mode.js +110 -0
  87. data/lib/samagotchi/web/public/owner_claim.js +81 -0
  88. data/lib/samagotchi/web/public/owner_verify.js +112 -0
  89. data/lib/samagotchi/web/public/session_fields.js +3 -3
  90. data/lib/samagotchi/web/public/strip_grip.js +3 -3
  91. data/lib/samagotchi/web/public/strip_hidden.js +65 -0
  92. data/lib/samagotchi/web/public/version_notices.js +132 -0
  93. data/lib/samagotchi/web/session_access_routes.rb +2 -1
  94. data/lib/samagotchi/web/session_hub.rb +6 -0
  95. data/lib/samagotchi/worker.rb +6 -0
  96. metadata +11 -1
data/docs/plugins.md CHANGED
@@ -29,6 +29,7 @@ plugin:
29
29
  file: plugin.rb
30
30
  sha256: sha256:6a1a7022… # shasum -a 256 plugin.rb
31
31
  requires_chi: ">= 0.1.28" # optional: a gem-style requirement (">= 0.1.28, < 0.2"); the bundle's hooks/ check it too
32
+ # every key below has a chi floor: check warns when requires_chi admits a chi that never read it
32
33
  # requires_chi_next: true # shipped bundles only, ignored by chi: rake release:bump raises requires_chi
33
34
  # to the chi being released (docs/releasing.md)
34
35
  session_settings: [cap] # optional: the settings a session may set (chi --set my-bundle.cap=3);
@@ -243,8 +244,13 @@ Web only: the terminals type the command (`/claude validate <text>`).
243
244
 
244
245
  ### `chi.tool(name, description, params:, schema:, label:, preview:, targets:, refs:) { |args, ctx| … }`
245
246
 
246
- This adds a tool the model can call. It is declared in the system prompt and
247
- in the chat path's `tools:`, after chi's own tools.
247
+ This adds a tool the model can call. A session's toolkit decides whether it
248
+ is declared, as for a built-in ([Toolkits](configuration.md#toolkits)): `full`
249
+ (and any kit on `base: full`) declares it, in the system prompt and in the
250
+ chat path's `tools:`, after chi's own tools; a kit that doesn't list it (`lean`,
251
+ `child`) leaves it **reachable**, found with `find_tools` as `chi/<name>` and
252
+ run by `call_tool` as the tool itself ([the chi group](#chitool_sourcecall-acts_as-label-wait-prefix));
253
+ `add:` declares it there, `remove:` takes it away.
248
254
 
249
255
  - `name`: a–z first, then a–z, 0–9 and `_`, up to 48 characters.
250
256
  - `params`: `{ name => property }`, where a property is a JSON Schema
@@ -461,6 +467,12 @@ end
461
467
  tool raises `ArgumentError` at once, as `chi.tool` does, and nothing is
462
468
  staged.
463
469
  - Inside `register`, use `chi.tool`: `replace_tools` raises there.
470
+ - Whether the session needs `find_tools` and `call_tool` is set by the tools
471
+ registered when its worker started. A tool a set adds on a kit that
472
+ leaves it out is reachable when the kit declares them anyway (`lean` does,
473
+ for its left-out built-ins); on a kit that declares neither it stays out
474
+ of reach until the user declares it (`/toolkit add NAME`), with one warn
475
+ notice by your bundle's name.
464
476
 
465
477
  ### `chi.on(event, priority: 100) { |event, ctx| … }`
466
478
 
@@ -683,11 +695,12 @@ two tools in all, whatever the number of sources: `find_tools(query,
683
695
  group?)` (a keyword search that answers each match's `inputSchema`) and
684
696
  `call_tool(tool, args)` (calls one as `<group>/<tool>`; its row label is the
685
697
  first source's `label`). The index has one group of its own, **`chi`**: the
686
- built-ins the session's toolkit leaves out but doesn't remove
687
- ([toolkits](configuration.md#toolkits)), each with its registered schema.
698
+ built-ins and plugin tools (`chi.tool`) the session's toolkit leaves out but
699
+ doesn't remove ([toolkits](configuration.md#toolkits)), each with its
700
+ registered schema; never `find_tools` and `call_tool` themselves.
688
701
  The two tools are registered once, after the plugins load, and declared per
689
- toolkit when it reaches a source group or has a reachable built-in (`full`
690
- with no source declares neither). Nothing a
702
+ toolkit when it reaches a source group or has a reachable tool, as the tools
703
+ stood then (`full` with no source declares neither). Nothing a
691
704
  source publishes later changes the request's tools, only what a search
692
705
  answers, so the prompt cache keeps: the search tool's description lists the
693
706
  source groups as they were at load, and a `chi` line first, per toolkit.
@@ -702,24 +715,25 @@ session's toolkit. A source's
702
715
  group named `chi`, or named like an earlier group, is left out of search,
703
716
  lookup and the description, with one warning per name.
704
717
 
705
- **A built-in through `call_tool` runs as that built-in.** `call_tool(tool:
718
+ **A chi tool through `call_tool` runs as that tool.** `call_tool(tool:
706
719
  "chi/delegate", args: {…})` (or a bare `delegate` while it's reachable) is
707
720
  turned into the `delegate` call itself before the guardrails, so rules on
708
721
  `delegate`, approvals (keyed `delegate:…`, the same as a direct call's),
709
722
  `before_tool_call`/`after_tool_call` hooks and the row see `delegate`, with
710
723
  `call[:via] == "call_tool"`; the row reads "delegate (via call_tool)", live
711
- and after a reload. `tool: call_tool` rules see MCP calls and misses only. A
712
- `chi/` name the toolkit removes or declares, or any built-in once
713
- `call_tool` itself is removed, is refused by its name before the guardrails
714
- (no hook, no card).
724
+ and after a reload. A plugin tool's the same: `chi/web_search` runs as
725
+ `web_search`, with its `label:`, `preview:` and `targets:`. `tool:
726
+ call_tool` rules see MCP calls and misses only. A `chi/` name the toolkit
727
+ removes or declares, or any chi tool once `call_tool` itself is removed, is
728
+ refused by its name before the guardrails (no hook, no card).
715
729
 
716
730
  **On a Messages host the model calls them by name too.** With `api:
717
731
  anthropic` (not Fireworks' dialect), every tool `call_tool` reaches goes in
718
732
  the request's tools deferred (`defer_loading`: its name only, until
719
- something loads it): a built-in by its name, a source's tool by its
733
+ something loads it): a built-in or plugin tool by its name, a source's tool by its
720
734
  `acts_as` (`mcp_fs_read`; one with a refusal, an acts_as two tools share, or
721
735
  a name another tool has stays out). A direct call of one runs whether
722
- anything loaded it or not: a built-in as itself (as if declared), a
736
+ anything loaded it or not: a built-in or plugin tool as itself (as if declared), a
723
737
  source's tool as the `call_tool` call it stands for, so its rules,
724
738
  approvals, hooks and row are `call_tool`'s. Elsewhere such a call is
725
739
  refused with the `call_tool` route, as before.
@@ -864,9 +878,9 @@ full label, `plugin.rb (bundle my-bundle)`.
864
878
  | `ctx.http` | an HTTP client for calling services (`get`, `post_json`, `request`): see [Calling services](#calling-services-ctxhttp) |
865
879
  | `ctx.sessions` | fork, send to, read, list and stop other sessions: see [Other sessions](#other-sessions-ctxsessions) |
866
880
  | `ctx.toolkit` | the name of the session's toolkit (`full` for a session from before toolkits); nil with no session |
867
- | `ctx.declared_tools` | the tool names the session's next turn declares (its toolkit resolved, with its own adds and removes): ask this, not the name, whether a tool is there |
881
+ | `ctx.declared_tools` | the tool names the session's next turn declares (its toolkit resolved, with its own adds and removes): ask this, not the name, whether a tool is there. Your bundle's own `chi.tool` tools are in it only on a kit that declares them (`full`, an `add:`); elsewhere they are reachable, not declared (the testing kit answers every tool, as on full, unless `bundle.set(declared_tools: […])`) |
868
882
  | `ctx.use_toolkit(name)` | put the session on toolkit `name` (yours from `chi.toolkit`, or chi's): saved, applied at the next turn's start and announced (`toolkit> toolkit: NAME from the next turn's start`, the web's tools chip follows). Only before the session's first model reply and outside a turn (the declared tools start every request, and a plugin doesn't ask first); otherwise it returns false and changes nothing: tell the user to run `/toolkit NAME`. An unknown name raises ArgumentError. Needs chi 0.66.0 |
869
- | `ctx.load_tools(names)` | load tools the session reaches but doesn't declare, so the model calls them by name: built-ins (`"delegate"`, `"chi/delegate"`), MCP tools (`"mcp_fs_read"`, `"fs/read"`). Returns the names it staged (the wire names); a declared tool, one loaded before, a removed or unknown one is skipped (an unknown one logged), so calling it every turn is fine. From a `before_turn` hook it loads for that turn; anywhere else (mid-turn too) from the next turn's start. Call it from a hook: before the session's first turn its toolkit isn't applied yet and nothing resolves. **A hint, not access**: the tools keep their guardrails and approvals, and only a host that takes tool changes (`api: anthropic`, not Fireworks) gets one, as a system message after the user's (`[tools loaded: delegate (your-bundle)]` in the session, a notice line live); elsewhere nothing is sent or saved, and the tools stay reachable with `call_tool`. A hook may switch the model after you call it, so the return value can't promise delivery. Raises ArgumentError for anything but an Array. Needs chi 0.99.0 |
883
+ | `ctx.load_tools(names)` | load tools the session reaches but doesn't declare, so the model calls them by name: built-ins and plugin tools (`"delegate"`, `"chi/delegate"`, `"chi/web_search"`), MCP tools (`"mcp_fs_read"`, `"fs/read"`). Returns the names it staged (the wire names); a declared tool, one loaded before, a removed or unknown one is skipped (an unknown one logged), so calling it every turn is fine. From a `before_turn` hook it loads for that turn; anywhere else (mid-turn too) from the next turn's start. Call it from a hook: before the session's first turn its toolkit isn't applied yet and nothing resolves. **A hint, not access**: the tools keep their guardrails and approvals, and only a host that takes tool changes (`api: anthropic`, not Fireworks) gets one, as a system message after the user's (`[tools loaded: delegate (your-bundle)]` in the session, a notice line live); elsewhere nothing is sent or saved, and the tools stay reachable with `call_tool`. A hook may switch the model after you call it, so the return value can't promise delivery. Raises ArgumentError for anything but an Array. Needs chi 0.99.0 |
870
884
  | `ctx.context` | attach outside text to this session and list it: see [Attached context](#attached-context-ctxcontext) |
871
885
  | `ctx.cache_confirm(line, changed:)` | inside a command: nil to apply a prompt-cache-restarting change now, or a confirm to return as `[text, confirm]`: see [Commands that ask](#commands-that-ask-ctxcache_confirm). Needs chi 0.132.0 |
872
886
 
@@ -1351,6 +1365,10 @@ context_providers:
1351
1365
  and becomes the installed bundle's folder each time it runs, so an
1352
1366
  upgrade moves nothing. `{ruby}` becomes the Ruby chi runs on (a `ruby` on
1353
1367
  the PATH may be another one, such as macOS's 2.6).
1368
+ - A bundle whose `requires_chi` this chi doesn't meet provides nothing:
1369
+ `.resolve` and `.any?` skip it (logged once per process per bundle and
1370
+ version), and a source already attached to one — before a chi downgrade,
1371
+ say — is refused by name, as a changed script is.
1354
1372
  - The command follows [the contract](context.md#the-command-contract).
1355
1373
 
1356
1374
  ## Block renderers
@@ -1390,9 +1408,9 @@ block_renderers:
1390
1408
 
1391
1409
  ## Stable for bundles
1392
1410
 
1393
- Bundles normally use only `chi.*` and `ctx`; the constants and helpers below
1394
- are the rest of the surface they may use. A change to any of them gets a
1395
- CHANGELOG line and bumps the shipped bundles that use it. Anything else under
1411
+ Bundles normally use only `chi.*` and `ctx`; the constants, helpers and manifest
1412
+ keys below are the rest of the surface they may use. A change to any of them gets
1413
+ a CHANGELOG line and bumps the shipped bundles that use it. Anything else under
1396
1414
  `Samagotchi::` is internal.
1397
1415
 
1398
1416
  **New API ships with its first caller:** a `chi.*` or `ctx.*` method arrives in the same change as a shipped bundle
@@ -1424,8 +1442,23 @@ that calls it, or for a real external bundle that does. No method on the chance
1424
1442
  `Samagotchi::VERSION`, `Samagotchi::CancellationController` (`new`, `cancel!`: what `cancel:` takes) and
1425
1443
  `Samagotchi::GitEnv::UNSET` (a spawn env: a bundle's own git, or gh, asks about the folder it names, not the repo
1426
1444
  a hook's or `rebase --exec`'s environment names).
1427
-
1428
- `Samagotchi::Testing::Boundary::ALLOWED` is the checked copy of this list,
1445
+ 7. **The manifest schema**: the manifest's keys and shapes, as [A bundle with a plugin](#a-bundle-with-a-plugin)
1446
+ and [bundles.md](bundles.md) document them — `name`, `version`, `scope`, `description`, `files`, `hooks`,
1447
+ `trust_level`, `plugin`, `requires_chi`, `requires_chi_next` (shipped bundles only: the release tooling
1448
+ reads it, chi doesn't), `needs`, `scripts`, `context_providers`, `block_renderers`, `includes`,
1449
+ `session_settings`, `settings`. Those are `Manifest::KEYS`; `Manifest::SINCE` is the chi release each
1450
+ key or shape first worked in, and the docs don't copy it — the code is its truth.
1451
+ - A key keeps its meaning. A new key gets a `SINCE` entry and a CHANGELOG line, and an older chi says
1452
+ it ignores it (`unknown-key`).
1453
+ - A later chi reads every key an earlier chi installed: the install keeps the bundle's `manifest.yml`
1454
+ ([Trust](#trust)). One installed before chi kept manifests reads what its install recorded until it is
1455
+ upgraded.
1456
+ - A bundle declares its floor with `requires_chi`, and an install below it is refused (`--force` aside).
1457
+ There is no `api_version:`.
1458
+ - `block_renderers:` and the frame's `chi.defineRenderer` ([Block renderers](#block-renderers)) are part
1459
+ of it. Neither is Ruby, so the scan below doesn't see them.
1460
+
1461
+ `Samagotchi::Testing::Boundary::ALLOWED` is the checked copy of items 1-6,
1429
1462
  and `Boundary::EXCEPTIONS` the per-bundle list of what each shipped bundle
1430
1463
  (and its specs) reaches beyond it, each with why: the APIs to design before
1431
1464
  that bundle could leave chi's repo. `spec/samagotchi/testing/boundary_spec.rb`
@@ -1433,9 +1466,6 @@ fails on a reference in neither (docs/testing.md). In `chi update`'s re-check of
1433
1466
  an installed bundle the same scan warns only for a name this chi no longer has
1434
1467
  (`Boundary.gone?`): a reference that still resolves gets no row.
1435
1468
 
1436
- A bundle's `block_renderers:` and the frame's `chi.defineRenderer` ([Block renderers](#block-renderers)) are stable
1437
- too. They aren't Ruby, so the scan above doesn't see them.
1438
-
1439
1469
  **Used by shipped bundles today, but internal** (may change without notice; a
1440
1470
  bundle that needs one should ask for an API instead):
1441
1471
 
@@ -1480,6 +1510,7 @@ bundle.judgments.runs("progress").last["asked"] # the runs as chi saves
1480
1510
  bundle.sessions.on(:children) { |all:| [] } # ctx.sessions, scripted and recorded
1481
1511
  bundle.http.stub(:post, "http://127.0.0.1:9000/classify", json: { label: "safe" }) # ctx.http, never the network
1482
1512
  bundle.set(running: false) # the session's values: cwd:, messages:, cancelled: …
1513
+ bundle.set(declared_tools: %w[read]) # ctx.declared_tools (default: every tool, as on full)
1483
1514
  bundle.set(cost: 2.5) # ctx.cost: reported USD, or { reported:, estimated:, side:, known: }
1484
1515
  bundle.set(usage: { tool_calls: 40, active_ms: 600_000 }) # ctx.usage: { turns:, tool_calls:, tool_calls_by_tool:, steps:, active_ms:, tool_ms_by_tool: }
1485
1516
  bundle.set(turn_author: "p:p_0123abcd") # a participant's turn: ctx.turn_author, and author: on every event
@@ -1551,7 +1582,8 @@ docs/testing.md). **Minitest:** `require "samagotchi/testing/minitest"` and subc
1551
1582
  `Samagotchi::Testing::Case` (the same helpers).
1552
1583
  - **Installed, not loaded from source:** `Samagotchi::Testing::Install.run(dir, settings: {})` (or
1553
1584
  `bundle.install`) installs the bundle into the sandbox as `chi bundle install` does (sha256 and `requires_chi`
1554
- checked) and loads the installed copy with chi's own loaders. It returns `warnings` (the Installer's),
1585
+ checked: a bundle whose `requires_chi` this chi doesn't meet raises `InstallError` unless `force: true`)
1586
+ and loads the installed copy with chi's own loaders. It returns `warnings` (the Installer's),
1555
1587
  `plugin_failures` / `hook_failures` (each with the `location` in your source, `plugin.rb:12`) and `registered`
1556
1588
  (commands, tools, hooks per event, inits, toolkits, tool and peer sources). A name chi ships raises
1557
1589
  `MemoryBundle::Installer::InstallError` unless `force: true`.
@@ -1579,7 +1611,9 @@ Plugins load when a session starts (`Engine.new`, in the REPL or a
1579
1611
  worker), after the bundle hooks. The steps are:
1580
1612
 
1581
1613
  1. The file must match the sha256 recorded at install (not checked for a bundle installed with `--dev`).
1582
- 2. chi must meet `requires_chi`.
1614
+ 2. chi must meet `requires_chi`. A bundle whose `requires_chi` this chi doesn't
1615
+ meet is refused at install, so this step is reached only by one installed
1616
+ with `--force` or by a chi that has since been downgraded.
1583
1617
  3. The file is `module_eval`'d into a new module in the bundle's namespace
1584
1618
  (`Samagotchi::Bundles::<bundle>`).
1585
1619
  4. The class is built, and `register(chi)` runs.
@@ -1609,6 +1643,11 @@ gem.
1609
1643
  source directory's, and an edit loads at the next start.
1610
1644
  - Install only copies the file (`--dev` links it). The code first runs at the
1611
1645
  next session start.
1646
+ - The bundle's `manifest.yml` is kept at install and checked the same way: chi
1647
+ reads hook events, `context_providers:`, `block_renderers:`, settings and
1648
+ `requires_chi` from it only while it has the sha256 recorded at install. An
1649
+ edited one isn't read (chi falls back to what the install recorded). `--dev`
1650
+ links it, unchecked.
1612
1651
  - Guardrail rules keyed by a tool's name apply to plugin tools, as they do to
1613
1652
  chi's own tools; path and command rules see what `targets:` says.
1614
1653
 
@@ -1616,18 +1655,30 @@ gem.
1616
1655
 
1617
1656
  - `chi bundle install <dir>`: copies the plugin to
1618
1657
  `<memories>/.bundles/<name>/plugin/`. It records the plugin's sha256 and
1619
- `requires_chi`, and warns about a wrong declared sha256 or an unmet
1620
- `requires_chi`.
1658
+ `requires_chi`, and warns about a wrong declared sha256. A bundle whose
1659
+ `requires_chi` this chi doesn't meet is **refused**: `Install failed: NAME
1660
+ can't be installed: it requires chi >= X (this is chi Y)`, exit 1, nothing
1661
+ written. `--force` installs it anyway, with the warnings that its plugin,
1662
+ hooks and rules won't load. It keeps the bundle's `manifest.yml` beside the record, and
1663
+ chi reads the manifest from there at load, so for a bundle installed by this
1664
+ chi or later, a key this chi didn't know at install works once a newer chi
1665
+ reads it (one installed before reads what its install recorded until
1666
+ `chi bundle upgrade <name>`).
1667
+ - `chi bundle upgrade <name>`: upgrades an installed bundle by name from the
1668
+ source its record keeps (its folder, archive or git URL, `#ref` included),
1669
+ so a bundle from elsewhere needs no path retyped; `upgrade <source>` takes a
1670
+ new one and says `Source changes: old → new`. A dev install is refused: it
1671
+ already follows its source.
1621
1672
  - `chi bundle status [<name>]`: shows `plugin=plugin.rb` in the list. For
1622
1673
  one bundle it shows `Plugin: plugin.rb [ok|modified|missing]` and a
1623
1674
  `requires_chi` failure.
1624
1675
  - `chi bundle diff <name> [plugin.rb]`: shows the installed base and the
1625
- file on disk.
1676
+ file on disk; for a memory file that stays behind, also the bundle's new text.
1626
1677
  - `chi bundle build --name <name>`: puts the installed plugin (and
1627
1678
  `requires_chi`, its `scripts/` and `context_providers:`) into the built
1628
1679
  bundle.
1629
1680
  - `chi bundle uninstall <name>`: removes the plugin with the bundle.
1630
- - `chi bundle install --dev <dir>`: links the bundle's plugin, hooks, rules and scripts to `<dir>` instead of
1681
+ - `chi bundle install --dev <dir>`: links the bundle's plugin, hooks, rules, scripts and `manifest.yml` to `<dir>` instead of
1631
1682
  copying them, with no sha256 check, so an edit applies at the next worker start
1632
1683
  ([cli.md](cli.md#writing-a-bundle-new-test-check)).
1633
1684
  - `chi bundle new`, `test` and `check`: start a bundle, run its tests on chi's Ruby, check it as an install takes
data/docs/releasing.md CHANGED
@@ -13,7 +13,7 @@ can run the whole release; the user approves the notes before the tag and the
13
13
  and `rake release:check` enforce it). The system bundle upgrades itself when
14
14
  chi starts.
15
15
  - **Every other shipped bundle** (btw, check-in, guardrails, known-names,
16
- loop-guard, mcp, skills, slow-commands, agents-md, cost-cap, work-cap, source-links, github-pr, coordinator, claude-peer, codex-peer, ruby, model-notes, decision-card, and the profiles core and dev) has its own semver in its
16
+ loop-guard, mcp, skills, slow-commands, agents-md, cost-cap, work-cap, source-links, github-pr, coordinator, claude-peer, codex-peer, ruby, model-notes, decision-card, web-search, and the profiles core and dev) has its own semver in its
17
17
  `manifest.yml` and, when it needs a newer chi, a `requires_chi:` line. They never upgrade by themselves: users run
18
18
  `chi update` (all of them, with the gem) or `chi bundle upgrade NAME`. So:
19
19
  - a change to a bundle's files bumps that bundle's `version:`
@@ -21,7 +21,9 @@ can run the whole release; the user approves the notes before the tag and the
21
21
  - a bundle that uses something new in chi marks it in the same change with
22
22
  `requires_chi_next: true` (or a short reason) in its `manifest.yml`, and
23
23
  leaves `requires_chi` alone: raising it to a chi that isn't released yet
24
- would make chi on main refuse its own bundle. `release:bump` raises a
24
+ would make chi on main refuse its own bundle outright — an unmet
25
+ `requires_chi` now stops the install, not just the load (`--force` is the
26
+ only way past it). `release:bump` raises a
25
27
  marked bundle's `requires_chi` to `">= X.Y.Z"` and drops the marker;
26
28
  `release:check` fails while a marker is left. chi itself ignores the key,
27
29
  and `bundles:check` notes it (and fails a marker that isn't `true` or a
@@ -30,6 +32,9 @@ can run the whole release; the user approves the notes before the tag and the
30
32
  - a new `ctx.*` / `chi.*` call a plugin may make gets its release's version in `Plugin::Api::SINCE` once
31
33
  released (`chi bundle check`'s `requires-chi-floor` reads it); an unreleased one waits there as an `# Unreleased` comment, and
32
34
  `release:check` fails while that comment is left: move each call up with the new version;
35
+ - a new manifest key or shape gets its release's version in `Manifest::SINCE` the same way (check's
36
+ `requires-chi-floor` reads it for the manifest too), waits as an `# Unreleased` comment while unreleased,
37
+ and `release:check` fails while that comment is left;
33
38
  - a new shipped bundle joins a profile (`includes:` in `core` or `dev`; a
34
39
  spec checks every bundle is in exactly one), and that profile's version
35
40
  goes up: `chi update` then installs only the new bundle for those who
@@ -72,7 +77,7 @@ as the GitHub release body.
72
77
  | `rake gem:validate`, `gem:build`, `gem:install`, `gem:clean` | Check the gemspec; build the gem into `pkg/`; build and install it locally; remove the built gems. |
73
78
  | `rake release:draft_changelog` | A draft Unreleased section from the commit subjects since the last tag, grouped. Stdout only. |
74
79
  | `rake release:bump[X.Y.Z]` | VERSION, the system manifest, Gemfile.lock, CHANGELOG, and `requires_chi: ">= X.Y.Z"` for each bundle marked `requires_chi_next` (the marker dropped), then a profile left below a member's `requires_chi` raised to it, with a patch bump of its version (none when it moved since the last tag) and named in the CHANGELOG's "Update with `chi update`" line. No commit. |
75
- | `rake release:check` | Clean tracked tree, VERSION == system bundle, a CHANGELOG section, no `requires_chi_next` left, no `# Unreleased` comment left in `Plugin::Api::SINCE`, `bundles:check`, `rake lint` (RuboCop) + `npm run lint` (the web frontend's ESLint), rspec (both roots: `spec` and the bundles' own `spec/`) + `rake bundles:specs` + npm test, `gem build`, then a clean install into a temp GEM_HOME that runs `chi --version`, `chi self` and `chi bundle list` with a temp HOME. Needs the network (gem dependencies). |
80
+ | `rake release:check` | Clean tracked tree, VERSION == system bundle, a CHANGELOG section, no `requires_chi_next` left, no `# Unreleased` comment left in `Plugin::Api::SINCE` or `Manifest::SINCE`, `bundles:check`, `rake lint` (RuboCop) + `npm run lint` (the web frontend's ESLint), rspec (both roots: `spec` and the bundles' own `spec/`) + `rake bundles:specs` + npm test, `gem build`, then a clean install into a temp GEM_HOME that runs `chi --version`, `chi self` and `chi bundle list` with a temp HOME. Needs the network (gem dependencies). |
76
81
  | `rake release:notes[X.Y.Z]` | Prints the CHANGELOG section (default: the current VERSION). |
77
82
 
78
83
  ## Runbook
data/docs/sessions.md CHANGED
@@ -102,7 +102,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
102
102
  - `Session.list` / `SessionManager.list_sessions` / `GET /api/sessions?sort=&order=&limit=&offset=` default to `updated_at desc` (newest activity first). Also supports `created_at`, `asc`. `X-Total-Count` header when paginated.
103
103
  - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new sessions start in that folder; the header chip says `<project> · all`, and `all` opens the same place as `?scope=all` (every session; a new session there starts in chi web's start folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the start folder's project). A bare `/` (the LAN link, the Home Screen app) is the start folder's project too: the folder chi web runs in, or `web.start_dir` / `chi web --start-dir DIR`; outside a repo it shows every session. A pushed notification opens its session in that session's own project (`/?dir=<its project>#/s/<id>`; `?scope=all` outside a repo). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat, and the grip on the chat's top edge — the same thin handle as the composer's, its twin — puts them away and brings them back: a drag up (or a click, or Enter/Space on it) hides them, a drag down (or a click) shows them again, and the choice is remembered. A hidden strip leaves its status in the top bar (`N sessions · ● 1 live · ● 1 waiting`, from the cards), and a click on that pill opens All sessions, without showing the strip first; there the pill shows whatever the strip's state, pressed, and a click on it goes back to the chat (as Esc does). In a window under 900 px wide, where the strip scrolls sideways, the grip stays under it, outside the scroller; a window under 700 px tall hides the strip by itself, so a question or approval card in the stage keeps room, and the grip shows it until the window is tall again. A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too; just `▸ 2 delegates` while the rows are open: they carry the marks then) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`; its header is the top bar's right end: a search over preview, id and status, the count, an `archived` toggle and Select (on a phone a row of its own under the bar). Esc or the pressed pill returns; where the sessions are stored is a faint line under the list. A card is its title on one line (an untitled session reads `Untitled` over its first message), a line under it (its recap, else its first message) and its meta row, with one mark per state and no status dot: a waiting card has its warn outline and its `question` (or `approval`…) chip, an errored one an `error` badge, a session a worker runs the `live` badge (not on a waiting card: waiting means a worker), and idle, completed and stopped ones are unmarked; a family's and a chain's rows the same. There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 · 7 delegates` (`3 / 14` while a search narrows it), a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new session. The message box grows with its text; drag its top edge to keep it taller (double-click resets). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links, bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked; a link whose text is such a ref alone (github-pr's link to the PR diff) becomes a ref too, and its plain click still goes to the PR. A plugin's refs on an answer (an `after_turn` hook's, see [Refs on the answer](hooks.md#refs-on-the-answer-display-only)) are paired with its links and text whatever the rows touched: they can link a file the session didn't touch, or an issue or a commit, and add their own actions to the ref's menu. A plugin tool's row carries the refs its `refs:` gives (see [Refs on a tool's row](plugins.md#refs-on-a-tools-row)): its title opens the first, and its `▾` menu adds the others as `<ref> · <action>` (the URL on hover); their URL actions work on a phone too. A ref with more than one target shows a small `▾` on hover: it opens a menu of them, in order **PR diff** (a link's), **Open in editor**, **Show in turn** and **Copy path**, and a plain click runs the first (a link's own, else the editor, else show in turn). **Show in turn** scrolls to the tool row that touched the file (looked for in the ref's own step, then its own turn, then the rows before it, then the nearest after it; there, the last one showing the ref's line, else the last one), opens the steps, the stage's steps and the row's diff (or the read showing the line) and flashes the row and the line; an answer's or a step's ref and an approval card's path have it, not a row's own title or lines. It needs no editor, so a viewer without one (a phone on the LAN) gets an answer's refs with **Show in turn** as their click; a row's title and diff lines stay plain text there. On a touch screen a tap opens the menu. A ref with a target is a tab stop: Enter runs its first target, ↓ opens the menu (↑/↓ move, Enter picks, Esc closes back to the ref). Diff and read lines have their click only. The info bar's id copies the full session id on click ("Session id copied"), the id every CLI command takes (`chi --attach ID`, `chi send ID`). A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its ⚙ chip, after the model, holds the session's thinking level, reasoning history and LLM context: `⚙` alone while the session follows the model for all three, else `⚙` and what the session set, in the accent colour (`⚙ think low · ctx stale,forget`; a phone shows ⚙ with a dot; the tooltip gives each value and its source). Its panel is the start page's ⚙ panel for this session: a Thinking row (`/thinking`), on a chat host a Reasoning row (the reasoning history, `/reasoning-history`), and a Context row with its Apply and budget fields (`/llm-context`); a tap runs that row's command at once in the worker (one reply in the conversation, as a typed one) and changes that field alone, the Context row's `default` resets the strategy, apply rule and budget (`/llm-context reset`), and a tap while a turn runs is queued for after it (the panel's note says so). `GET /api/sessions/:id`'s `session.thinking`, `session.reasoning_history` (null on a native host) and `session.llm_context` carry the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. After ⚙, the 🧰 chip names the session's toolkit: `🧰` alone on the configured default (`toolkit.default`, `session.toolkit_default`), `🧰 lean` on another (see [Toolkits](configuration.md#toolkits); `/toolkit` changes it). A `/thinking` or `/reasoning-history` change typed in the composer that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the ⚙ panel sends that line itself; see [Changes that restart the prompt cache](configuration.md#changes-that-restart-the-prompt-cache)). It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`); after a `/model` switch the info bar's tooltip adds a `per model:` row per run of turns on one model above those totals (`splash:qwen3-35b · 6 turns · 96.0k in / 9.0k out · ~$0.02`, a switch's prefill under its row: `└ switch prefill 58.0k uncached prompt ≈$0.01`; the timing's `model_segments`, replaced by each `generation_completed`'s; the session cards stay as they were), and right after the switch its ctx % is the new model's window's; and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new session starts on, and a fresh chain link's (see [Session chains](#session-chains); there a pick shows as a `/model` command bubble in the link); it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`; `toolkits`: `{default, suggest, kits: [{name, description?}]}`, the start page's 🧰 list: the configured `toolkit.default`, `suggest` `on` only when a new session's first message may get a suggested kit (`toolkit.suggest.mode: on`, a System 1 host, two described kits; else `audit` or `off`), and the built-in and config kits a new session can start on, without `review`, `child`, `undecided`, a kit `groups:` refuses or one that doesn't resolve (a base that is a bundle kit), the default always listed; left out when it can't be read), and `POST /api/sessions` takes `model` (blank means the default). Beside it, ⚙ opens the new session's rarely used settings (thinking and LLM context, following each `GET /api/models` row's `thinking` and `llm_context`, and the review files; see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)), and 🧰 its toolkit: **Auto** (with `toolkit.suggest.mode: on`: the first message may get a suggested kit, else the default) or a kit from the payload's `toolkits`, sent as `POST /api/sessions`'s `toolkit` (see [configuration.md](configuration.md#toolkits)); a refused kit (`400 toolkit_refused`) is said in the list, opened again. What is set shows on the row as an accent chip (`think high`, `tools: lean`; a dot on ⚙ or 🧰 on a phone); each is for the next new session only, never remembered. The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
104
104
  - Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
105
- - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `#N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `stage_view.js` (the stage view: the running turn pinned above the composer, with Stop turn in its status row while it runs, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model, three groups with hairlines between them, on one line above a phone's width (the folder gives way first): the setup (id, status, model, the ⚙ and 🧰 chips, notes, the memory or working-set chip), the relations (chain links, the parent as `↑ <id>`, delegates, participants; only when there is one) and the telemetry (folder, ctx, speed, the session's time, worker badge)), `settings_chip.js` (the session bar's ⚙ chip and its panel over `settings_panel.js`: a tap runs that row's command, one field), `toolkit_chip.js` (the session bar's 🧰 chip: the kit against `toolkit.default`), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`), `markdown_place.js` (where rendered markdown and its refs go: a text as markdown HTML, its `path:line` refs, a final answer's rendering), `images.js` (image chips in the composer: paste, drop and the image button, the upload before a turn, the thumbnails and the lightbox), `composer_grip.js` (the composer's grip and fold: the drag, tap and keys, the fold per visit), `composer_mount.js` (the composer mounted on a page's markup: the message box and its height, the image chips from a paste, a drop or the image button, the grip and fold, ↑/↓ over the prompt history and the keys; the page's own parts come in as hooks: what Enter and Send do, keys taken first, Esc, where the history comes from, the info bar's drop while folded, typing anywhere to open it; its styles, the card it sits in (`.composer-card`, its footer and its line above the text field) included, are the shared `composer.css`, linked after `tokens.css` by `index.html` and `participant.html`), `strip_grip.js` (the latest-sessions strip's grip, the composer grip's twin: hides and shows the strip), `review_history.js` (a participant's ↑/↓ list on the participant page: their own comments, in localStorage per grant, never the owner's `/api/history`; `participant.js` mounts `composer_mount.js` with it, no `/` list, no delivery choices, images off), `storage.js` (the page's per-browser choices in localStorage, guarded: a choice that can't be read or written is not remembered), `unqueue.js` (the × on a message queued for after the turn: takes it back, its text into an empty composer or a toast), `toasts.js` (the page's toast stack: a timer each, sticky ones until acted on, the same text reusing its item), `toast_place.js` (where the toast sits: above the composer card, the stage and an open model panel), `dock_reserve.js` (what floats over the history: the history's bottom reserve (`--dock-h`) at the composer card's and the showing stage's height, a reader at the end kept there, and the toast's place measured for `toast_place.js`), `notice_links.js` (a hook notice line's http(s) URLs as links), `start_page.js` (the start page's model picker, its ⚙ panel's and 🧰 list's choices for the next new session only and their override chips; a fresh chain link's picker and its `/model`), `start_settings.js` (the start page's ⚙ panel: thinking and LLM context as rows of segments, applied at once, the Apply and budget disclosure, the review files' view; the override chips' words and ⚙'s dot), `settings_panel.js` (the panel's rows as lit-html templates, shared: the segments, the disclosure, a hidden row's sent-anyway line, the review line; the caller passes its rows and applies the picks), `start_toolkit.js` (the start page's 🧰 list: Auto with suggest on, the kits, the default marked; a pick closes it; its override chip and dot), `session_actions.js` (Continue, Archive and its Undo, Delete and Stop, and the start page when the open session is gone), `send_turn.js` (the composer's send: a new session first on the start page, the route, the echo, the image upload, the `/turn` ack, a fast failure's restore and each error's place). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
105
+ - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `#N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `stage_view.js` (the stage view: the running turn pinned above the composer, with Stop turn in its status row while it runs, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model, three groups with hairlines between them, on one line above a phone's width (the folder gives way first): the setup (id, status, model, the ⚙ and 🧰 chips, notes, the memory or working-set chip), the relations (chain links, the parent as `↑ <id>`, delegates, participants; only when there is one) and the telemetry (folder, ctx, speed, the session's time, worker badge)), `settings_chip.js` (the session bar's ⚙ chip and its panel over `settings_panel.js`: a tap runs that row's command, one field), `toolkit_chip.js` (the session bar's 🧰 chip: the kit against `toolkit.default`), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts (the restarting set is `version_notices.js`'s) as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`), `markdown_place.js` (where rendered markdown and its refs go: a text as markdown HTML, its `path:line` refs, a final answer's rendering), `images.js` (image chips in the composer: paste, drop and the image button, the upload before a turn, the thumbnails and the lightbox), `composer_grip.js` (the composer's grip and fold: the drag, tap and keys, the fold per visit), `composer_mount.js` (the composer mounted on a page's markup: the message box and its height, the image chips from a paste, a drop or the image button, the grip and fold, ↑/↓ over the prompt history and the keys; the page's own parts come in as hooks: what Enter and Send do, keys taken first, Esc, where the history comes from, the info bar's drop while folded, typing anywhere to open it; its styles, the card it sits in (`.composer-card`, its footer and its line above the text field) included, are the shared `composer.css`, linked after `tokens.css` by `index.html` and `participant.html`), `strip_grip.js` (the latest-sessions strip's grip, the composer grip's twin: hides and shows the strip), `strip_hidden.js` (the strip hidden or shown: the saved choice, the short window's own hide, the grip and the body's class), `review_history.js` (a participant's ↑/↓ list on the participant page: their own comments, in localStorage per grant, never the owner's `/api/history`; `participant.js` mounts `composer_mount.js` with it, no `/` list, no delivery choices, images off), `storage.js` (the page's per-browser choices in localStorage, guarded: a choice that can't be read or written is not remembered), `unqueue.js` (the × on a message queued for after the turn: takes it back, its text into an empty composer or a toast), `toasts.js` (the page's toast stack: a timer each, sticky ones until acted on, the same text reusing its item), `toast_place.js` (where the toast sits: above the composer card, the stage and an open model panel), `dock_reserve.js` (what floats over the history: the history's bottom reserve (`--dock-h`) at the composer card's and the showing stage's height, a reader at the end kept there, and the toast's place measured for `toast_place.js`), `notice_links.js` (a hook notice line's http(s) URLs as links), `start_page.js` (the start page's model picker, its ⚙ panel's and 🧰 list's choices for the next new session only and their override chips; a fresh chain link's picker and its `/model`), `start_settings.js` (the start page's ⚙ panel: thinking and LLM context as rows of segments, applied at once, the Apply and budget disclosure, the review files' view; the override chips' words and ⚙'s dot), `settings_panel.js` (the panel's rows as lit-html templates, shared: the segments, the disclosure, a hidden row's sent-anyway line, the review line; the caller passes its rows and applies the picks), `start_toolkit.js` (the start page's 🧰 list: Auto with suggest on, the kits, the default marked; a pick closes it; its override chip and dot), `session_actions.js` (Continue, Archive and its Undo, Delete and Stop, and the start page when the open session is gone), `send_turn.js` (the composer's send: a new session first on the start page, the route, the echo, the image upload, the `/turn` ack, a fast failure's restore and each error's place). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
106
106
  - Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
107
107
  - The web session page's message box folds away for room to read. Drag the grip on its top edge (the pill that shows on hover; always on a phone) down, past the box by 32 px: the pill turns accent while releasing would hide it, and on release the box folds and the info bar stays as the page's bottom edge. The grip stays as a handle on the info bar's top edge, drawn always: pull it up (8 px opens it, and the box follows the pointer in the same gesture), click or tap it (opens it and focuses the box), or just type on the page (the key lands in the box) to bring it back; Enter or Space on the focused grip folds or opens too. A draft (text or images, which folding never clears) colours the handle with the accent, a session waiting on you with the attention colour; its tooltip says so. Stop turn isn't the message box's: it sits in the running turn's stage, at the right edge of its status row beside the fold caret (collapsed stage or not), while the turn runs, and goes when the turn ends A turn the page knows runs but has nothing of to draw (a reload whose session payload carries no current turn) shows that status row alone, `running` and Stop turn. The fold is remembered per browser (`chi_composer_collapsed`), an archived session opens folded for the visit, and opening it there, like the page opening it itself (a restored line, a quote, continue →'s opener), is for that visit only. The fold keeps the height the grip set (`chi_composer_floor`): the box opens again at the height it had, and only a double-click on the grip forgets it. That height never takes more than half of the window the box is drawn in: a floor saved in a bigger window is held to that half in a smaller one (a rotated phone, a resized window, another device on the same origin), and the saved floor itself stays, so the height comes back when the room does. A click on the expanded grip hides the box as well: it folds 250 ms after the press, so a second click inside that window is read as a double-click and resets the height instead (a drag, Enter or Space act at once, and a drag after a click takes the wait back). The start page's grip only resizes.
108
108
 
@@ -495,7 +495,7 @@ mise.toml pins Ruby 3.4 gets that `ruby`, `gem` and `bundle` in its tools while
495
495
  The `delegate` tool hands a task to a **child session**: an ordinary chi session in a worker of its own, started in the parent's `working_directory` with the task, verbatim, as its first user message, on the parent's model unless the call names one (`model:` takes a name or an alias, resolved as `--model` is; an unknown host comes back as the tool's `Error:` line; an id the host's saved model list doesn't have starts the child with a `Warning: host 'h' doesn't list model 'x' (did you mean: …?)` line in the result). Children run in parallel and only their **final reply** comes back to the parent: the child's newest `output/<timestamp>.txt` file, which the worker writes at the end of each turn that produced visible text, cut head-and-tail above 64 KB (`DelegateWait.cut`); a reply longer than `max_tool_output_chars` keeps its start and its end (the conclusion), with a gap line naming that `output/` file, where the whole reply is (`ToolOutputCut`'s `:head_tail_ref`; see [Tool output guardrails](internals/tool-output-limits.md#the-runners-cut-max_tool_output_chars)). Nothing of the child's trace enters the parent's context. Nothing is hidden: a child shows in `chi sessions list` (`↳ <parent>`), the web (folded into its parent's card on the strip and in all sessions, whose `▸ N delegates` chip lists the delegates at any depth, see Web UI above; a card of its own with a `↳` chip when its parent isn't listed; `↑ <parent id>` in its info bar, its tooltip `delegated by session …`; the parent's info bar carries a `⑂ N` chip counting its delegates, with how many run, wait, are done, failed, stopped or idle in the tooltip and the waiting ones named in the attention colour; a click opens all sessions), the terminal's status row (a parent's `⑂ 2 running · 1 waiting`, see [Status Line](cli.md#status-line)) and the `list_sessions` tool (`child`; the parent shows as `parent` in the child's own list); the user can `chi --attach <child id>` and steer it mid-run, and `chi send` and `send_note` reach it like any session.
496
496
 
497
497
  - `delegate(task, model:, session:, cwd:, wait:, timeout:)`: starts a child and, by default, waits for its reply. `wait: false` is the usual way for work that takes a while, several children side by side, or to keep talking with the user: it returns at once, the parent ends its turn or works on, and the reply comes back by itself as a *delegate report* (below), in a turn of its own if the parent is idle. The tool descriptions tell the model so, and to wait (`wait: true`, `delegate_result`) only when its turn can't go on without the reply. `timeout`, default 600 s, leaves a slow child running; 0 checks once without waiting. With `session:` (a child's id or prefix) the task goes to that child as a follow-up instead, delivered like `chi send` (client id `delegate:<parent short id>`, shown as `delegate>` in the child's UIs); only this session's children take one. The wait (`wait: true`, a later `delegate_result`) and the delegate report are for the reply of the child's turn the follow-up went to: its running turn, which it merges into, or the turn of its own it runs when the child is idle (`turn` in `delegates.json`, set when the child's worker names `turn_ids`), not a newer reply of a turn queued behind it. A follow-up merged as a steer into a turn the child started at that moment (a wake, a reminder, another message) gets that turn's reply or question. A child on its first turn, or with an older worker, gives its next reply, as before.
498
- - `cwd:` starts the child in another folder of the parent's repository: a linked worktree (`git worktree add ../<repo>-<task> -b <branch>`, which the model runs with `execute` first, under guardrails; chi runs no git here) or a subfolder; a relative path is taken from the parent's folder. The folder must exist, have the parent's project root (the same common git dir, `MemoryPaths.project_root`) and be in a work tree: a git dir (`.git`, `.git/objects`, a bare layout's `.bare`) or a bare layout's container is refused (`git rev-parse --is-inside-work-tree`, read-only, decides). Outside git only a subfolder of the parent's folder is allowed. Otherwise the tool returns `Error: cwd must be a folder of this session's repository …` or `… is not in a work tree …`. A follow-up (`session:`) refuses `cwd:`: the child keeps its folder. The child's guardrails measure from its own worktree, and project memories, `list_sessions` and the web's project are the same as the parent's. With no `cwd:` and a task that names another checkout of the same repository (a path in the task belonging to a linked worktree, read from the files with `Guardrails::GitInfo.checkouts`, no git process), the result carries `Warning: this child runs in <folder>, but its task names <checkout>. If it should work there, stop it (chi sessions stop <id>) and call delegate again without session, with cwd: <checkout>.` One line however many are named; nothing for a path of the child's own checkout, a folder that isn't one, or a path with spaces. The child starts as it would anyway, and with `wait: true` (the default) the line comes after the child ran, so it only helps with `wait: false`. A `Warning:` for an unknown model id comes first, then this one. `Log.info(:worker, "delegate_other_checkout", parent:)` records each line.
498
+ - `cwd:` starts the child in another folder of the parent's repository: a linked worktree (`git worktree add ../<repo>-<task> -b <branch>`, which the model runs with `execute` first, under guardrails; chi runs no git here) or a subfolder; a relative path is taken from the parent's folder. The folder must exist, have the parent's project root (the same common git dir, `MemoryPaths.project_root`) and be in a work tree: a git dir (`.git`, `.git/objects`, a bare layout's `.bare`) or a bare layout's container is refused (`git rev-parse --is-inside-work-tree`, read-only, decides). Outside git only a subfolder of the parent's folder is allowed. Otherwise the tool returns `Error: cwd must be a folder of this session's repository …` or `… is not in a work tree …`. A follow-up (`session:`) refuses `cwd:`: the child keeps its folder. The child's guardrails measure from its own worktree, and project memories, `list_sessions` and the web's project are the same as the parent's. With no `cwd:` and a task that names another checkout of the same repository (a path in the task belonging to a linked worktree, read from the files with `Guardrails::GitInfo.checkouts`, no git process), the result carries `Warning: this child runs in <folder>, but its task names <checkout>. If it should work there, stop it (chi sessions stop <id>) and call delegate again without session, with cwd: <checkout>.` One line however many are named; nothing for a path of the child's own checkout, a folder that isn't one, a path with spaces, or the main checkout when the child runs in a linked worktree of it (the work belongs in the worktree, so naming a main-checkout file is not a forgotten `cwd:`). The child starts as it would anyway, and with `wait: true` (the default) the line comes after the child ran, so it only helps with `wait: false`. A `Warning:` for an unknown model id comes first, then this one. `Log.info(:worker, "delegate_other_checkout", parent:)` records each line.
499
499
  - `delegate_result(session:, timeout:)`: waits for a child's next reply: the one named, or the newest running child (`timeout` as for `delegate`: 0 checks once and returns at once). The reply comes back once; a later call waits for the next one. After `wait: false` it isn't needed: the report brings the reply.
500
500
  - A wait also ends early when a person's message waits for the parent's turn's next step (the `task_wait` rule: `Engine#input_waiting?`, `SessionInbox.steer_waiting?`, never in a participant's turn): `ReplyWait`'s `message_waiting:` probe gives `:message_waiting`, checked after every outcome the child can arrive at (a reply, a question, an end, a gone worker) and a cancel, before the deadline, and the result says `no reply yet: a message from the user is waiting; the child keeps running. Read the message first.` with the same last line as a timeout's. The child is never touched, the cursor doesn't move (the reply still comes, as a report or at the next `delegate_result`), a relayed approval's outcome line stays in the result, and a reserved report reads as for a timeout. A delegate child's report doesn't end a wait. This holds for `delegate`'s own wait too (both go through `DelegateWait`).
501
501
  - A wait that timed out or was canceled still gets its delegate report: neither moves the parent's cursor, so the child's reply comes as a report when its turn ends. The result says so (`chi brings its reply here by itself as a delegate report …`; with `queue`, that it joins the next step or turn) and that `delegate_result` waits again only if the turn needs the reply now. A parent that gets no reports (`session.delegate_reports: off`, a `--no-shared` REPL, `--non-interactive`) is told `delegate_result <id> waits again` instead, and `wait: false`'s result says `delegate_result waits for its reply`.
data/docs/testing.md CHANGED
@@ -27,7 +27,7 @@ It also checks `spec/e2e/` for two things: `no-unused-vars`, and the Playwright
27
27
  Wait for the state (a locator's `expect`, `data-phase`, an animation's end through `waitForFunction`); time the page
28
28
  keeps (a pointer's dwell, a hold) runs on Playwright's `page.clock` (`install`, then `pauseAt` and `runFor`: the
29
29
  stage's hand-off dwell in stage.spec.js); a pause that is the behaviour outside the page (a slow post held in a
30
- route) carries `// eslint-disable-line no-restricted-syntax -- why`. And in the six
30
+ route) carries `// eslint-disable-line no-restricted-syntax -- why`. And in the seven
31
31
  modules that hold the owner page's session state, one more: no write into `current`, `store.current` or `state()`
32
32
  in place (`samagotchi/session-writes`; the store is the write path, see internals/web-frontend.md, The session store).
33
33
 
@@ -56,7 +56,7 @@ exception's text changes between Ruby versions: write the string yourself).
56
56
  The web specs run in plain node. One that loads lit-html, itself or through a module it imports (`turn_block.js`,
57
57
  `turn_html.js`, `card.js`, `copy.js`, `images.js`, `scope.js`, `command_view.js`, `diff_view.js`, `read_view.js`,
58
58
  `question_cards.js`, `stream_handlers.js`, `stage_view.js`, `bubbles.js`, `cards_place.js`, the chips, `info_bar.js`, `info_bar_model.js`, `lists_view.js`, `context_chips.js`, `families_view.js`,
59
- `chains.js`, `working_set_view.js`, `working_set_rail.js`, `unqueue.js`, `toasts.js`, `notice_links.js`, `start_page.js`, `start_settings.js`, `settings_panel.js`, `start_toolkit.js`, `session_actions.js`, `send_turn.js`, `composer_mount.js`, `dock_reserve.js`,
59
+ `chains.js`, `working_set_view.js`, `working_set_rail.js`, `unqueue.js`, `toasts.js`, `notice_links.js`, `start_page.js`, `start_settings.js`, `settings_panel.js`, `start_toolkit.js`, `session_actions.js`, `send_turn.js`, `composer_mount.js`, `dock_reserve.js`, `version_notices.js`, `owner_claim.js`, `owner_verify.js`,
60
60
  `vendor/lit-html.js`) needs a DOM: `import "./dom_shim.js";` as its first import (happy-dom, a devDependency only,
61
61
  never shipped; lit reads `document` as it loads). The shim's `markupOf(template)`,
62
62
  `htmlOf(nodes)` and `canon(html)` compare markup without lit's marker comments; the DOM writes text with only `&`, `<`, `>`
@@ -76,10 +76,10 @@ module Samagotchi
76
76
  def for_installed
77
77
  taken = {}
78
78
  MemoryBundle::Provenance.each_installed do |bundle, record|
79
- next unless record.block_renderers?
80
- next if MemoryBundle::Manifest.requires_chi_failure(record.requires_chi, VERSION)
79
+ manifest = record.manifest
80
+ next if manifest.requires_chi_failure(VERSION)
81
81
 
82
- renderers(bundle, record).each do |renderer|
82
+ renderers(bundle, manifest).each do |renderer|
83
83
  taken[renderer.lang] ||= Installed.new(lang: renderer.lang, bundle: bundle, files: renderer.load)
84
84
  end
85
85
  end
@@ -104,11 +104,12 @@ module Samagotchi
104
104
  raise Invalid, "bundle #{bundle}'s scripts/#{file} differs from the installed one (reinstall the bundle)"
105
105
  end
106
106
 
107
- def renderers(bundle, record)
108
- parse_list(record.block_renderers)
109
- rescue ArgumentError => e # Invalid is one; so are a record's bytes that aren't UTF-8
110
- Log.warn(:web, "block_renderers_unreadable", bundle: bundle, msg: e.message)
111
- []
107
+ # Its parsed renderers; a list that doesn't parse is none (logged).
108
+ def renderers(bundle, manifest)
109
+ if (problem = manifest.problems["block_renderers"])
110
+ Log.warn(:web, "block_renderers_unreadable", bundle: bundle, msg: problem)
111
+ end
112
+ manifest.block_renderers
112
113
  end
113
114
  private_class_method :renderers
114
115
  end
@@ -84,6 +84,7 @@ module Samagotchi
84
84
  if manifest
85
85
  missing = check_files(manifest)
86
86
  check_source_tree(manifest, missing) unless @installed
87
+ check_keys(manifest)
87
88
  check_requires_chi(manifest)
88
89
  check_trust(manifest) unless @installed
89
90
  check_clash(manifest)
@@ -147,6 +148,44 @@ module Samagotchi
147
148
  index && (index + 1)
148
149
  end
149
150
 
151
+ # ── the manifest's own keys ───────────────────────────────────────
152
+
153
+ # A key this chi's parser never reads is one it ignores, and a key whose
154
+ # floor is above what requires_chi admits is one an older chi would
155
+ # ignore too. The floor is the author's fix, so an installed bundle gets
156
+ # only the unknown-key line, as check_floor's call floor does today.
157
+ def check_keys(manifest)
158
+ manifest.unknown_keys.each do |key|
159
+ add(:warn, :"unknown-key", "manifest.yml", MemoryBundle::Manifest.unknown_key_line(key), line: manifest_line(key),
160
+ fix: "update chi, or remove #{key}: if it's a typo")
161
+ end
162
+ check_key_floor(manifest) unless @installed
163
+ end
164
+
165
+ # The manifest uses a key the oldest chi requires_chi admits never read.
166
+ # No plugin needed: the key is the bundle's own claim.
167
+ def check_key_floor(manifest)
168
+ key, version = manifest.floor_keys.max_by { |_, floor| Gem::Version.new(floor) }
169
+ return if key.nil?
170
+
171
+ bound = lower_bound(manifest.requires_chi)
172
+ return if bound && Gem::Version.new(version) <= bound
173
+
174
+ add(:warn, :"requires-chi-floor", "manifest.yml",
175
+ "#{manifest.requires_chi ? "requires_chi #{manifest.requires_chi.inspect} admits chi #{bound}," : "no requires_chi,"} " \
176
+ "but the manifest uses #{key_label(key)}, which needs chi #{version}",
177
+ line: manifest_line(key.split(".").first), fix: "requires_chi: \">= #{version}\"")
178
+ end
179
+
180
+ # How to name a SINCE key in a message: a shape is not a key on its own.
181
+ def key_label(key)
182
+ case key
183
+ when "files.description" then "a files: entry given as a mapping"
184
+ when "session_settings.types", "settings.types" then "#{key.split(".").first}: as a mapping of keys to types"
185
+ else "#{key}:"
186
+ end
187
+ end
188
+
150
189
  # ── files ─────────────────────────────────────────────────────────
151
190
 
152
191
  # @return [Boolean] whether a listed file is missing
@@ -340,15 +379,16 @@ module Samagotchi
340
379
  return if manifest.settings.nil? || @settings.empty?
341
380
 
342
381
  require_relative "../bundle_settings_check"
343
- record = MemoryBundle::InstalledBundle.new(name: manifest.name, settings: manifest.settings, setting_types: manifest.setting_types,
344
- session_settings: manifest.session_settings)
345
- BundleSettingsCheck.record_warnings(record, @settings).each { |line| add(:warn, :settings, "manifest.yml", line, line: manifest_line("settings")) }
382
+ BundleSettingsCheck.record_warnings(manifest, @settings).each { |line| add(:warn, :settings, "manifest.yml", line, line: manifest_line("settings")) }
346
383
  end
347
384
 
348
385
  def install_warnings(warnings)
349
386
  stale_reported = @findings.any? { |f| f.code == :sha }
350
387
  warnings.each do |warning|
351
388
  next if stale_reported && warning.start_with?("Checksum mismatch")
389
+ # An unknown manifest key is check_keys' row, with the fix the install
390
+ # can't offer; the same condition twice would be two rows.
391
+ next if warning.include?("doesn't know the manifest key")
352
392
  next if @too_new && warning.include?("won't load:")
353
393
  # This machine's PATH, said at install; nothing a chi update changed.
354
394
  next if @installed && warning.start_with?("needs ")
@@ -58,13 +58,17 @@ module Samagotchi
58
58
  private_class_method :in_child
59
59
 
60
60
  # What the child sends: an edited file, which the copy's own shas can't
61
- # tell, and Check's warnings and errors, naming the installed bundle's
62
- # folder rather than the copy's.
61
+ # tell, a manifest key that doesn't parse (the copy holds the mirror's
62
+ # value or none: InstalledManifest#key_problems), and Check's warnings
63
+ # and errors, naming the installed bundle's folder rather than the copy's.
63
64
  def answer_of(bundle)
64
65
  edited = MemoryBundle::ShippedUpdate.edited_executables(bundle.name, bundle).map do |file|
65
66
  Check::Finding.new(level: :warn, code: :edited, file: file, line: nil, fix: "chi bundle upgrade #{bundle.name}, or reinstall it",
66
67
  message: "changed since it was installed, so chi doesn't load it")
67
68
  end
69
+ edited += bundle.manifest.key_problems.values.map do |message|
70
+ Check::Finding.new(level: :error, code: :manifest, file: "manifest.yml", line: nil, message: message, fix: nil)
71
+ end
68
72
  edited = edited.map { |finding| { level: finding.level, text: finding.to_s } }
69
73
  installed_dir = MemoryBundle::Provenance.new(name: bundle.name).bundle_dir
70
74
  edited + MemoryBundle::InstalledCopy.with(bundle) do |dir|
@@ -101,6 +101,9 @@ module Samagotchi
101
101
  raise
102
102
  end
103
103
 
104
+ # The environment to spawn a child with: what the sandbox keeps, with
105
+ # every key it dropped present as nil (ChildEnv.spawn_env), so the
106
+ # child doesn't inherit it from this process.
104
107
  def child_env = @child_env.dup
105
108
 
106
109
  # Installs the tried bundle, then the shipped guardrails and
@@ -219,7 +222,12 @@ module Samagotchi
219
222
  # The user's environment (their hosts and keys) minus what no child
220
223
  # chi takes from its spawner (ChildEnv), the worker markers, this
221
224
  # chi's log and test settings, and the env forms of the config
222
- # sections the sandbox drops.
225
+ # sections the sandbox drops. ChildEnv.spawn_env makes every dropped
226
+ # key an unset in the child: a Hash handed to Open3 or Process.spawn
227
+ # is merged onto this process's environment, so deleting a key from
228
+ # it alone still passes it on. The Bundler changes come from ENV, not
229
+ # from this copy: read from the copy, a key dropped above would look
230
+ # like one Bundler added and come back with its value.
223
231
  def build_child_env
224
232
  env = ChildEnv.scrub(ENV.to_h)
225
233
  env.delete("SAMAGOTCHI_LOG_FILE")
@@ -235,8 +243,8 @@ module Samagotchi
235
243
  "SAMAGOTCHI_KEYCHAIN" => "on",
236
244
  **@layout.env
237
245
  )
238
- env.merge!(ChildEnv.unbundled(env))
239
- env
246
+ env.merge!(ChildEnv.unbundled)
247
+ ChildEnv.spawn_env(env)
240
248
  end
241
249
  end
242
250
  end