samagotchi 0.135.0 → 0.136.1

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -1
  3. data/docs/cli.md +7 -6
  4. data/docs/configuration.md +10 -7
  5. data/docs/internals/prompt-caching.md +1 -1
  6. data/docs/internals/web-frontend.md +10 -9
  7. data/docs/internals/working-set.md +2 -2
  8. data/docs/sessions.md +3 -3
  9. data/docs/testing.md +11 -6
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/version.rb +1 -1
  12. data/lib/samagotchi/web/app.rb +4 -4
  13. data/lib/samagotchi/web/message_display.rb +16 -0
  14. data/lib/samagotchi/web/public/answer_fold.js +110 -30
  15. data/lib/samagotchi/web/public/app.js +22 -40
  16. data/lib/samagotchi/web/public/bubbles.js +27 -7
  17. data/lib/samagotchi/web/public/chip_popover.js +2 -2
  18. data/lib/samagotchi/web/public/details_motion.js +74 -0
  19. data/lib/samagotchi/web/public/history_reveal.js +2 -2
  20. data/lib/samagotchi/web/public/index.html +75 -54
  21. data/lib/samagotchi/web/public/info_bar.js +11 -12
  22. data/lib/samagotchi/web/public/info_bar_model.js +8 -10
  23. data/lib/samagotchi/web/public/level_chip.js +33 -95
  24. data/lib/samagotchi/web/public/llm_context_chip.js +13 -144
  25. data/lib/samagotchi/web/public/reasoning_history_chip.js +8 -34
  26. data/lib/samagotchi/web/public/revise_stack.js +9 -3
  27. data/lib/samagotchi/web/public/session_fields.js +4 -4
  28. data/lib/samagotchi/web/public/session_state.js +7 -5
  29. data/lib/samagotchi/web/public/session_store.js +1 -1
  30. data/lib/samagotchi/web/public/settings_chip.js +212 -0
  31. data/lib/samagotchi/web/public/settings_panel.js +121 -0
  32. data/lib/samagotchi/web/public/shell_command.js +26 -0
  33. data/lib/samagotchi/web/public/start_settings.js +25 -88
  34. data/lib/samagotchi/web/public/thinking_chip.js +7 -45
  35. data/lib/samagotchi/web/public/time_gaps.js +5 -2
  36. data/lib/samagotchi/web/public/timing.js +13 -3
  37. data/lib/samagotchi/web/public/toolkit_chip.js +37 -21
  38. data/lib/samagotchi/web/public/turn_block.js +6 -1
  39. data/lib/samagotchi/web/public/turn_events.js +1 -1
  40. data/lib/samagotchi/web/public/turn_html.js +16 -14
  41. data/lib/samagotchi/web/session_create.rb +1 -1
  42. data/lib/samagotchi/web/session_show.rb +6 -3
  43. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 93a0efdc96f935536f82621c8c70c078cf4d89a9a88ce2a6881b77a9e545de30
4
- data.tar.gz: c8fd5b64ba27f5ad4d83369f58990cc53d449f6eb498050f32e2199216fcb9b6
3
+ metadata.gz: a5dbc0c078bedf4518127ba59fef2277480193f98de99dcbd8983bb79a3802d2
4
+ data.tar.gz: e352d50d3b09a5e2f2a3ddd8dda3b79461e76c7e4743c306fc885e78fa670100
5
5
  SHA512:
6
- metadata.gz: 478a7f90b5f012fa5c26cc571743191dc78550b9227880d01957e922606f495f35a6d4f4e6dba65fe2edacc0274fb57a48b67377efd704ea7f4034e0a03c1bb0
7
- data.tar.gz: 66e0a258aa80f18ddbee8d40430c77f19b74dc0b335a889379e795f3e38e21282ec283018b63563318e3b9fb03ee170143ae1e68ca1131fce923422d3ddeb248
6
+ metadata.gz: c48493b22b5916b579d1f1fd386f0169f8a26ea71a29f5c77a567045e422f115b8b3ec421cb6f0499e0fc584da7e08840eeebf2650ad5d9bd75a61c41dc34c89
7
+ data.tar.gz: b9e9e2c78d869fe803ac6dbf54fcacc305b60c4b4ccd354d78b0967d340ad54cbf3f18357bb24c26050d96e4cff3899e90755857979fd9ec6680cc9fd730f46a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,46 @@ and commands may change between minor versions. How releases are made:
7
7
  [docs/releasing.md](docs/releasing.md).
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.136.1] - 2026-10-30
11
+
12
+ ### Changed
13
+
14
+ - Web: collapsibles keep their shape. A steps block, a thinking block, the recap and a long answer's fold are the
15
+ same box open or closed (the bar, the border, the width); a click changes the height, animated unless the system
16
+ prefers reduced motion. A closed answer fold is its box's one line, "▸ first line · N lines"; open, the row is the
17
+ box's top edge, "▾ N lines", and no longer repeats the answer's first line above the answer (a delegate report's
18
+ likewise, in the report's tint).
19
+
20
+ Update with `chi update`, then restart `chi web`. `chi home doctor` says what else to do.
21
+
22
+ ## [0.136.0] - 2026-10-30
23
+
24
+ ### Changed
25
+
26
+ - Web: an unfolded long answer's fold row is the answer's header: it keeps the first line and size, and its bar runs
27
+ on into the answer's. The recap's ▸/▾ matches the steps' and thinking's in place of the browser's triangle.
28
+ - Web: a long delegate report folds to one row, "delegate report · its first heading · N lines", where the report
29
+ sits; a click opens it and this browser remembers that. A report merged into a running turn gets its row on the
30
+ next reload.
31
+ - Web: a delegate report in a reloaded history renders as markdown (headings, lists, code), as answers do; Copy
32
+ still copies its text. A report that arrives live shows as plain text until the next reload.
33
+ - Web: the session bar's id copies itself on click; the copy chi --attach button is gone.
34
+ - Web: the session bar's think, reasoning and llm ctx chips are one ⚙ chip whose panel is the start page's:
35
+ segments, a pick applies at once; the chip names only what the session set.
36
+ - Web: the session bar's tools chip is a 🧰 chip after ⚙: 🧰 alone while the session is on the configured default
37
+ kit (its tooltip names it), "🧰 lean" on another.
38
+
39
+ ### Fixed
40
+
41
+ - Web: a `!cmd`'s output in a reloaded history is its command bubble alone: it no longer opens a turn that showed
42
+ the next turn's steps and "turn N" timing a second time.
43
+ - Web: on a phone, a labelled line in the history (a delegate report, a tui line, a participant's) puts its label
44
+ above the text instead of squeezing the text into a narrow column beside it.
45
+ - Web: a session's recap is the page's last line again: the last turn's check-in notice no longer lands under it.
46
+
47
+ Update with `chi update`, then restart `chi web` and the session workers (`chi sessions restart`). `chi home doctor`
48
+ says what else to do.
49
+
10
50
  ## [0.135.0] - 2026-10-30
11
51
 
12
52
  ### Added
@@ -5190,7 +5230,9 @@ and long-lived sessions.
5190
5230
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
5191
5231
  hotkey panel that send selected text or the clipboard to your sessions.
5192
5232
 
5193
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.135.0...HEAD
5233
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.136.1...HEAD
5234
+ [0.136.1]: https://github.com/dm1try/samagotchi/compare/v0.136.0...v0.136.1
5235
+ [0.136.0]: https://github.com/dm1try/samagotchi/compare/v0.135.0...v0.136.0
5194
5236
  [0.135.0]: https://github.com/dm1try/samagotchi/compare/v0.134.0...v0.135.0
5195
5237
  [0.134.0]: https://github.com/dm1try/samagotchi/compare/v0.133.0...v0.134.0
5196
5238
  [0.133.0]: https://github.com/dm1try/samagotchi/compare/v0.132.0...v0.133.0
data/docs/cli.md CHANGED
@@ -707,9 +707,9 @@ back). Once the session has a model reply, the two that break the cache now
707
707
  the `--yes` line that applies them; `/llm-context reset --yes` works too.
708
708
  `/llm-context` alone answers mid-turn too; a change sent while a turn runs
709
709
  waits for the turn's end (see *Typing during a turn*). `/stats` shows
710
- the same values on its `llm context:` line. The web's info bar shows them as the
711
- `llm ctx` chip (`llm ctx stale,forget · session`), whose popover sets them
712
- through `/llm-context … --yes` (its form says what a change costs). See "LLM context: a session's own strategy" in
710
+ the same values on its `llm context:` line. The web's session bar names the
711
+ session's own in its ⚙ chip (`⚙ ctx stale,forget`), whose panel sets them
712
+ through `/llm-context … --yes`, one field a tap (its note says what a change costs). See "LLM context: a session's own strategy" in
713
713
  [configuration.md](configuration.md).
714
714
 
715
715
  `/working-set` lists the session's working set as the model has it (see [The
@@ -777,7 +777,7 @@ comes first; `/reasoning-history default` unsets it. It is saved and carried as
777
777
  or from `all` once an earlier reply has thinking rewrites the earlier replies;
778
778
  `drop` ↔ `turn` costs nothing. Under `turn` a step-limit Continue keeps the
779
779
  reasoning of the turn it continues. `/stats` shows it on its `reasoning history:`
780
- line, the web as the `reasoning` chip. See "Reasoning history" in
780
+ line, the web in the session bar's ⚙ panel (`⚙ reasoning all` once the session set its own). See "Reasoning history" in
781
781
  [configuration.md](configuration.md).
782
782
 
783
783
  `/toolkit` lists the tools the next turn declares (each with its estimated
@@ -1026,8 +1026,9 @@ copies its Markdown source, not the rendered text; a code block copies just
1026
1026
  its code; a prompt copies the text as you typed it.
1027
1027
 
1028
1028
  A long answer (over 25 lines or 2,000 characters of its text) can be folded
1029
- by hand: a slim "▾ N lines" row sits just above it once its turn has ended.
1030
- Click it and the answer folds to one row, "▸ its first line · N lines" (the
1029
+ by hand: a "▾ N lines" row is its top edge once its turn has ended. Click
1030
+ it and the answer closes to one line in its own box,
1031
+ "▸ its first line · N lines" (the
1031
1032
  first line of what a hook presented in its place, else its first heading,
1032
1033
  else its first line); click again to unfold. A delegate report's reply
1033
1034
  folds the same way. Folding is this browser's choice, kept per session in
@@ -656,7 +656,7 @@ models:
656
656
  came from);
657
657
  - `chi --thinking LEVEL` on a run that starts or resumes a session (the REPL, `-p`, `--resume`, an attached one);
658
658
  - `chi send --new --thinking LEVEL`;
659
- - the web's `think` chip in the session bar, and the start page's ⚙ panel (`POST /api/sessions` takes `"thinking": "low"`;
659
+ - the web's ⚙ panel in the session bar (its Thinking row), and the start page's ⚙ panel (`POST /api/sessions` takes `"thinking": "low"`;
660
660
  anything that isn't a level answers `400 invalid_thinking`).
661
661
  - `chi web --thinking LEVEL` is the default for the sessions its workers run, below a session's own. A session started
662
662
  with only the env (`SAMAGOTCHI_THINKING_LEVEL=low chi send --new …`) loses the level when a worker without that env
@@ -809,7 +809,7 @@ models:
809
809
  (its file's `"reasoning_history"`), so a `--resume`, a respawned worker, a continue and `/model` keep it; a plugin's
810
810
  fork copies it, a delegate child starts without it. Set it with `/reasoning-history LEVEL` in the session
811
811
  (`/reasoning-history default` unsets it, `/reasoning-history` alone shows the level and where it came from) or the
812
- web's `reasoning` chip in the session bar (shown on chat hosts only). There is no start flag: a fresh session runs
812
+ web's ⚙ panel in the session bar (its Reasoning row, shown on chat hosts only). There is no start flag: a fresh session runs
813
813
  the command before its first turn at no cost. A change takes effect at the next turn's start; sent while a turn runs
814
814
  it waits for its end.
815
815
  - What a change costs the prompt cache (`/reasoning-history` says so, and asks first when it costs: "Changes that
@@ -849,8 +849,8 @@ first: an unknown host or a bad alias is an error at once, not a question.
849
849
  - `Nothing changed yet: send /thinking low --yes to apply.` names the line that applies it.
850
850
  - `--yes` (or `-y`, anywhere after the command) applies it at once.
851
851
  - The terminal (the REPL and an attached TUI) puts the `--yes` line in the next input: Enter applies it.
852
- - The web's reply bubble has an **Apply (restarts the cache)** button. The session bar's chips are already an explicit
853
- choice: their Set sends the `--yes` line, and their form says what a change costs. Command bubbles are page-only, so
852
+ - The web's reply bubble has an **Apply (restarts the cache)** button. The session bar's ⚙ panel is already an
853
+ explicit choice: a tap sends the `--yes` line, and its note says what a change costs. Command bubbles are page-only, so
854
854
  the button is gone after a reload.
855
855
  - Only a person at a web tab or a chi TUI is asked. `chi send ID "/thinking low"`,
856
856
  `chi -p "/thinking low" --non-interactive`, lines piped into the REPL, a delegate, a plugin and a card's action
@@ -1570,12 +1570,15 @@ toolkit:
1570
1570
  cache; `full` and every kit without a prompt send the bytes they sent before. Keep it to a line or two that
1571
1571
  fits the kit's work (the coordinator bundle's `coding`: be concise): one that isn't text or is over 2000
1572
1572
  characters is left out with a warning, and long text belongs in `append_system_prompt:`. `/toolkit` shows it
1573
- (`prompt: …`), `/stats` its kit and size (`kit prompt:`), the web's tools chip in its tooltip.
1573
+ (`prompt: …`), `/stats` its kit and size (`kit prompt:`), the web's 🧰 chip in its tooltip.
1574
1574
  - **Saved at creation, definitions resolve at worker start.** A session stores its toolkit as named
1575
1575
  (`"toolkit": {"name": "lean", "add": [], "remove": []}`) when it is created: `toolkit.default`, `toolkit.child`
1576
1576
  for a delegate child (or the kit its `delegate` call names), or `chi --toolkit NAME` / `--tool` / `--no-tool` ([cli](cli.md)). Changing `toolkit.default`
1577
1577
  doesn't move existing sessions, not even when their worker is woken again; a session from before toolkits is on
1578
- `full`. A continue and a fork keep the toolkit name. `/toolkit` shows and changes it. The kit's tools, prompt
1578
+ `full`. A continue and a fork keep the toolkit name. `/toolkit` shows and changes it. The web's session bar
1579
+ shows it as the 🧰 chip after ⚙: `🧰` alone while the session is on `toolkit.default` (its tooltip names the kit,
1580
+ "full (the default)"), `🧰 lean` on another kit (`GET /api/sessions/:id`'s `session.toolkit` against
1581
+ `session.toolkit_default`, the default as chi web reads it now); it has no picker, `/toolkit` changes it. The kit's tools, prompt
1579
1582
  text and group selectors are part of its definition, not the session file: edits reach a saved name at its next
1580
1583
  worker start.
1581
1584
  - **A new web chat: 🧰.** The start page's 🧰 (beside ⚙; a bottom sheet on a phone) picks the new session's toolkit.
@@ -1708,7 +1711,7 @@ its own). The first target is a long session such as a day-long coordinator, whe
1708
1711
 
1709
1712
  - **Set it** at start with `chi --llm-context stale,forget --llm-context-apply turn_end --llm-context-budget 64k`
1710
1713
  (the same flags on `chi send --new`), in the session with `/llm-context strategy … apply … budget …`
1711
- (docs/cli.md, "Session commands"), or from the web's `llm ctx` chip in the info bar.
1714
+ (docs/cli.md, "Session commands"), or from the Context row of the web's ⚙ panel in the session bar.
1712
1715
  - **A new web chat:** the start page's ⚙ panel (beside the model picker; a bottom sheet on a phone) holds the new
1713
1716
  session's rarely used settings, each a row of segments that applies at once: **Thinking** and **Context** (the
1714
1717
  strategy; **Apply and budget ▸** opens the other two fields, with their own Set), the first segment following the
@@ -253,7 +253,7 @@ is prefilled once. The native and the chat loop use the same plan.
253
253
  estimate when no count is known. On `api: anthropic`, a `:full` / `:provider` change also warns that preserved
254
254
  thinking in replies is dropped from then on. A new setting with a cost adds its classifier and goes through
255
255
  the same step. A launch flag (`chi --model`, `--llm-context`, `--thinking` on an attached or resumed session,
256
- `LLMContextOverride.command_line`) and a chip whose form states the cost (`level_chip.js`, `llm_context_chip.js`)
256
+ `LLMContextOverride.command_line`) and the session bar's ⚙ panel, whose note states the cost (`settings_chip.js`)
257
257
  send their line with `--yes`: typing the flag, or the Set, is the choice. A plugin command asks
258
258
  through `ctx.cache_confirm(line, changed:)` (cost `:full`: its prompt section is in the stable part), with the
259
259
  sender's flag `SessionCommands#run` gives it (`CacheConfirm.asking`), and returns `[text, confirm]`.
@@ -17,9 +17,9 @@ on an existing module, imported by a new version of another, then finds the old
17
17
  page. A factory dep a new version stops passing is the same hazard the other way round: the store's redraws took
18
18
  `updateInfoBar` and the chip controls out of `stream_handlers.js`'s deps (and `updateInfoBar`/`updateComposerMode`
19
19
  out of `send_turn.js`, `session_actions.js` and `lists_view.js`); a page loaded before keeps all its old modules
20
- until a reload, so the risk is a mixed cache, not an open tab. `answer_fold.js`,
20
+ until a reload, so the risk is a mixed cache, not an open tab. `answer_fold.js`, `details_motion.js`,
21
21
  `turn_range.js`, `history_reveal.js`, `latest.js`, `session_payload.js`, `method_api.js`, `session_store.js`,
22
- `session_fields.js`, `feature_gates.js` and `revise_stack.js` are new modules for that reason.
22
+ `session_fields.js`, `feature_gates.js`, `revise_stack.js` and `shell_command.js` are new modules for that reason.
23
23
 
24
24
  ## Two pages, two models
25
25
 
@@ -88,22 +88,22 @@ A changed value (`!==`) marks its field's redraws; `sessions` marks `lists` and
88
88
  the chain's ← → links, the children chip, the archived button, the card's title, the fresh link's picker); `selected`
89
89
  marks `paintActive`, `composer` and `infoBar`; `touch(name)` marks one redraw for an input the store doesn't hold
90
90
  (the clock, versions, the stream, `liveTurn.running`). They run synchronously, once each, at the end of the outermost
91
- `batch()` (at once for a bare write), in one `ORDER`: `lists`, `paintActive`, the chip popovers (`llmContextChip`,
92
- `thinkingChip`, `reasoningChip`, `workingSetStrategy`), `featureGates`, `composer`, `infoBar`. `lists` comes before
91
+ `batch()` (at once for a bare write), in one `ORDER`: `lists`, `paintActive`, the open panels (`settingsPanel`,
92
+ `workingSetStrategy`), `featureGates`, `composer`, `infoBar`. `lists` comes before
93
93
  the bar because `updateSessionTitle` reads the strip's active card; `composer` before the bar as `setTurnRunning`
94
94
  runs them. Not a rAF (a background tab runs none, and the title badge tests run there; the e2e per-frame checks would
95
95
  see the composer move a frame late) and not a microtask (code that reads the page right after a write would see it
96
96
  stale).
97
97
 
98
98
  **The redraws are registered, not called.** app.js registers each name once (`store.on`, at the bottom, once every
99
- piece a redraw calls exists): `lists` → `lists.render()`, `paintActive` → `lists.paintActive()`, the chips →
100
- `xControl.refresh()`, `workingSetStrategy` → `workingSet.strategyChanged()`, `featureGates` (below), `composer` →
99
+ piece a redraw calls exists): `lists` → `lists.render()`, `paintActive` → `lists.paintActive()`, `settingsPanel` →
100
+ `settingsControl.refresh()`, `workingSetStrategy` → `workingSet.strategyChanged()`, `featureGates` (below), `composer` →
101
101
  `updateComposerMode()`, `infoBar` → `updateInfoBar()`. A writer writes and stops there: no `updateInfoBar()` after
102
102
  a store write. A value written unchanged redraws nothing (a chunk with the same ctx %).
103
103
 
104
104
  An input the store doesn't hold calls `store.touch(name)` where it changes: `infoBar` for the clock (liveTurn's
105
105
  `onTick`), the versions (`noticeVersions`), `restartingIds`, the stream (`startStream`, `workerGone`), the working
106
- set's and the participants' `onChange`, the copy-attach timer, the continue and review in-flight sets
106
+ set's and the participants' `onChange`, the continue and review in-flight sets
107
107
  (`session_actions.js`); `composer` and `infoBar` for `liveTurn.running` (`setTurnRunning`) and at the end of
108
108
  `renderSessionView` (a whole view drawn). Left as they are, on purpose: the `narrowScreen` listener calls
109
109
  `updateComposerMode()` (the bar doesn't read it), `applyStripHidden`/`applyView` call `updateSessionTitle()`, and
@@ -171,8 +171,9 @@ The redraws, by their store names:
171
171
  runs on most writes and every clock tick.
172
172
  - `composer` (`updateComposerMode()`): the composer's placeholder, buttons and delivery line (selected, running,
173
173
  owner).
174
- - `llmContextChip`, `thinkingChip`, `reasoningChip` (`xControl.refresh()`): an info-bar chip's **open popover** (the
175
- chip itself is drawn by the bar); `workingSetStrategy`: the working set's open list.
174
+ - `settingsPanel` (`settingsControl.refresh()`): the ⚙ chip's **open panel** (`settings_chip.js`; the chip itself is
175
+ drawn by the bar), on `thinking`, `reasoningHistory` and `llmContext`; `workingSetStrategy`: the working set's open
176
+ list.
176
177
  - `lists` / `paintActive` (`lists.render()` / `lists.paintActive()`): the strip and the all view from
177
178
  `store.sessions`.
178
179
  - `featureGates`: the gates above.
@@ -123,7 +123,7 @@ the Bridge (`ClientEvent.project`) passes it on unchanged, and the attached TUI
123
123
 
124
124
  **The route.** `GET /api/sessions/:id/working_set` (`Web::App#handle_working_set`) answers the snapshot's `to_h`
125
125
  and `running`. With a live worker it builds from the worker's snapshot messages (as `handle_show` (`Web::SessionShow`) reads them, keys
126
- symbolized) and the worker's llm ctx chip. Without one it builds from the session file and its saved chip
126
+ symbolized) and the worker's `llm_context` summary. Without one it builds from the session file and its saved chip
127
127
  (`SessionSetting::LLM_CONTEXT`). The folder is the session's `working_directory`. `running` is true when the
128
128
  snapshot has a turn in progress (`current_turn`).
129
129
 
@@ -260,7 +260,7 @@ with whitespace is quoted (`quoteWord`), and `steerWords` reads a line as the wo
260
260
  is in `STATES`: struck like forgotten, and a group is `dropped` when all its members are.
261
261
  `overlay` carries the server's `pinned` and `has_drop` (as `hasDrop`; `undroppable`): a forgotten item holding a
262
262
  drop offers only `undrop`, a live one `drop` and `undrop`, and neither offers `pin` (the worker asks for the undrop
263
- first). A row offers `pin` only when the strategy the session's `llm ctx` chip names
263
+ first). A row offers `pin` only when the strategy the session's `llm_context` summary names (the ⚙ chip's context value)
264
264
  has forget (`working_set.js forgetOn`, `pinnable`: not a muted, dropped or preloaded-only item). A `command_ran` that carries
265
265
  `llm_context` redraws the open list (`strategyChanged`), so its pins come and go with the strategy at once. A pinned row shows
266
266
  `📌` and `unpin` under any strategy (its drop is greyed, `dropRefusal`: "pinned by the user: unpin it first", the
data/docs/sessions.md CHANGED
@@ -39,7 +39,7 @@ A new session field (the rule in `Session::FIELDS`' comment): only top-level key
39
39
 
40
40
  **A worker killed mid-turn:** while a turn runs, its worker keeps it on disk (`TurnCheckpoint`): the session file has the turn so far (the prompt from the turn's start, then the finished tool steps and lines merged into it) and `interrupted_turn: {id, started_at, at, iteration, enqueued_id}` (`enqueued_id`: the input the turn runs, so any process can tell which turn is running; a continue turn carries its offer turn's), written as the turn starts, at the loop's first iteration boundary and then at most every 10 s (`updated_at` moves with it). The turn's end saves the real messages without the mark. A worker that died without ending its turn (SIGKILL, a crash of the process) leaves the mark, and loses what came after its last written boundary: at most the last 10 s of steps, plus the step in flight. Each checkpoint write also saves the turn's metrics so far to `analytics.json` (tokens, speeds, timings, its finished tool calls), as the record a killed turn leaves: canceled, `cancellation_reason: worker_stopped`; the turn's own end writes over it. The next worker to start for the session (a `chi send`, `--resume`, `--attach`, the web), or a `--no-shared` REPL resuming it, settles the turn before anything else (`TurnCheckpoint.settle`, log `interrupted_turn_settled`): a note `the previous turn was cancelled (worker-stopped) after Ns` goes at the tail, the status goes idle and the mark goes; `last_turn` stays the previous turn's (a new one saved before the turn a `chi send --wait` woke the worker for would end that wait at once); the dead turn's end is recorded under its `enqueued_id` instead, in `<id>/input/settled/<enqueued_id>` (a `last_turn` record: canceled, `cancel_reason: worker_stopped`), which only a wait for that very turn reads (`chi send --queue --wait`, a `chi send --wait -m` to an idle session or `--new`, `chi send --wait ID --turn`, a continue's wait): it ends `canceled` with `cancel_reason: worker_stopped`, detail "its worker exited mid-turn", instead of timing out. A wait with no turn id that saw the turn running ends the same way when the settle comes (`chi send --wait ID` with no message on a turn with no id, such as a session's first prompt or a reminder turn; a message merged into the turn as a steer), unless an input still waits or was claimed since: an unmerged message runs as its own turn next, and that turn's end is the answer. A reply the turn wrote before its worker died is still its answer. The session shows the turn's prompt and steps with that note; the dead turn is not run again, and the model reads on its next turn that it was cut.
41
41
 
42
- **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM, then CONT (a stopped worker, SIGSTOP or Ctrl-Z, runs its TERM handler only once continued, so it leaves at once instead of outliving the wait), and waits (up to 10 s) until the worker has let go of `owner.lock`. A running turn is canceled over the Bridge first, with reason `session_stopped` (the turn's line ends "(session stopped)", its note says `cancelled (session-stopped)`, and a command it was running says "stopped as the session was stopped"; a bare signal to the worker makes it "stopped by a signal to chi"; an older worker takes the reason as `manual`); the TERM stops whatever still runs as a Stop with reason `signal` and saves it (a spawned worker traps TERM, HUP and INT: `WorkerSignals`; a turn not ended within 3 s, 0.5 s after a stop or a second signal, is cut and the session saved as it was), and a stopped worker still saves its kept turn, since the marker, not the saved status, says stopped. Any TERM, HUP or INT does the same to a worker outside a stop: it leaves (log `worker stop reason=signal`) and the next send wakes a new one, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's `llm ctx` chip, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s (the page's Stop shows the stop at once), or for the body's `wait` up to the same 10 s (`SessionManager::STOP_WAIT`): `chi --remote NAME sessions stop` sends it, so it waits as long as here. Before that stop, the page's `stop session` button asks what the worker still has that the user can stop: `GET /api/sessions/:id/holds` (owner-only) answers the worker's `holds` — the same list the exit ask's 409 carries — always an array, empty when there is no live worker, one from before the route, or one that did not answer. With something, a sheet (a bottom sheet on a phone, a popover at the button otherwise — `stop_sheet.js`) lists them, at most three of a kind named and `+N more`, and offers **Stop all** (`POST /api/sessions/:id/holds/stop` → `HoldStop#stop_all`, then the stop), **Stop session only** (the stop exactly as it was) or **Cancel**. Nothing to ask about is the plain confirm, one tap as before. Both buttons stay because a stop only TERMs the worker: its reminders die with it (a stop also deletes a restart's `reminders.json` no successor took yet, so a later resume doesn't bring them back), while a background task and a delegate are their own processes and keep running. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
42
+ **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM, then CONT (a stopped worker, SIGSTOP or Ctrl-Z, runs its TERM handler only once continued, so it leaves at once instead of outliving the wait), and waits (up to 10 s) until the worker has let go of `owner.lock`. A running turn is canceled over the Bridge first, with reason `session_stopped` (the turn's line ends "(session stopped)", its note says `cancelled (session-stopped)`, and a command it was running says "stopped as the session was stopped"; a bare signal to the worker makes it "stopped by a signal to chi"; an older worker takes the reason as `manual`); the TERM stops whatever still runs as a Stop with reason `signal` and saves it (a spawned worker traps TERM, HUP and INT: `WorkerSignals`; a turn not ended within 3 s, 0.5 s after a stop or a second signal, is cut and the session saved as it was), and a stopped worker still saves its kept turn, since the marker, not the saved status, says stopped. Any TERM, HUP or INT does the same to a worker outside a stop: it leaves (log `worker stop reason=signal`) and the next send wakes a new one, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's ⚙ panel, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s (the page's Stop shows the stop at once), or for the body's `wait` up to the same 10 s (`SessionManager::STOP_WAIT`): `chi --remote NAME sessions stop` sends it, so it waits as long as here. Before that stop, the page's `stop session` button asks what the worker still has that the user can stop: `GET /api/sessions/:id/holds` (owner-only) answers the worker's `holds` — the same list the exit ask's 409 carries — always an array, empty when there is no live worker, one from before the route, or one that did not answer. With something, a sheet (a bottom sheet on a phone, a popover at the button otherwise — `stop_sheet.js`) lists them, at most three of a kind named and `+N more`, and offers **Stop all** (`POST /api/sessions/:id/holds/stop` → `HoldStop#stop_all`, then the stop), **Stop session only** (the stop exactly as it was) or **Cancel**. Nothing to ask about is the plain confirm, one tap as before. Both buttons stay because a stop only TERMs the worker: its reminders die with it (a stop also deletes a restart's `reminders.json` no successor took yet, so a later resume doesn't bring them back), while a background task and a delegate are their own processes and keep running. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
43
43
 
44
44
  **Deleting a session:** `chi sessions delete ID...`, `/exit --delete` in a terminal and the Web UI's delete all go through `SessionManager.delete_session`: it resolves a unique prefix, removes `<id>.json` and the whole `<id>/` dir, and returns what it removed. A session a plain REPL has open (`owner.lock` kind `tui`) is always refused. One a worker runs is refused unless the caller asks to stop it: then it stops the worker as `chi sessions stop` does and deletes once `owner.lock` is free (`--force` on the CLI, 10 s; the web page always, 2 s; `/exit --delete` after the worker agreed to exit, 10 s). A worker that outlives the wait leaves the session in place. The web's route is `DELETE /api/sessions/:id?force=true` (the page sends `force`; without it a live worker's session is 409 `worker_running`, as the CLI without `--force`; 200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
45
45
 
@@ -98,9 +98,9 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
98
98
  - 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=`.
99
99
 
100
100
  - `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.
101
- - 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. 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) 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`, with a search over preview, id and status (Esc or Back returns). 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)`, 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 copies `chi --attach <id>` for a terminal. 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 `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. Its `think` chip does the same for the thinking level (`/thinking`), and on a chat host its `reasoning` chip for the reasoning history (`reasoning all · session`, `/reasoning-history`; `session.reasoning_history`, null on a native host). A `/thinking` or `/reasoning-history` change that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the chips' Set 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).
101
+ - 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. 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) 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`, with a search over preview, id and status (Esc or Back returns). 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)`, 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).
102
102
  - 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).
103
- - 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, 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: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`), `markdown_place.js` (where rendered markdown and its refs go: a text as markdown HTML, its `path:line` refs, a final answer's rendering), `images.js` (image chips in the composer: paste, drop and the image button, the upload before a turn, the thumbnails and the lightbox), `composer_grip.js` (the composer's grip and fold: the drag, tap and keys, the fold per visit), `composer_mount.js` (the composer mounted on a page's markup: the message box and its height, the image chips from a paste, a drop or the image button, the grip and fold, ↑/↓ over the prompt history and the keys; the page's own parts come in as hooks: what Enter and Send do, keys taken first, Esc, where the history comes from, the info bar's Stop turn and drop while folded, typing anywhere to open it; its styles, the card it sits in (`.composer-card`, its footer and its line above the text field) included, are the shared `composer.css`, linked after `tokens.css` by `index.html` and `participant.html`), `strip_grip.js` (the latest-sessions strip's grip, the composer grip's twin: hides and shows the strip), `review_history.js` (a participant's ↑/↓ list on the participant page: their own comments, in localStorage per grant, never the owner's `/api/history`; `participant.js` mounts `composer_mount.js` with it, no `/` list, no delivery choices, images off), `storage.js` (the page's per-browser choices in localStorage, guarded: a choice that can't be read or written is not remembered), `unqueue.js` (the × on a message queued for after the turn: takes it back, its text into an empty composer or a toast), `toasts.js` (the page's toast stack: a timer each, sticky ones until acted on, the same text reusing its item), `toast_place.js` (where the toast sits: above the composer card, the stage and an open model panel), `dock_reserve.js` (what floats over the history: the history's bottom reserve (`--dock-h`) at the composer card's and the showing stage's height, a reader at the end kept there, and the toast's place measured for `toast_place.js`), `notice_links.js` (a hook notice line's http(s) URLs as links), `start_page.js` (the start page's model picker, its ⚙ panel's and 🧰 list's choices for the next new session only and their override chips; a fresh chain link's picker and its `/model`), `start_settings.js` (the start page's ⚙ panel: thinking and LLM context as rows of segments, applied at once, the Apply and budget disclosure, the review files' view; the override chips' words and ⚙'s dot), `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`).
103
+ - 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, 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: id, status, model, the ⚙ and 🧰 chips, chain links, chips, ctx, speed, clock, worker badge), `settings_chip.js` (the session bar's ⚙ chip and its panel over `settings_panel.js`: a tap runs that row's command, one field), `toolkit_chip.js` (the session bar's 🧰 chip: the kit against `toolkit.default`), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`), `markdown_place.js` (where rendered markdown and its refs go: a text as markdown HTML, its `path:line` refs, a final answer's rendering), `images.js` (image chips in the composer: paste, drop and the image button, the upload before a turn, the thumbnails and the lightbox), `composer_grip.js` (the composer's grip and fold: the drag, tap and keys, the fold per visit), `composer_mount.js` (the composer mounted on a page's markup: the message box and its height, the image chips from a paste, a drop or the image button, the grip and fold, ↑/↓ over the prompt history and the keys; the page's own parts come in as hooks: what Enter and Send do, keys taken first, Esc, where the history comes from, the info bar's Stop turn and drop while folded, typing anywhere to open it; its styles, the card it sits in (`.composer-card`, its footer and its line above the text field) included, are the shared `composer.css`, linked after `tokens.css` by `index.html` and `participant.html`), `strip_grip.js` (the latest-sessions strip's grip, the composer grip's twin: hides and shows the strip), `review_history.js` (a participant's ↑/↓ list on the participant page: their own comments, in localStorage per grant, never the owner's `/api/history`; `participant.js` mounts `composer_mount.js` with it, no `/` list, no delivery choices, images off), `storage.js` (the page's per-browser choices in localStorage, guarded: a choice that can't be read or written is not remembered), `unqueue.js` (the × on a message queued for after the turn: takes it back, its text into an empty composer or a toast), `toasts.js` (the page's toast stack: a timer each, sticky ones until acted on, the same text reusing its item), `toast_place.js` (where the toast sits: above the composer card, the stage and an open model panel), `dock_reserve.js` (what floats over the history: the history's bottom reserve (`--dock-h`) at the composer card's and the showing stage's height, a reader at the end kept there, and the toast's place measured for `toast_place.js`), `notice_links.js` (a hook notice line's http(s) URLs as links), `start_page.js` (the start page's model picker, its ⚙ panel's and 🧰 list's choices for the next new session only and their override chips; a fresh chain link's picker and its `/model`), `start_settings.js` (the start page's ⚙ panel: thinking and LLM context as rows of segments, applied at once, the Apply and budget disclosure, the review files' view; the override chips' words and ⚙'s dot), `settings_panel.js` (the panel's rows as lit-html templates, shared: the segments, the disclosure, a hidden row's sent-anyway line, the review line; the caller passes its rows and applies the picks), `start_toolkit.js` (the start page's 🧰 list: Auto with suggest on, the kits, the default marked; a pick closes it; its override chip and dot), `session_actions.js` (Continue, Archive and its Undo, Delete and Stop, and the start page when the open session is gone), `send_turn.js` (the composer's send: a new session first on the start page, the route, the echo, the image upload, the `/turn` ack, a fast failure's restore and each error's place). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
104
104
  - 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).
105
105
  - The web session page's message box folds away for room to read. Drag the grip on its top edge (the pill that shows on hover; always on a phone) down, past the box by 32 px: the pill turns accent while releasing would hide it, and on release the box folds and the info bar stays as the page's bottom edge. The grip stays as a handle on the info bar's top edge, drawn always: pull it up (8 px opens it, and the box follows the pointer in the same gesture), click or tap it (opens it and focuses the box), or just type on the page (the key lands in the box) to bring it back; Enter or Space on the focused grip folds or opens too. A draft (text or images, which folding never clears) colours the handle with the accent, a session waiting on you with the attention colour; its tooltip says so. Stop turn moves into the info bar while it is folded. The fold is remembered per browser (`chi_composer_collapsed`), an archived session opens folded for the visit, and opening it there, like the page opening it itself (a restored line, a quote, continue →'s opener), is for that visit only. The fold keeps the height the grip set (`chi_composer_floor`): the box opens again at the height it had, and only a double-click on the grip forgets it. That height never takes more than half of the window the box is drawn in: a floor saved in a bigger window is held to that half in a smaller one (a rotated phone, a resized window, another device on the same origin), and the saved floor itself stays, so the height comes back when the room does. A click on the expanded grip hides the box as well: it folds 250 ms after the press, so a second click inside that window is read as a double-click and resets the height instead (a drag, Enter or Space act at once, and a drag after a click takes the wait back). The start page's grip only resizes.
106
106
 
data/docs/testing.md CHANGED
@@ -23,8 +23,10 @@ style pass: add a rule only for a bug class it would have caught. It runs in abo
23
23
  `rake release:check`; `rake lint` stays RuboCop alone (CI's rubocop job has no node). A name that is also a browser
24
24
  global (`name`, `status`, `open`…) passes even when its import is gone: give an export such a name only with care.
25
25
  It also checks `spec/e2e/` for one thing, the Playwright twin of `Samagotchi/SpecSleep`: no `page.waitForTimeout`.
26
- Wait for the state (a locator's `expect`, `data-phase`, an animation's end through `waitForFunction`); a pause that
27
- is the behaviour (a pointer's dwell) carries `// eslint-disable-line no-restricted-syntax -- why`. And in the six
26
+ Wait for the state (a locator's `expect`, `data-phase`, an animation's end through `waitForFunction`); time the page
27
+ keeps (a pointer's dwell, a hold) runs on Playwright's `page.clock` (`install`, then `pauseAt` and `runFor`: the
28
+ stage's hand-off dwell in stage.spec.js); a pause that is the behaviour outside the page (a slow post held in a
29
+ route) carries `// eslint-disable-line no-restricted-syntax -- why`. And in the six
28
30
  modules that hold the owner page's session state, one more: no write into `current`, `store.current` or `state()`
29
31
  in place (`samagotchi/session-writes`; the store is the write path, see internals/web-frontend.md, The session store).
30
32
 
@@ -46,14 +48,14 @@ exception's text changes between Ruby versions: write the string yourself).
46
48
  The web specs run in plain node. One that loads lit-html, itself or through a module it imports (`turn_block.js`,
47
49
  `turn_html.js`, `card.js`, `copy.js`, `images.js`, `scope.js`, `command_view.js`, `diff_view.js`, `read_view.js`,
48
50
  `question_cards.js`, `stream_handlers.js`, `stage_view.js`, `bubbles.js`, `cards_place.js`, the chips, `info_bar.js`, `info_bar_model.js`, `lists_view.js`, `context_chips.js`, `families_view.js`,
49
- `chains.js`, `working_set_view.js`, `working_set_rail.js`, `unqueue.js`, `toasts.js`, `notice_links.js`, `start_page.js`, `start_settings.js`, `start_toolkit.js`, `session_actions.js`, `send_turn.js`, `composer_mount.js`, `dock_reserve.js`,
51
+ `chains.js`, `working_set_view.js`, `working_set_rail.js`, `unqueue.js`, `toasts.js`, `notice_links.js`, `start_page.js`, `start_settings.js`, `settings_panel.js`, `start_toolkit.js`, `session_actions.js`, `send_turn.js`, `composer_mount.js`, `dock_reserve.js`,
50
52
  `vendor/lit-html.js`) needs a DOM: `import "./dom_shim.js";` as its first import (happy-dom, a devDependency only,
51
53
  never shipped; lit reads `document` as it loads). The shim's `markupOf(template)`,
52
54
  `htmlOf(nodes)` and `canon(html)` compare markup without lit's marker comments; the DOM writes text with only `&`, `<`, `>`
53
55
  escaped (a `"` in text stays `"`) and an attribute with `&` and `"` escaped. happy-dom 20 picks the wrong option of a `<select>` whose options lit inserted with
54
56
  `?selected`: read the option marked `selected` (`option[selected]`), not `select.value`; the browser follows the mark.
55
- A select bound with `.value` instead reads right in both, but lit sets it before the options exist on a first render:
56
- `llm_context_chip.js`'s `renderForm` draws twice then. Assert a kept node with `assert.ok(a === b)`: a failing
57
+ A select bound with `.value` instead reads right in both, but lit sets it before the options exist on a first render
58
+ (draw the options alone first, then the value). Assert a kept node with `assert.ok(a === b)`: a failing
57
59
  `assert.equal` on two DOM nodes inspects the whole DOM (huge output, can stall the run).
58
60
 
59
61
  `npm test` runs every file in one process (`--test-isolation=none`; CI and local runs use Node 24): the files share
@@ -91,7 +93,10 @@ Each level owns its own share; a detail is asserted once, at the lowest level th
91
93
  per example, as the wiring keeps one per turn.
92
94
  - **e2e** is for real-browser behaviour (layout, focus, reload, two tabs), one happy path per Ruby↔web mechanism and
93
95
  a few journeys. Before adding a test, extend an existing journey's step; a variant goes to node, a Ruby↔web shape
94
- variant to a shared fixture (`spec/fixtures/composer_routes.json`).
96
+ variant to a shared fixture (`spec/fixtures/composer_routes.json`). A journey names its stages with `test.step`
97
+ (the report names the one that failed: web.spec.js's notifications). The `lan` project's chi web is on the LAN only
98
+ when the machine has a private IPv4: without one its LAN tests skip and the participant review journey runs on
99
+ loopback.
95
100
 
96
101
  ## Waiting and time
97
102
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.135.0
3
+ version: 0.136.1
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.135.0"
4
+ VERSION = "0.136.1"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1289,7 +1289,7 @@ module Samagotchi
1289
1289
  nil
1290
1290
  end
1291
1291
 
1292
- # The LLM context +model+ starts under (the start page's llm ctx chip);
1292
+ # The LLM context +model+ starts under (the start page's ⚙ panel);
1293
1293
  # one that can't be read leaves this model without, not the others.
1294
1294
  def add_llm_context(model, target, names, settings)
1295
1295
  model[:llm_context] = LLMContextStrategy.explain(target, names: names, models: settings).summary(nil)
@@ -1298,7 +1298,7 @@ module Samagotchi
1298
1298
  end
1299
1299
 
1300
1300
  # The thinking level +model+ starts at, with where it came from (the
1301
- # start page's think chip; SettingExplained#summary, no session).
1301
+ # start page's ⚙ panel; SettingExplained#summary, no session).
1302
1302
  def add_thinking(model, target, names, settings)
1303
1303
  model[:thinking] = Thinking.explain(target, names: names, models: settings).summary
1304
1304
  rescue StandardError
@@ -1473,7 +1473,7 @@ module Samagotchi
1473
1473
  # set when it seeds a session's and when the expanded view opens (a
1474
1474
  # file changed while the session sat idle). A live worker's snapshot
1475
1475
  # messages are the truth, as #handle_show's, else the file's; the
1476
- # strategy is the session's llm ctx chip's, the folder its working
1476
+ # strategy is the session's llm_context summary's, the folder its working
1477
1477
  # directory. running: a turn runs. The snapshot's messages lag it, so
1478
1478
  # the answer is then the turn's last :working_set_updated (the
1479
1479
  # snapshot's working_set, TurnAccumulator#working_set); before the
@@ -1506,7 +1506,7 @@ module Samagotchi
1506
1506
  { items: set["items"], at: set["at"] }
1507
1507
  end
1508
1508
 
1509
- # The strategy an llm ctx chip names ("none", "stale,forget"), as
1509
+ # The strategy an llm_context summary names ("none", "stale,forget"), as
1510
1510
  # LLMContextView takes it; none when there is no chip.
1511
1511
  def working_set_strategy(llm_context)
1512
1512
  named = llm_context.is_a?(Hash) ? (llm_context[:strategy] || llm_context["strategy"]).to_s : ""
@@ -29,9 +29,19 @@ module Samagotchi
29
29
  # A POST /api/render body may be this big: a decision message is a few
30
30
  # KB, and the cap bounds the work a page can ask for.
31
31
  MAX_RENDER_BYTES = 64 * 1024
32
+ # A delegate report's leading "session: <id>" / "status: <s>" lines
33
+ # (Tools::DelegateWait.result).
34
+ REPORT_HEADER = /\A((?:(?:session|status): [^\n]*\n)+)/
32
35
 
33
36
  private
34
37
 
38
+ # A delegate report's text as the markdown it renders from: its header
39
+ # lines one to a line and a paragraph of their own, so a "---" after
40
+ # them is a rule, not a heading's underline.
41
+ def report_markdown(text)
42
+ text.sub(REPORT_HEADER) { "#{Regexp.last_match(1).lines.map(&:chomp).join(" \n")}\n\n" }
43
+ end
44
+
35
45
  # The recap saved with the session and how many user turns came since:
36
46
  # a live worker's (it counts against its Engine's messages), else
37
47
  # recap.json against the session file. A stopped session gets its
@@ -132,6 +142,12 @@ module Samagotchi
132
142
  message.merge!(merged: true, step: steer_step(list, index)) unless Samagotchi::Steer.turn_prompt?(m)
133
143
  source = (m[:source] || m["source"]).to_s
134
144
  message[:source] = source unless source.empty?
145
+ # A delegate report is the child's markdown: rendered as an
146
+ # answer is (its copy source stays the text). Another user line
147
+ # stays plain.
148
+ if source == Samagotchi::Steer::DELEGATE_REPORT && @markdown_renderer.available?
149
+ message[:html] = @markdown_renderer.render(report_markdown(stripped))
150
+ end
135
151
  end
136
152
  # The turn it started (Engine): the page pairs it with that turn's
137
153
  # record by this, not by its place.