samagotchi 0.157.0 → 0.158.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -1
  3. data/README.md +1 -1
  4. data/bin/chi +3 -1
  5. data/docs/architecture.md +7 -6
  6. data/docs/bundle-authoring.md +5 -5
  7. data/docs/bundles.md +2 -1
  8. data/docs/cli.md +21 -17
  9. data/docs/configuration.md +7 -5
  10. data/docs/context.md +5 -3
  11. data/docs/guardrails.md +8 -7
  12. data/docs/hooks.md +44 -80
  13. data/docs/internals/bridge.md +1 -1
  14. data/docs/internals/bundles.md +47 -34
  15. data/docs/internals/engine.md +7 -5
  16. data/docs/memory.md +39 -9
  17. data/docs/plugins.md +13 -9
  18. data/docs/releasing.md +6 -5
  19. data/docs/sessions.md +5 -5
  20. data/docs/testing.md +4 -4
  21. data/lib/samagotchi/bridge/card_places.rb +5 -13
  22. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  23. data/lib/samagotchi/bridge/turn_drawings.rb +7 -1
  24. data/lib/samagotchi/bundle_command/check.rb +23 -10
  25. data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
  26. data/lib/samagotchi/bundle_command/try/run.rb +4 -1
  27. data/lib/samagotchi/bundle_command.rb +15 -43
  28. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/memory_guide.md +6 -6
  31. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  32. data/lib/samagotchi/context_command.rb +7 -5
  33. data/lib/samagotchi/context_sources.rb +4 -2
  34. data/lib/samagotchi/engine.rb +1 -6
  35. data/lib/samagotchi/extension_load.rb +20 -41
  36. data/lib/samagotchi/guardrails/load_failures.rb +3 -2
  37. data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
  38. data/lib/samagotchi/guardrails/review_paths.rb +1 -5
  39. data/lib/samagotchi/hook_routes.rb +2 -2
  40. data/lib/samagotchi/hooks.rb +2 -3
  41. data/lib/samagotchi/memory_bundle/asset_installer.rb +39 -119
  42. data/lib/samagotchi/memory_bundle/builder.rb +10 -34
  43. data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
  44. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  45. data/lib/samagotchi/memory_bundle/installed_copy.rb +11 -18
  46. data/lib/samagotchi/memory_bundle/installed_manifest.rb +2 -16
  47. data/lib/samagotchi/memory_bundle/installer.rb +32 -14
  48. data/lib/samagotchi/memory_bundle/manifest.rb +37 -54
  49. data/lib/samagotchi/memory_bundle/provenance.rb +11 -51
  50. data/lib/samagotchi/memory_bundle/remerge.rb +7 -3
  51. data/lib/samagotchi/memory_bundle/shas.rb +5 -8
  52. data/lib/samagotchi/memory_bundle/shipped_update.rb +30 -35
  53. data/lib/samagotchi/memory_bundle/status.rb +12 -10
  54. data/lib/samagotchi/memory_bundle/system_bundle.rb +16 -5
  55. data/lib/samagotchi/memory_bundle/trash.rb +7 -12
  56. data/lib/samagotchi/memory_orphans.rb +4 -4
  57. data/lib/samagotchi/memory_paths.rb +102 -13
  58. data/lib/samagotchi/plugin/api.rb +1 -4
  59. data/lib/samagotchi/plugin/loader.rb +42 -5
  60. data/lib/samagotchi/project_command.rb +167 -0
  61. data/lib/samagotchi/project_init.rb +69 -0
  62. data/lib/samagotchi/project_scope.rb +6 -4
  63. data/lib/samagotchi/prompt_files.rb +10 -7
  64. data/lib/samagotchi/recap_place.rb +3 -4
  65. data/lib/samagotchi/run_options.rb +1 -0
  66. data/lib/samagotchi/session.rb +44 -18
  67. data/lib/samagotchi/session_access/contract.rb +1 -1
  68. data/lib/samagotchi/session_field.rb +5 -4
  69. data/lib/samagotchi/steer.rb +10 -0
  70. data/lib/samagotchi/system_prompt.rb +20 -11
  71. data/lib/samagotchi/terminal_ui/join_replay.rb +6 -1
  72. data/lib/samagotchi/testing/bundle.rb +5 -12
  73. data/lib/samagotchi/testing/install.rb +5 -5
  74. data/lib/samagotchi/testing/recorder.rb +1 -1
  75. data/lib/samagotchi/testing.rb +1 -1
  76. data/lib/samagotchi/tool_declarations.rb +1 -1
  77. data/lib/samagotchi/toolkit_binding.rb +1 -4
  78. data/lib/samagotchi/toolkit_command.rb +1 -4
  79. data/lib/samagotchi/tools/delegate.rb +12 -8
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +35 -0
  82. data/lib/samagotchi/web/project_list.rb +3 -2
  83. data/lib/samagotchi/web/public/index.html +6 -1
  84. data/lib/samagotchi/web/public/project_picker.js +86 -4
  85. metadata +3 -2
  86. data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
data/docs/plugins.md CHANGED
@@ -17,7 +17,7 @@ loop, the event shapes, the kit, the pitfalls.
17
17
  my-bundle/
18
18
  manifest.yml
19
19
  plugin.rb
20
- identity.md # optional, like any bundle's memories, hooks/ and guardrails/
20
+ identity.md # optional, like any bundle's memories and guardrails/
21
21
  README.md # never a memory (nor CHANGELOG, LICENSE, ...): see memory.md#what-a-bundle-owns
22
22
  ```
23
23
 
@@ -30,7 +30,7 @@ plugin:
30
30
  sha256: sha256:6a1a7022… # shasum -a 256 plugin.rb
31
31
  # required: true # optional: a guardrail plugin; if it fails to load, every tool call is
32
32
  # denied (see chi.on's on_error: :fail_closed; chi 0.157.0 or newer)
33
- requires_chi: ">= 0.1.28" # optional: a gem-style requirement (">= 0.1.28, < 0.2"); the bundle's hooks/ check it too
33
+ requires_chi: ">= 0.1.28" # optional: a gem-style requirement (">= 0.1.28, < 0.2"); the bundle's rules check it too
34
34
  # every key below has a chi floor: check warns when requires_chi admits a chi that never read it
35
35
  # requires_chi_next: true # shipped bundles only, ignored by chi: rake release:bump raises requires_chi
36
36
  # to the chi being released (docs/releasing.md)
@@ -473,7 +473,8 @@ end
473
473
 
474
474
  ### `chi.on(event, priority: 100, on_error: :log) { |event, ctx| … }`
475
475
 
476
- This is a bundle hook, the same as a `hooks/*.rb` file. See
476
+ This is a bundle hook: the one way a bundle hooks an event (class-file
477
+ `hooks/*.rb` hooks are retired, [hooks.md](hooks.md#class-file-hooks-are-retired)). See
477
478
  [hooks.md](hooks.md#hook-events) for the events; on `:after_turn`,
478
479
  `event[:present]` sets how the answer is shown in the web
479
480
  ([Presenting the answer](hooks.md#presenting-the-answer-display-only)) and `event[:refs]`
@@ -1546,7 +1547,7 @@ bundle.shutdown
1546
1547
  ```
1547
1548
 
1548
1549
  - **Real seams.** The plugin loads through `Plugin::Loader.register_file` (the real `chi` API and a real `ctx`),
1549
- the hook files through `Hooks::BundleLoader.load_hook_file`, into registries that already hold chi's own
1550
+ into registries that already hold chi's own
1550
1551
  commands and tools: a name chi has fails the load as it would in chi. `tool_call` runs a real
1551
1552
  `Guardrails::Gate` (context and targets computed for the call; `verdict:` starts from one with a vote in it).
1552
1553
  Only the edges are recorded: the session (`Testing::Host`), the hook helpers (`Testing::Runtime`),
@@ -1600,7 +1601,10 @@ bundle.shutdown
1600
1601
  go on; `bundle.lenient { … }` collects them in `bundle.errors` instead.
1601
1602
  - **Sandbox.** A test runs with temp config, state, data and cache homes (config.yml, memories, `ctx.data_dir`, the
1602
1603
  debug log), `HOME`, `CLAUDE_CONFIG_DIR` and `CODEX_HOME`, no git config of yours, no Keychain and none of your
1603
- `SAMAGOTCHI_*` settings; `sandbox.config(hash)` writes its config.yml.
1604
+ `SAMAGOTCHI_*` settings; `sandbox.config(hash)` writes its config.yml. A `Testing::Bundle.new` or `Bundle.load` with
1605
+ no `sandbox:` and no current sandbox enters its own, snapshots `ENV` when it is built, and restores that copy at
1606
+ `bundle.shutdown`. Build it before changing `ENV` in a test, not lazily inside a block that sets `ENV` for one example (your own
1607
+ helper, or ClimateControl); or scope the sandbox to the example with the `:bundle` tag or `Sandbox.with { … }`.
1604
1608
  - **RSpec:** `require "samagotchi/testing/rspec"`; example groups tagged `:bundle` (a spec under a bundle's own
1605
1609
  `spec/` is tagged by itself) get `load_bundle(dir)`, `shipped_bundle(name)` and `sandbox`, each example in its
1606
1610
  own sandbox, its bundles shut down after it (chi's shipped bundles keep theirs in `<bundle>/spec/`,
@@ -1634,14 +1638,14 @@ When a session's worker exits (an idle exit, `/exit`, a crash, TERM), or a
1634
1638
  ## Loading, and when it fails
1635
1639
 
1636
1640
  Plugins load when a session starts (`Engine.new`, in a worker or a
1637
- `--non-interactive` one-shot), after the bundle hooks. The steps are:
1641
+ `--non-interactive` one-shot), after the config hooks. The steps are:
1638
1642
 
1639
1643
  1. The file must match the sha256 recorded at install (not checked for a bundle installed with `--dev`).
1640
1644
  2. chi must meet `requires_chi`. A bundle whose `requires_chi` this chi doesn't
1641
1645
  meet is refused at install, so this step is reached only by one installed
1642
1646
  with `--force` or by a chi that has since been downgraded.
1643
1647
  3. The file is `module_eval`'d into a new module in the bundle's namespace
1644
- (`Samagotchi::Bundles::<bundle>`).
1648
+ (`Samagotchi::Bundles::<bundle>`, `Plugin::Loader.namespace_for`).
1645
1649
  4. The class is built, and `register(chi)` runs.
1646
1650
 
1647
1651
  What `register` adds takes effect only when it returns. A plugin that raises
@@ -1663,8 +1667,8 @@ starts again (`chi sessions stop`, or the idle exit).
1663
1667
 
1664
1668
  ## Trust
1665
1669
 
1666
- A plugin is Ruby code that runs inside chi with your permissions, just like
1667
- a bundle's hooks. **Installing a bundle means trusting it**, as you would a
1670
+ A plugin is Ruby code that runs inside chi with your permissions.
1671
+ **Installing a bundle means trusting it**, as you would a
1668
1672
  gem.
1669
1673
 
1670
1674
  - The sha256 is an integrity check, not proof of who wrote the file. A file
data/docs/releasing.md CHANGED
@@ -31,10 +31,10 @@ can run the whole release; the user approves the notes before the tag and the
31
31
  profile's (next point);
32
32
  - a new `ctx.*` / `chi.*` call a plugin may make gets its release's version in `Plugin::Api::SINCE` once
33
33
  released (`chi bundle check`'s `requires-chi-floor` reads it); an unreleased one waits there as an `# Unreleased` comment, and
34
- `release:check` fails while that comment is left: move each call up with the new version;
34
+ `release:bump` moves each call it names up with the new version (`release:check` fails while a comment is left);
35
35
  - a new manifest key or shape gets its release's version in `Manifest::SINCE` the same way (check's
36
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;
37
+ and `release:bump` moves it up with the new version the same way;
38
38
  - a new shipped bundle joins a profile (`includes:` in `core` or `dev`; a
39
39
  spec checks every bundle is in exactly one), and that profile's version
40
40
  goes up: `chi update` then installs only the new bundle for those who
@@ -69,14 +69,14 @@ as the GitHub release body.
69
69
 
70
70
  | Task | What it does |
71
71
  | --- | --- |
72
- | `rake bundles:sha` | Recomputes the sha256 lines of every shipped bundle manifest (`files:`, `hooks:`, `plugin:`). |
72
+ | `rake bundles:sha` | Recomputes the sha256 lines of every shipped bundle manifest (`files:`, `plugin:`, `scripts:`). |
73
73
  | `rake bundles:check` | Sha lines match; every top-level `*.md` of a bundle is listed in its `files:` (an install takes only the listed ones); a bundle changed since the last `v*` tag has a higher version (skipped, with a note, before the first tag). Warns (doesn't fail) when such a bundle's `requires_chi` still admits the tagged chi: mark it `requires_chi_next` if the change uses anything new. Notes a bundle marked `requires_chi_next`, and a profile (`core`, `dev`) whose `requires_chi` is below a member's. The release workflow and `release:check` run it in full. |
74
74
  | `rake 'bundles:check[ci]'` | The CI workflow's (every push and PR): the same, except the version check leaves out the system bundle, which is changed and unbumped between releases (`release:bump` moves its version with VERSION). A task argument, not `CI=1`: GitHub sets `CI` in the release workflow too. |
75
75
  | `rake bundles:specs` | The shipped bundles' own specs (`lib/samagotchi/bundles/*/spec`) on the bundle test kit alone (no `spec_helper`), as an external bundle's run. CI runs it as its own step. |
76
76
  | `rake lint` / `rake lint:fix` | RuboCop over the repo (must report no offenses; CI runs it) / its safe autocorrect (`rubocop -a`). |
77
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. |
78
78
  | `rake release:draft_changelog` | A draft Unreleased section from the commit subjects since the last tag, grouped. Stdout only. |
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. |
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, and each entry an `# Unreleased` comment in `Plugin::Api::SINCE` or `Manifest::SINCE` names moved into the table at X.Y.Z (the comment dropped). No commit. |
80
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). |
81
81
  | `rake release:notes[X.Y.Z]` | Prints the CHANGELOG section (default: the current VERSION). |
82
82
 
@@ -96,7 +96,8 @@ The agent does each step and stops where the user has to say yes.
96
96
  names the bundles whose `requires_chi` it raised (their `requires_chi_next`
97
97
  markers are gone) and the profiles it raised with them (a patch bump of
98
98
  each one's version, none for a profile already bumped since the last tag),
99
- which it names in the CHANGELOG's "Update with `chi update`" line.
99
+ which it names in the CHANGELOG's "Update with `chi update`" line, and the
100
+ SINCE entries it moved out of their `# Unreleased` comments.
100
101
  5. **Check.** Commit first (the check wants a clean tree), then run it:
101
102
  `git commit -am "Release X.Y.Z"` and `bundle exec rake release:check`.
102
103
  Fix anything it finds in new commits and run it again.
data/docs/sessions.md CHANGED
@@ -91,7 +91,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
91
91
 
92
92
  `chi sessions list` shows each session's saved recap, its first sentence without the "The user was…" opening (as on the web cards), in place of its last prompt, cut to 60 characters; a session with no recap shows its last prompt. A session whose last turn a hook stopped carries `[looped]` when the hook was loop-guard (the thinking watch's cut, or its stop) and `[stopped by <hook>]` for any other hook, after `[archived]` (`--format json` has it as `stopped_by`; from `Session#last_turn`, so it stays until a later turn ends differently). The web's session cards wear the same marker as a badge: `looped` or `stopped by <hook>` in its meta row (a session that also waits on you shows only its waiting badge: the stop is behind the need), and the all-view's search matches its words too. A delegated session's row ends with `↳ <parent's short id>` (plain and `--live`; `--format json` has `parent_id`), and a chain's link with `↪ <the previous link's short id>` (`--format json`: `continues`); rows keep their `updated_at` order, so a parent may be on another page than its children. After the status, `ctx 12%` says how full the context was after the session's last counted turn (from its `analytics.json`; blank when no turn had a server count or the window is unknown; `ctx_pct` in `--format json`). The fill is counted against the session's LLM context budget when one is set and smaller than the window (its own `--llm-context-budget`, else the model's/host's/config's; see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)), as the live meter counts it; the list, the web card and the session page otherwise agree. The web's session cards show it quietly too (`12%`), and a session's ctx meter shows it on load, before its next turn streams.
93
93
 
94
- **Projects:** a session belongs to the git project it was started in: the repository, whichever worktree or subfolder of it (the same project root memories use). `chi sessions list` (with `--live` and `--format` too) and `chi web` show the current folder's project; `--scope=all` shows every session, and so does a folder in no repo (`~`). `--cwd PATH` is a folder filter instead of the project. The project is stored with the session (`project_root` in its JSON, `project` in `--format json`), so a session keeps it after its worktree is deleted; sessions older than that are placed by their folder, and one whose folder is gone shows in `--scope=all` only. Retention, `--resume`/`--attach ID`, `chi send`/`chi note ID` and `chi note --all` (every live session) are not scoped. The agent's `list_sessions` lists its own project's sessions; `cwd: "/"` lists every one.
94
+ **Projects:** a session belongs to the project it was started in: the git repository, whichever worktree or subfolder of it, or a folder marked by `chi project init` and its subfolders (the same project root memories use; see [Projects without git](memory.md#projects-without-git)). `chi sessions list` (with `--live` and `--format` too) and `chi web` show the current folder's project; `--scope=all` shows every session, and so does a folder in no project (`~`). Sessions saved in a folder before it was marked join its project (their stored root is `null` and is looked up again); a running `chi web` places them after its restart. `--cwd PATH` is a folder filter instead of the project. The project is stored with the session (`project_root` in its JSON, `project` in `--format json`), so a session keeps it after its worktree is deleted; sessions older than that are placed by their folder, and one whose folder is gone shows in `--scope=all` only. Retention, `--resume`/`--attach ID`, `chi send`/`chi note ID` and `chi note --all` (every live session) are not scoped. The agent's `list_sessions` lists its own project's sessions; `cwd: "/"` lists every one.
95
95
 
96
96
  `--live`, `--cwd` and `--format` make `list` a picker for scripts (`SessionManager.session_rows`: the rows are `SessionRow`; the plain list, the picker's text and a remote list print one line, `SessionRow#cli_line`): `--live` keeps the sessions a worker runs now (the owner lock, not the saved status; a session a one-turn `chi --non-interactive` run has is left out), `--cwd PATH` those in PATH or below, and test runs are left out unless the list is itself run as one (`SAMAGOTCHI_ENV=test`, `RACK_ENV=test` or `CI` set: then they show, marked `[test]`; `chi note --all` likewise). `--live` shows 10 unless `--limit` says otherwise; filters apply before the limit. `--format json` prints `[{id, short_id, desc, cwd, project, updated_at, live, busy, owner, recap, parent_id, continues, delegate, archived, scratch, ctx_pct}]` (`owner`: `"worker"`, `"tui"` for a one-turn `chi --non-interactive` run, which takes no notes or messages, or null; `recap`: the first sentence of the session's recap, or null; `delegate`: true for a child the `delegate` tool started, false for a fork, which has a `parent_id` too), `--format tsv` one `id<TAB>desc` line per session, where `desc` is `<folder> · <last prompt>` cut to 60 characters (the text form of `--live`/`--cwd` prints the plain list's line, with its state, `idle` for a live idle worker too, and its text; tsv and json keep `desc`).
97
97
 
@@ -100,7 +100,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
100
100
  - The web header's project chip opens a picker. The start project is the default row (including its start folder); other rows show their root, session and waiting counts. **All projects** opens every session, and choosing a project drops the open-session hash while keeping `?view=`.
101
101
 
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
- - 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).
103
+ - Web UI (`chi web`): the page's scope is in its URL. Started in a project (a git repository or a `chi project init` folder), `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 project (no repository or marked folder) 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 project). 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
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).
@@ -115,7 +115,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
115
115
 
116
116
  ## Session file
117
117
 
118
- `Session::FIELDS` is the table of `<uuid>.json`, in the order `Session#save` writes it (`Session.new`, `.from_h` and `#to_h` all go by it; 41 keys). Each key is one `SessionField` row (`session_field.rb`): its default for a file without it, how a value is cast when read and what the file gets, the codec being its type's own (`Toolkit.from_file`, `SessionReview.of`, `PromptNote.list`, `#to_file`…); the typed fields' writers cast through their row, and `#to_h` reads each field's instance variable, not its reader. A key a file lacks reads as the default below, a key present as `null` as `null` (then cast: `test_run` false, `toolkit_origin` chosen); `review`, `first_turn_id`, `verdicts` and `verify_fresh` are written only when set (`OPTIONAL_FIELDS`). A key this chi doesn't know is kept and written back as it was (see the rule above). `thinking`, `reasoning_history` and `llm_context` follow one Unread rule (`SessionField.level`): a value this chi can't read (a newer chi's level, a hand edit) reads as none and is kept and written back as it was, until the session sets its own; `llm_context` keeps the parts it can't read under the ones it can. Where the file and the session's dir are, and which ids name a session, is `SessionPaths` (`session_paths.rb`, a leaf that `Session` extends: `Session.session_dir` is `SessionPaths.session_dir`). The bytes a save writes for each field and on each path that makes or rewrites a file are pinned in `spec/session_file_goldens_spec.rb`, with what a fork, a continue, a delegate child and a respawn keep of their source (`carry_matrix.json`). "Who writes it": the Engine runs in the session's worker (or a `--non-interactive` run); the rest are named.
118
+ `Session::FIELDS` is the table of `<uuid>.json`, in the order `Session#save` writes it (`Session.new`, `.from_h` and `#to_h` all go by it; 41 keys). Each key is one `SessionField` row (`session_field.rb`): its default for a file without it, how a value is cast when read and what the file gets, the codec being its type's own (`Toolkit.from_file`, `SessionReview.of`, `PromptNote.list`, `#to_file`…); the typed fields' writers cast through their row, and `#to_h` reads each field's instance variable, not its reader. A key a file lacks reads as the default below, a key present as `null` as `null` (then cast: `test_run` false, `toolkit_origin` chosen); `review`, `first_turn_id`, `verdicts` and `verify_fresh` are written only when set (`OPTIONAL_FIELDS`). A key this chi doesn't know is kept and written back as it was (see the rule above). `thinking`, `reasoning_history` and `llm_context` follow one Unread rule (`SessionField.level`): a value this chi can't read (a newer chi's level, a hand edit) reads as none and is kept and written back as it was, until the session sets its own; `llm_context` keeps the parts it can't read under the ones it can. So does `toolkit_origin`: an unknown one (a String) reads as `chosen` and is written back as it was, until the session sets its own. Where the file and the session's dir are, and which ids name a session, is `SessionPaths` (`session_paths.rb`, a leaf that `Session` extends: `Session.session_dir` is `SessionPaths.session_dir`). The bytes a save writes for each field and on each path that makes or rewrites a file are pinned in `spec/session_file_goldens_spec.rb`, with what a fork, a continue, a delegate child and a respawn keep of their source (`carry_matrix.json`). "Who writes it": the Engine runs in the session's worker (or a `--non-interactive` run); the rest are named.
119
119
 
120
120
  | Key | Meaning | Written by |
121
121
  |-----|---------|------------|
@@ -135,7 +135,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
135
135
  | `test_run` | Tagged by `SAMAGOTCHI_ENV=test`, `RACK_ENV=test` or `CI` (see Test-session hygiene). | creation |
136
136
  | `pending_question` | The question the worker waits on (`PendingQuestion`), `null` with none. | `QuestionDesk` |
137
137
  | `used_memory_names` | The memories the session read. | the Engine |
138
- | `project_root` | The project the session started in, stored because a worktree may be deleted later; `null` outside a repo. | creation |
138
+ | `project_root` | The project the session started in, stored because a worktree may be deleted later; `null` outside a repository or a marked folder (looked up again on read). | creation |
139
139
  | `preloaded_memory_names`, `muted_memory_names` | `--memory` and `--mute`, written before the worker starts so a respawn builds the same prompt. | creation |
140
140
  | `prompt_notes` | The model notes the system prompt carried at its last build (`[{name, scope, chars, digest}]`, see [Memory](memory.md#model-notes)). | the Engine, at each prompt build |
141
141
  | `parent_id` | The session that delegated this one or forked it (`ctx.sessions.fork`); `null` otherwise. See [Delegating](#delegating). | `SessionManager.spawn_session`; `ChildMove` writes a `parent.json` beside it instead of this file |
@@ -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 (`turn` in `delegates.json` is the follow-up's own id, set when the child's worker names `turn_ids`): the turn of its own it runs, or the running turn whose drain merges it as a steer (the drain's `input/merged/` record says which; inferred for an older child), not a newer reply of a turn queued behind it. A follow-up the running turn ended without taking runs as its own turn and gets that turn's reply. 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, 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.
498
+ - `cwd:` starts the child in another folder of the parent's project (a repository or a marked folder): 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). In a marked project the folder must have the parent's project root too (so a nested repository or marked project is refused); outside both only a subfolder of the parent's folder is allowed. Otherwise the tool returns `Error: cwd must be a folder of this session's project …` 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`, `--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
@@ -100,7 +100,7 @@ change, then read the diff.
100
100
  The bundle-state corpus (`spec/samagotchi/memory_bundle/file_state_corpus/`, `spec/fixtures/bundle_states/`) pins what
101
101
  every bundle file state does under every command that reads or writes it. Each case in `memory_cases.yml` (a memory:
102
102
  owned, edited, behind, missing, dropped, the user's own, another bundle's too…) and `asset_cases.yml` (a rule, the
103
- plugin, a script, a hook) is written directly as a starting tree, in the 0.155 and the 0.149 record shape, and run
103
+ plugin, a script, an older install's class-file hook) is written directly as a starting tree, in the 0.155 and the 0.149 record shape, and run
104
104
  through install, `--force`, `--dry-run`, upgrade and its flags, chi update's plan, Remerge, the system bundle's restore,
105
105
  uninstall and status. A cell holds only what a user or the next command sees (the bytes, the trash, base and checksum,
106
106
  the mark, the summary's word, status's label, the warnings), never the Installer's result strings. `expect/*.yml` are
@@ -321,7 +321,7 @@ any other file requires it, so a spec file run alone still finds its requires. E
321
321
 
322
322
  ## Bundle specs: the test kit
323
323
 
324
- A shipped bundle's plugin and hook files are tested through the bundle test kit (`samagotchi/testing`,
324
+ A shipped bundle's plugin is tested through the bundle test kit (`samagotchi/testing`,
325
325
  [plugins.md](plugins.md#testing-a-bundle-experimental)): `shipped_bundle("check-in", settings: …)` loads it through
326
326
  chi's own loaders, registries, `ctx` and guardrail Gate, with the session, the hook helpers and `ctx.sessions`
327
327
  recorded. No fake `chi` or `ctx`: what the kit can't express is a kit change (with a kit spec), not a local fake.
@@ -340,13 +340,13 @@ The kit's own specs, in `spec/samagotchi/testing/`, keep it and the bundles hone
340
340
  - `spec/samagotchi/testing/contract_spec.rb`: the kit sets every `Plugin::Host` and `Hooks::Runtime` member,
341
341
  `Testing::Sessions` has `Plugin::Sessions`' methods and parameters, and each `ctx` helper lands in its recorder. A
342
342
  member added to the Host fails it until the kit has it.
343
- - `spec/samagotchi/testing/shipped_bundles_spec.rb`: each shipped bundle with a plugin or hooks registers the same
343
+ - `spec/samagotchi/testing/shipped_bundles_spec.rb`: each shipped bundle with a plugin registers the same
344
344
  commands, tools, hooks, inits, toolkits and sources loaded by the kit as installed and loaded by chi
345
345
  (`Testing::Install`).
346
346
  - `spec/samagotchi/bundle_command/check_spec.rb`: `chi bundle check` passes every shipped bundle with no errors (one
347
347
  example per bundle), so a shipped bundle that wouldn't install or load cleanly for a user fails here too. Run it
348
348
  by hand on one with `ruby -Ilib bin/chi bundle check lib/samagotchi/bundles/NAME`.
349
- - `spec/samagotchi/testing/boundary_spec.rb`, the boundary lint: each bundle's Ruby (plugin, hooks, scripts, specs)
349
+ - `spec/samagotchi/testing/boundary_spec.rb`, the boundary lint: each bundle's Ruby (plugin, scripts, specs)
350
350
  reaches chi only through `Samagotchi::Testing::Boundary::ALLOWED` (the "Stable for bundles" list) and that
351
351
  bundle's `Boundary::EXCEPTIONS` entry, each with why. A new internal reference fails it, and so does an exception
352
352
  no longer used. Fix the reference (the kit or a stable API), or add the exception with its why.
@@ -14,19 +14,11 @@ module Samagotchi
14
14
  # an entry says how many it came after (+past_idless+, #idless_after), and
15
15
  # which one that is when it has an id (+after_opening+, #after_opening).
16
16
  #
17
- # The same place as a stamp (#opening_of): the id of the opening the entry
18
- # comes after (a prompt's turn_id, a line's opening_id), which the page
19
- # finds by its bubble, not by counting. Pure: no Engine, no file.
17
+ # The same place as a stamp (#opening_of, Steer.opening_stamp): the id of
18
+ # the opening the entry comes after (a prompt's turn_id, a line's
19
+ # opening_id), which the page finds by its bubble, not by counting.
20
+ # Pure: no Engine, no file.
20
21
  class CardPlaces
21
- # An opening's stamp: its turn_id (a prompt's, a wake note's), else its
22
- # own opening_id (/continue no's reason line); nil for neither (a line
23
- # an older chi wrote). The recap's too (RecapPlace).
24
- # @return [String, nil]
25
- def self.stamp(message)
26
- id = message[:turn_id] || message["turn_id"] || message[:opening_id] || message["opening_id"]
27
- id.is_a?(String) && !id.empty? ? id : nil
28
- end
29
-
30
22
  # @param turn_log [Array<String>] the turn ids, oldest first
31
23
  # @param messages [Array<Hash>] the messages the page draws
32
24
  def initialize(turn_log, messages)
@@ -123,7 +115,7 @@ module Samagotchi
123
115
  position >= 0 ? @last_at_or_before[position] : nil
124
116
  end
125
117
 
126
- def stamp_of(message) = self.class.stamp(message)
118
+ def stamp_of(message) = Steer.opening_stamp(message)
127
119
 
128
120
  def idless_run(opening)
129
121
  @ids.drop(opening + 1).take_while(&:nil?).size
@@ -183,7 +183,10 @@ module Samagotchi
183
183
  when "notice"
184
184
  close_text.call
185
185
  notice = part[:event]
186
- events << notice.merge(type: notice[:type].to_sym) if notice.is_a?(Hash) && notice[:type]
186
+ # A damaged drawing's type may be anything: only a name replays.
187
+ if notice.is_a?(Hash) && (notice[:type].is_a?(String) || notice[:type].is_a?(Symbol))
188
+ events << notice.merge(type: notice[:type].to_sym)
189
+ end
187
190
  when "steer"
188
191
  close_text.call
189
192
  events << { type: :pending_input_merged, count: 0, content: nil, steers: [{ source: part[:source], text: part[:text] }] }
@@ -55,13 +55,14 @@ module Samagotchi
55
55
  # One line of the file (string-keyed drawing and end, as JSON has them).
56
56
  Line = Data.define(:v, :turn_id, :kind, :from, :to, :from_digest, :to_digest, :drawing, :end) do
57
57
  # @return [Line, nil] nil for a line this chi can't draw (another
58
- # version, a missing field)
58
+ # version, a missing field, parts that aren't rows: .rows?)
59
59
  def self.from_h(raw)
60
60
  return nil unless raw.is_a?(Hash) && raw["v"] == VERSION
61
61
  return nil unless raw["turn_id"].is_a?(String) && !raw["turn_id"].empty?
62
62
  return nil unless raw["from"].is_a?(Integer) && raw["to"].is_a?(Integer) && raw["from"] < raw["to"] && raw["from"] >= 0
63
63
  return nil unless raw["from_digest"].is_a?(String) && raw["to_digest"].is_a?(String)
64
64
  return nil unless raw["drawing"].is_a?(Hash) && raw["end"].is_a?(Hash)
65
+ return nil unless TurnDrawings.rows?(raw["drawing"]["parts"])
65
66
 
66
67
  new(v: raw["v"], turn_id: raw["turn_id"], kind: raw["kind"].to_s, from: raw["from"], to: raw["to"],
67
68
  from_digest: raw["from_digest"], to_digest: raw["to_digest"], drawing: raw["drawing"], end: raw["end"])
@@ -134,6 +135,11 @@ module Samagotchi
134
135
  private :scrubbed
135
136
 
136
137
  class << self
138
+ # Whether a drawing's +parts+ are rows TurnAccumulator.replay_events
139
+ # can unfold: none, or a list of Hashes (a hand-edited line's may be
140
+ # anything, and the unfold would raise).
141
+ def rows?(parts) = parts.nil? || (parts.is_a?(Array) && parts.all?(Hash))
142
+
137
143
  # The end event's display keys (END_KEYS), with an empty answer's
138
144
  # notice from its summary.
139
145
  # @return [Hash]
@@ -21,7 +21,7 @@ module Samagotchi
21
21
  #
22
22
  # It installs and loads the bundle in a Testing::Sandbox (temp HOME and
23
23
  # XDG dirs), not in the user's config or state, but in this process: the
24
- # plugin's and hooks' own code runs as written; --fix rewrites stale
24
+ # plugin's own code runs as written; --fix rewrites stale
25
25
  # sha256 lines (MemoryBundle::Shas). #run returns 0 without errors, 1
26
26
  # with any.
27
27
  #
@@ -34,8 +34,8 @@ module Samagotchi
34
34
 
35
35
  Check the bundle at PATH (default: .) as an install would take it, one finding per line:
36
36
  error|warn|info CODE file[:line]: message (fix: hint)
37
- It installs and loads the bundle in a sandbox (temp HOME, config and state), not in yours, but its plugin and
38
- hooks run in this process as written: run it on code you'd install.
37
+ It installs and loads the bundle in a sandbox (temp HOME, config and state), not in yours, but its plugin
38
+ runs in this process as written: run it on code you'd install.
39
39
  --fix rewrite stale and placeholder sha256 lines in manifest.yml
40
40
  --set KEY=VALUE a setting for the load (config.yml bundles: <name>: KEY), repeatable
41
41
  Exit status: 0 no errors, 1 errors, 2 usage.
@@ -81,6 +81,7 @@ module Samagotchi
81
81
  # @return [Integer] exit status
82
82
  def run
83
83
  manifest = read_manifest
84
+ manifest = nil if manifest && hooks_retired?
84
85
  if manifest
85
86
  missing = check_files(manifest)
86
87
  check_source_tree(manifest, missing) unless @installed
@@ -136,12 +137,27 @@ module Samagotchi
136
137
  Dir.mktmpdir("chi-bundle-check-") do |tmp|
137
138
  File.write(File.join(tmp, "manifest.yml"), MemoryBundle::Shas.parseable(@text))
138
139
  MemoryBundle::Manifest.read(dir: tmp)
140
+ rescue MemoryBundle::Manifest::HooksRetired => e
141
+ add(:error, :"hooks-retired", "manifest.yml", e.message, line: manifest_line("hooks"), fix: hooks_fix)
142
+ nil
139
143
  rescue MemoryBundle::Manifest::ValidationError => e
140
144
  add(:error, :manifest, "manifest.yml", e.message.gsub(tmp, display(@dir)))
141
145
  nil
142
146
  end
143
147
  end
144
148
 
149
+ # A source with hooks/*.rb (listed or not) can't be installed: the run
150
+ # stops there, as after a manifest error (Installer#refuse_hooks).
151
+ def hooks_retired?
152
+ files = MemoryBundle::Manifest::HooksRetired.files_in(@dir)
153
+ return false if files.empty?
154
+
155
+ add(:error, :"hooks-retired", "hooks/", MemoryBundle::Manifest::HooksRetired.for_files(files).message, fix: hooks_fix)
156
+ true
157
+ end
158
+
159
+ def hooks_fix = "hook the events from plugin.rb with chi.on, then remove hooks/ and the manifest's hooks:"
160
+
145
161
  # The line of manifest.yml naming +file+ (its entry), or nil.
146
162
  def manifest_line(file)
147
163
  base = File.basename(file)
@@ -192,7 +208,7 @@ module Samagotchi
192
208
  # @return [Boolean] whether a listed file is missing
193
209
  def check_files(manifest)
194
210
  listed = manifest.files.keys + Array(manifest.plugin && manifest.plugin[:file]) +
195
- manifest.hooks.keys.map { |h| File.join("hooks", h) } + manifest.scripts.keys.map { |s| File.join("scripts", s) }
211
+ manifest.scripts.keys.map { |s| File.join("scripts", s) }
196
212
  missing = listed.reject { |rel| File.file?(path(rel)) }
197
213
  missing.each do |rel|
198
214
  add(:error, :"missing-file", "manifest.yml", "lists #{rel}, which isn't in the bundle", line: manifest_line(rel),
@@ -202,7 +218,7 @@ module Samagotchi
202
218
  end
203
219
 
204
220
  # What an install leaves out: a memory files: doesn't list (by
205
- # MemoryFiles' rules), a plugin, hook or script the manifest doesn't name.
221
+ # MemoryFiles' rules), a plugin or script the manifest doesn't name.
206
222
  def check_unlisted(manifest)
207
223
  memories = MemoryBundle::MemoryFiles.select(@dir, manifest)
208
224
  loose = Dir.glob(path("*.md")).map { |f| File.basename(f) }.sort.reject do |name|
@@ -211,9 +227,6 @@ module Samagotchi
211
227
  loose.each { |name| unlisted(name, "files:") }
212
228
  plugin = manifest.plugin && manifest.plugin[:file]
213
229
  unlisted("plugin.rb", "plugin:") if File.file?(path("plugin.rb")) && plugin != "plugin.rb"
214
- Dir.glob(path("hooks/*.rb")).sort.each do |file|
215
- unlisted("hooks/#{File.basename(file)}", "hooks:") unless manifest.hooks.key?(File.basename(file))
216
- end
217
230
  Dir.glob(path("scripts/*")).sort.each do |file|
218
231
  unlisted("scripts/#{File.basename(file)}", "scripts:") unless manifest.scripts.key?(File.basename(file))
219
232
  end
@@ -251,7 +264,7 @@ module Samagotchi
251
264
 
252
265
  # ── requires_chi ──────────────────────────────────────────────────
253
266
 
254
- def code?(manifest) = manifest.plugin || manifest.hooks.any? || manifest.scripts.any?
267
+ def code?(manifest) = manifest.plugin || manifest.scripts.any?
255
268
 
256
269
  def check_requires_chi(manifest)
257
270
  line = manifest_line("requires_chi")
@@ -414,7 +427,7 @@ module Samagotchi
414
427
  registered.tools.each { |name| add(:info, :registered, plugin, "tool #{name}") }
415
428
  registered.hooks.each do |event, hooks|
416
429
  hooks.each do |hook|
417
- file = manifest.hooks.key?(hook.hook) ? "hooks/#{hook.hook}" : plugin
430
+ file = plugin
418
431
  next add(:info, :registered, file, "hook #{event} (priority #{hook.priority})") if Hooks::EVENTS.include?(event)
419
432
 
420
433
  add(:warn, :"hook-event", file, "hook on #{event.inspect}, which chi never fires",
@@ -59,8 +59,9 @@ module Samagotchi
59
59
 
60
60
  # What the child sends: an edited file, which the copy's own shas can't
61
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.
62
+ # value or none: InstalledManifest#key_problems), the class-file hooks
63
+ # the record still has (they don't load), and Check's warnings and
64
+ # errors, naming the installed bundle's folder rather than the copy's.
64
65
  def answer_of(bundle)
65
66
  edited = MemoryBundle::ShippedUpdate.edited_executables(bundle.name, bundle).map do |file|
66
67
  Check::Finding.new(level: :warn, code: :edited, file: file, line: nil, fix: "chi bundle upgrade #{bundle.name}, or reinstall it",
@@ -69,6 +70,7 @@ module Samagotchi
69
70
  edited += bundle.manifest.key_problems.values.map do |message|
70
71
  Check::Finding.new(level: :error, code: :manifest, file: "manifest.yml", line: nil, message: message, fix: nil)
71
72
  end
73
+ edited << retired_hooks(bundle) unless bundle.hooks.empty?
72
74
  edited = edited.map { |finding| { level: finding.level, text: finding.to_s } }
73
75
  installed_dir = MemoryBundle::Provenance.new(name: bundle.name).bundle_dir
74
76
  edited + MemoryBundle::InstalledCopy.with(bundle) do |dir|
@@ -80,6 +82,15 @@ module Samagotchi
80
82
  end
81
83
  private_class_method :answer_of
82
84
 
85
+ # A 0.157 record's class-file hooks: they no longer load, and only an
86
+ # upgrade from a source that has moved them (or an uninstall) clears it.
87
+ def retired_hooks(bundle)
88
+ Check::Finding.new(level: :warn, code: :"hooks-retired", file: "hooks/", line: nil,
89
+ message: MemoryBundle::Manifest::HooksRetired::WORDS,
90
+ fix: "chi bundle upgrade #{bundle.name} once its source hooks from plugin.rb, or chi bundle uninstall #{bundle.name}")
91
+ end
92
+ private_class_method :retired_hooks
93
+
83
94
  def died(why, prefix: true) = Found.new(level: "error", text: check_line(prefix ? "the check died: #{why}" : why))
84
95
  private_class_method :died
85
96
 
@@ -163,7 +163,10 @@ module Samagotchi
163
163
  reader.join(0.1) if reader.alive?
164
164
  reader.kill if reader.alive?
165
165
  end
166
- readers.map { |reader| reader.value.to_s }
166
+ # readpartial gives raw bytes: the child writes UTF-8 (the answer,
167
+ # the trace), so each stream is read as that, a bad byte as "?",
168
+ # here once, for the text and the --json report alike.
169
+ readers.map { |reader| reader.value.to_s.dup.force_encoding(Encoding::UTF_8).scrub("?") }
167
170
  end
168
171
 
169
172
  def close_streams(streams)