samagotchi 0.19.0 → 0.21.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +130 -1
  3. data/docs/architecture.md +8 -1
  4. data/docs/cli.md +16 -4
  5. data/docs/configuration.md +4 -1
  6. data/docs/guardrails.md +92 -14
  7. data/docs/plugins.md +27 -8
  8. data/docs/sessions.md +3 -3
  9. data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
  10. data/lib/samagotchi/bootstrap_command.rb +26 -5
  11. data/lib/samagotchi/bridge/sse_writer.rb +32 -0
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
  13. data/lib/samagotchi/bridge.rb +16 -4
  14. data/lib/samagotchi/bridge_client.rb +33 -9
  15. data/lib/samagotchi/bundle_command.rb +28 -4
  16. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
  18. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
  19. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  20. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
  22. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
  24. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  26. data/lib/samagotchi/commands/registry.rb +4 -3
  27. data/lib/samagotchi/config.rb +3 -0
  28. data/lib/samagotchi/context_usage.rb +3 -3
  29. data/lib/samagotchi/engine.rb +31 -4
  30. data/lib/samagotchi/events.rb +1 -1
  31. data/lib/samagotchi/guardrail_wiring.rb +2 -1
  32. data/lib/samagotchi/guardrails/approval.rb +4 -3
  33. data/lib/samagotchi/guardrails/approvals.rb +11 -5
  34. data/lib/samagotchi/guardrails/context.rb +16 -9
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
  36. data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
  37. data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
  38. data/lib/samagotchi/guardrails/rules.rb +69 -5
  39. data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
  40. data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
  41. data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
  42. data/lib/samagotchi/guardrails/targets.rb +65 -4
  43. data/lib/samagotchi/guardrails.rb +2 -0
  44. data/lib/samagotchi/hooks/registry.rb +6 -2
  45. data/lib/samagotchi/idle_recap.rb +7 -2
  46. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  47. data/lib/samagotchi/llm/openai_chat.rb +19 -5
  48. data/lib/samagotchi/llm/usage.rb +1 -1
  49. data/lib/samagotchi/log_subscriber.rb +4 -2
  50. data/lib/samagotchi/memory_bundle/builder.rb +1 -5
  51. data/lib/samagotchi/memory_bundle/installer.rb +46 -13
  52. data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
  53. data/lib/samagotchi/memory_bundle/status.rb +12 -4
  54. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -5
  55. data/lib/samagotchi/memory_paths.rb +6 -0
  56. data/lib/samagotchi/self_report.rb +38 -2
  57. data/lib/samagotchi/session.rb +35 -6
  58. data/lib/samagotchi/session_commands.rb +13 -2
  59. data/lib/samagotchi/session_manager.rb +26 -9
  60. data/lib/samagotchi/session_metrics.rb +230 -23
  61. data/lib/samagotchi/steer.rb +1 -1
  62. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
  63. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
  64. data/lib/samagotchi/terminal_ui/formatting.rb +59 -3
  65. data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
  66. data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
  67. data/lib/samagotchi/token_usage.rb +57 -41
  68. data/lib/samagotchi/tool_runner.rb +21 -2
  69. data/lib/samagotchi/tools/execute.rb +9 -8
  70. data/lib/samagotchi/version.rb +1 -1
  71. data/lib/samagotchi/web/app.rb +19 -0
  72. data/lib/samagotchi/web/public/app.js +68 -22
  73. data/lib/samagotchi/web/public/card.js +34 -2
  74. data/lib/samagotchi/web/public/ctx.js +59 -0
  75. data/lib/samagotchi/web/public/format.js +11 -0
  76. data/lib/samagotchi/web/public/index.html +33 -2
  77. data/lib/samagotchi/web/public/model_pick.js +6 -2
  78. data/lib/samagotchi/web/public/model_picker.js +2 -1
  79. data/lib/samagotchi/web/public/notifications.js +8 -3
  80. data/lib/samagotchi/web/public/notify.js +49 -7
  81. data/lib/samagotchi/web/public/question_card.js +2 -1
  82. data/lib/samagotchi/web/public/timing.js +11 -5
  83. data/lib/samagotchi/web/public/turn_events.js +17 -1
  84. data/lib/samagotchi/web/session_summary.rb +6 -2
  85. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b6bb51634467b04d3519fc2c42a778be2db25558565e8e872bc2fbdc17f5c76
4
- data.tar.gz: 9f1e4b1bd71272126a3dea346b2b0f5125a608770a97832b17c42ec4a237156b
3
+ metadata.gz: f9d794c2749a0f1293cb16d7afeb7e023f38dd8798532e4dcb2d0e9fcb575af1
4
+ data.tar.gz: 8ade3692c344214cc8cdbe90c1fb8539242639f7cbf8707c751a8d2b02f9548a
5
5
  SHA512:
6
- metadata.gz: b1b2eb7e4142061cce070830722bc88625cfc8b8301b97e836751ac9690d0e78984741afd747a6cc9ac42be6a0b59122bc49075273522629eda56f22327999d9
7
- data.tar.gz: 6277d5a520c55f5b620f79936321c2536739409ef98a5a7b530e41505a2ffb17bd0bf04987fc0bf027c09a24dbedbea29f61affca2bf7f3a74218bbcf8651df8
6
+ metadata.gz: b3eb054e3b0dd3ded63ef8de4bfff37d8d36971dbe02107ac2e30114fadc12563807d4c7bdc4d3fdf8b801a7bd8250fcb5e73a547604b0feba7382abfdff0ee7
7
+ data.tar.gz: 168c3cfef2a37c4f5e3f6e2782b614a546f8307793e41af0efe200b2afa8d100b725889e5bb8fcf520fa7c2a0be18faa4cedb10a9ddf573bedac162a1404f133
data/CHANGELOG.md CHANGED
@@ -8,6 +8,133 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.21.0] - 2026-10-04
12
+
13
+ ### Added
14
+
15
+ - The debug log's generation_completed line names the OpenRouter provider that served it (provider=).
16
+
17
+ ### Fixed
18
+
19
+ - loop-guard cuts thinking that loops in short sentences ("I'll write it. Go. OK. Writing."),
20
+ which it skipped before: such a loop ran for minutes to the provider's output cap. New settings
21
+ `thinking.short_run` (24) and `thinking.short_distinct` (6).
22
+ - loop-guard's thinking watch no longer goes blind for the rest of a generation after an empty thinking
23
+ chunk (a FrozenError, logged as `plugin_hook_failed`), so it now sees every generation's thinking.
24
+ - loop-guard no longer stops a whole turn for a thinking loop that comes long after the model recovered from
25
+ its first one: after `thinking.forget_after` (10) steps with no loop, the next loop is cut and retried again.
26
+ - A turn a plugin stopped now says who did it, `✕ turn stopped by loop-guard` (the web: `✕ stopped by
27
+ loop-guard`), instead of `✕ canceled (by a hook)`, also after a reload of the web page.
28
+ - An attached `/exit` the worker reads only after the TUI stopped waiting (a frozen or sleeping worker) no
29
+ longer stops it later: the request is dropped, as late turns and commands are.
30
+ - The attached `/exit` (and `/exit --delete`, `/archive`) in an empty session no longer claims the session is
31
+ already gone: a note arriving before the worker stops keeps it, so it says it will be discarded if nothing
32
+ arrives.
33
+ - Archiving a session (`/archive`, `chi sessions archive`, the web) with a prompt still queued or a step-limit
34
+ question open is refused with a message saying so, instead of stopping the worker and leaving the queued
35
+ prompt to run on the next resume.
36
+ - `/exit` right after a `/detach` and `chi --attach` no longer says "another UI is attached" and leaves the worker
37
+ up: the worker notices a closed stream at once instead of at its next 15-second heartbeat.
38
+ - Leaving chi's terminal UI (`/exit`, `/detach`, Ctrl-D) no longer can leave a stray cursor report such as
39
+ `^[[12;1R` at the shell prompt: input nobody read is dropped before the terminal is handed back.
40
+ - Joining a running turn (web or `chi --attach`) while the model is holding before its first token now shows
41
+ the step it's on, as a live view does, instead of the previous step still live.
42
+ - Web: sending into a session whose worker was already up (but the page didn't know) no longer draws
43
+ that worker's earlier turns a second time: the page re-reads the session instead.
44
+ - Web without the session events stream (fetched list): a re-fetched list now badges and notifies like the
45
+ live list does.
46
+ - Web: a session deleted while the page had lost the session events stream (or between two fetches of the
47
+ list) no longer keeps its question or card counted in the tab title's badge until the tab comes to front.
48
+ - Web on a phone: the session footer's archive, stop and delete buttons are icons (the words stay as their
49
+ accessible names and tooltips), so the status and `mem N` chips beside them are in sight again.
50
+ - Web: with several chi tabs in the background, a session that needs you shows one OS notification, not one
51
+ per tab (every tab still counts it in its title).
52
+ - chi web: a session stream whose worker took the connection and froze before answering no longer holds the
53
+ request forever; it gives up after 5 s per attempt.
54
+
55
+ Update with `chi update`: loop-guard moves to 0.3.1 (short-sentence loops, forget_after, the blind-watch fix). Restart `chi web` and running sessions afterwards (`chi sessions restart ID`).
56
+
57
+ ## [0.20.0] - 2026-10-03
58
+
59
+ ### Added
60
+
61
+ - `guardrails.mode` (config.yml only): `auto`, the new default, or `strict`. Rules can be tagged `modes: [strict]`
62
+ to vote only in strict mode; a rule without `modes:` (every rule you wrote in config.yml) votes in both.
63
+ `/guardrails` shows the mode and marks the rules it leaves out `(strict only)`. See docs/guardrails.md#modes.
64
+ - New guardrail rule keys: `skip_read_only: true` (a shell command that only reads, such as `ls`, `cat`, `rg`,
65
+ `sed -n` or `git log`, doesn't match), `touches: chi_dirs` (a shell command names a path in chi's real config,
66
+ hooks, approvals or bundles dir, or a `.git/hooks`) and `rm: outside_tmp` (an `rm -rf` reaches outside the tmp
67
+ dirs). A config.yml rule that uses one fails closed on an older chi.
68
+ - Model speed and token stats. `/stats` has a speed line (the last generation's decode speed and the
69
+ session's average: exact from llama.cpp, estimated and marked `~` elsewhere), cached and reasoning
70
+ tokens, and the cost when the provider reports it (OpenRouter). The web's info bar shows the speed
71
+ next to `ctx`, updated after each generation, and the `ctx` tooltip there and on session cards lists
72
+ tokens in / cached / out (+ reasoning) and the cost, for that session only.
73
+
74
+ ### Changed
75
+
76
+ - [BEHAVIOUR] Guardrails ask less by default (auto mode). The guardrails bundle (0.5.0) no longer asks before
77
+ `git rebase`, writes outside the session's repo (`write-outside-repo`) or git in another checkout
78
+ (`git-outside-repo`); `guardrails: { mode: strict }` in config.yml brings those back. Everything else still asks
79
+ in both modes, as do the small-model rules and chi's own protected paths.
80
+ - [BEHAVIOUR] `shell-touches-chi` matches chi's real folders instead of text, and lets read-only commands through:
81
+ `ls ~/.config/samagotchi`, `rg samagotchi/hooks lib` and a scratch copy under `/tmp` no longer ask; `echo x >>
82
+ <config dir>/config.yml` and `cp hook .git/hooks/pre-commit` still do. A word it can't resolve falls back to the
83
+ old text match.
84
+ - [BEHAVIOUR] `rm-rf-wide` no longer asks when every `rm -rf` target is inside a tmp dir (`rm -rf /tmp/x`);
85
+ `/tmp` itself, `/tmp/*`, a `$VAR` and a link out of tmp still ask.
86
+ - [BEHAVIOUR] "Allow … in this repo" approvals hold for the whole repository, in every worktree of it, and the
87
+ ask names the repository (`in this repo (samagotchi)`) instead of the worktree folder. Approvals stored
88
+ before keep matching their exact folder.
89
+ - `chi bundle status` shows a bundle whose recorded scope this chi doesn't know (one a newer chi wrote, or a hand
90
+ edit) as `scope=team (unknown)`, counted as one issue, instead of checking its files in the system memories;
91
+ the shipped-bundle update skips it (upgrade chi or reinstall), and `chi bundle diff` refuses it with a one-line
92
+ error instead of a stack trace.
93
+ - The web model picker shows a model's configured sampling (`temperature=0.6 (hosts.work)`) as its row's tooltip;
94
+ `GET /api/models` has it as `sampling` on the models that have some.
95
+ - The web shows a plugin card that is only a notice (info level, no buttons, one short line, such as check-in's
96
+ "Nudged the model at 105 tool calls.") as a one-line row, `▸ check-in: Nudged …`, that opens on a click, like the
97
+ tool rows and resolved questions; warn cards, cards with buttons and longer ones stay framed cards.
98
+ - On a phone (a window under 900 px wide) the sessions strip's `hide` is in the top bar, where the `▾ N sessions`
99
+ pill comes back, with a finger-sized target, instead of the last column of the sideways-scrolling strip.
100
+ - On a phone the web info bar shows `mem N` (the memories the session used; their names in the tooltip) after the
101
+ session's status, since the `mem: …` line is hidden there.
102
+ - The web info bar's chips (model, ctx, speed, session time) are tinted over the card's glass instead of solid, and
103
+ the empty-history cover is see-through like the session panel.
104
+
105
+ ### Fixed
106
+
107
+ - An MCP server that exits without a wait status no longer raises `NoMethodError`; its waiting calls fail with
108
+ "the server exited" instead of hanging.
109
+ - A hook that raises is now logged (`hook_failed`) instead of being swallowed silently; the turn still goes on.
110
+ - A hook's card (check-in, skills) now prints under its tool call's row: `tool_call_completed` is emitted before
111
+ `after_tool_call` fires.
112
+ - When a hook corrects a tool call (known-names), the model is told what actually ran with a leading
113
+ `ran as: <tool> <args>` line.
114
+ - MCP image content is described with a neutral `[image N: …]` line instead of claiming it was attached, which
115
+ was wrong for a model that can't see images.
116
+ - An MCP `resource` block carrying an image `blob` is now attached like an `image` block instead of being
117
+ dropped to its URI text.
118
+ - check-in's `nudge` mode no longer says "nudged" when the steer was dropped later; it says "nudge not sent" at
119
+ the turn's end.
120
+ - A session started with an annotation (a web quote) is previewed by the note typed under the quote, not
121
+ `From your thinking: > …`; a quote with no note previews by its words.
122
+ - A session that has only context notes so far (`chi note`, `send_note`) is previewed on its card and in the
123
+ lists as `note: …` instead of `—`, until a message is typed.
124
+ - A web turn of images alone names each image once in the transcript a recap is written from, not twice.
125
+ - `chi bootstrap` no longer says "loading the model?" for a slow test on a remote provider (OpenRouter and the
126
+ like); the hint is for a local server, where a slow answer is a model loading.
127
+ - `chi bootstrap` says why it saved a host as `name-2` when the derived name is already taken (or a model id
128
+ starts with it), instead of silently picking another name.
129
+ - `chi bundle upgrade` reports a hook or plugin file that is byte-identical to the installed one as "already up
130
+ to date" instead of "Updated".
131
+ - `chi bundle upgrade --agent` from a git/zip/tar source no longer hands the agent an `incoming:` path that was
132
+ already deleted: the extracted source is kept until the agent step is done, then cleaned up.
133
+ - `chi self` now checks a local `api: openai` host's reachability (a `GET <base>/models` with the same short
134
+ probe timeouts) and reports up/down with the ids it serves, instead of always saying "reported per turn".
135
+
136
+ Update with `chi update`: guardrails moves to 0.5.0 (auto mode and the new rule keys; it needs chi 0.20.0), mcp to 0.4.1 (exit status, image lines, image blobs) and check-in to 0.2.1 (a dropped nudge says so). Restart `chi web` and running sessions afterwards (`chi sessions restart ID`).
137
+
11
138
  ## [0.19.0] - 2026-10-03
12
139
 
13
140
  ### Changed
@@ -1118,7 +1245,9 @@ and long-lived sessions.
1118
1245
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
1119
1246
  hotkey panel that send selected text or the clipboard to your sessions.
1120
1247
 
1121
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.19.0...HEAD
1248
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.21.0...HEAD
1249
+ [0.21.0]: https://github.com/dm1try/samagotchi/compare/v0.20.0...v0.21.0
1250
+ [0.20.0]: https://github.com/dm1try/samagotchi/compare/v0.19.0...v0.20.0
1122
1251
  [0.19.0]: https://github.com/dm1try/samagotchi/compare/v0.18.1...v0.19.0
1123
1252
  [0.18.1]: https://github.com/dm1try/samagotchi/compare/v0.18.0...v0.18.1
1124
1253
  [0.18.0]: https://github.com/dm1try/samagotchi/compare/v0.17.0...v0.18.0
data/docs/architecture.md CHANGED
@@ -170,7 +170,8 @@ higher-level events so UIs get clean turn boundaries without inferring them:
170
170
 
171
171
  - `:turn_started` — `{ session_id:, prompt: }`
172
172
  - `:turn_completed` — `{ result: }` (the final `LLM::ModelResult`)
173
- - `:turn_canceled` — `{ cancellation_reason: }`
173
+ - `:turn_canceled` — `{ cancellation_reason:, cancelled_by: }` (`cancelled_by`: the bundle or hook label that
174
+ stopped it, for `cancellation_reason: :hook`; else nil)
174
175
 
175
176
  Every event is a `Hash` with a `:type` symbol key; the sink must not raise (the Engine
176
177
  rescues sink errors). A new UI (web, API) supplies its own `on_event` and
@@ -274,6 +275,12 @@ The metrics in `/state`, `/snapshot`, `/stats` and the SSE `snapshot`/`reset` fr
274
275
  newest turn the worker finished and any it hasn't saved yet (at most 20 turns), with their tool
275
276
  calls and the running turn's finished ones. The full history is the session's `analytics.json`,
276
277
  which `SessionMetrics#persist` rewrites after each turn; the web server merges the two by id.
278
+ The totals' `tokens` block adds the cached and reasoning sums, the cost, the decode time and tokens
279
+ behind the average speed (`avg_decode_tps`) and the last speeds (`last_decode_tps`,
280
+ `last_prefill_tps`, `tps_source`: `server` or `estimate`). Each `generation_completed` event
281
+ carries the generation's `speed` (`{decode_tps, source}`, null without one) and those running
282
+ `tokens`: the Engine closes the generation in `SessionMetrics#finish_generation` before relaying
283
+ it, so the web's info bar updates per generation without parsing chunk payloads.
277
284
 
278
285
  The page's frequent reads of `GET /api/sessions/:id` stay light too:
279
286
  - `?tail=1&recent=1` (each turn's end, a cancel, an answer display): `{tail, session: {id, status,
data/docs/cli.md CHANGED
@@ -53,7 +53,9 @@ chi bootstrap # try localhost 8080, 11434, 1234, 8000
53
53
  - **The file.** With no config.yml it writes a small commented one:
54
54
  `default.model` as `<host>:<model>` and one `hosts:` entry named `local`
55
55
  (localhost), `lan` (an IP) or after the domain (`openrouter`); `--name`
56
- sets it. An existing file is left as it is apart from the new entry, added
56
+ sets it. When that name is taken (or a model id starts with it), chi picks
57
+ `name-2` and says why, with `--name` to choose another. An existing file is
58
+ left as it is apart from the new entry, added
57
59
  at the end of its `hosts:` block (it gets a `hosts:` block, with a
58
60
  `default` entry for its `server:` first, when it has none), after a backup
59
61
  to `config.yml.bak-<time>`. `default.model` is set only when the file has
@@ -413,8 +415,17 @@ file and index line stay as they are; only this session doesn't see it.
413
415
  `/help` lists the commands this session takes, the installed bundles' too, each
414
416
  with a line on what it does. `/stats` shows the session's numbers: turns, tool
415
417
  calls (by tool, with errors), iterations, tokens in/out summed over every
416
- request, generation latency, cancellations, retries, context used and window,
417
- the prompt profile and the model the server says it ran. `/recap` is in
418
+ request (with the cached and reasoning tokens when the server reports them),
419
+ the decode speed, generation latency, cancellations, retries, context used
420
+ and window, the prompt profile and the model the server says it ran, and the
421
+ cost when the provider reports one (OpenRouter's `usage.cost`; no prices are
422
+ guessed). The speed line is the last generation's and the session's average
423
+ (weighted by tokens): llama.cpp reports it exactly (`87 tok/s out, 1.9k tok/s
424
+ prompt (last, server)`); for other servers it is estimated from the first
425
+ streamed chunk to the end and marked `~` (`~64 tok/s out (last, estimate)`).
426
+ Generations under 8 tokens or 100 ms get no speed of their own, nor does a
427
+ reply without server token counts or a non-streamed one. A delegated child
428
+ keeps its own numbers: a parent's tokens and cost leave them out. `/recap` is in
418
429
  [Session recap](#session-recap), `/model` and `/models` in [Runtime Model
419
430
  Switch](#runtime-model-switch-assist-mode).
420
431
 
@@ -833,7 +844,8 @@ Behavior notes:
833
844
  `partial progress kept; !rollback restores the pre-turn state` under it (a canceled continue is back where it
834
845
  started). A failed turn ends with `✕ turn failed: <summary> · 2.0s` and a dim `prompt restored for retry`. The
835
846
  REPL and attached mode say the same; the web says `✕ canceled (Ctrl-C)` (`stopped` for its Stop button,
836
- `by a hook` for a hook's).
847
+ `by a hook` for a hook's). A turn a bundle's plugin stopped names it: `✕ turn stopped by loop-guard` (the web:
848
+ `✕ stopped by loop-guard`).
837
849
  - Visible text the canceled request had streamed stays in the conversation, marked `[interrupted]`, so the next
838
850
  message (or a continue) picks up from the half-finished reply; the canceled request's thinking and any unfinished
839
851
  tool call are dropped.
@@ -243,7 +243,9 @@ or ending in `localhost`, `.local`, `.lan`, `.home`, `.home.arpa`, `.internal`,
243
243
  or `.ts.net` (Tailscale) is local: chi doesn't look names up. A remote
244
244
  host keeps its model list for 10 minutes, gets a 120-second first-token limit,
245
245
  and isn't asked for llama.cpp's `/props` (its context window and served model
246
- come from its model list and each turn). `remote: true` or `remote: false` on a
246
+ come from its model list and each turn). A local `api: openai` host has no
247
+ `/props`, so `chi self` GETs its `/models` with the same short probe timeouts
248
+ and reports up/down with the ids it serves. `remote: true` or `remote: false` on a
247
249
  host decides it instead, e.g. for a llama.cpp behind an https proxy on the LAN:
248
250
 
249
251
  ```yaml
@@ -852,6 +854,7 @@ described in their own sections.
852
854
  | `image.max_bytes` | `3750000` | | See "Images". |
853
855
  | `image.max_per_request` | `20` | | See "Images". |
854
856
  | `guardrails.enabled` | `true` | | `false`: no rules, and hooks' asks are dropped (a deny still applies). config.yml only: a worker unsets any `SAMAGOTCHI_GUARDRAILS_*` it inherits. See [Guardrails](guardrails.md). |
857
+ | `guardrails.mode` | `auto` | | Which rules vote: `auto` leaves out the rules tagged `modes: [strict]` (in the guardrails bundle: `git rebase`, writes and git outside the session's repo); `strict` runs them all. An unknown value warns and is `auto`. config.yml only. See [Guardrails](guardrails.md#modes). |
855
858
  | `guardrails.small_models` | `auto` | | Which models get the `models: small` rules: `auto` (32B or less by the size in the name, an MoE's active size), a list of globs, or `[]`. config.yml only. See [Guardrails](guardrails.md#rules-for-some-models). |
856
859
  | `guardrails.parent_approvals` | `off` | | What `chi answer` lets a parent agent allow on an approval: `off` (deny only) or `once` ("Allow once", never a wider scope). config.yml only: no environment variable (a parent can still pick the config dir with `XDG_CONFIG_HOME`). A convention for an honest parent, not a security boundary. See [Guardrails](guardrails.md#approvals-from-a-parent-agent). |
857
860
  | `log.file` | state dir | yes | See "Debug Log File". |
data/docs/guardrails.md CHANGED
@@ -81,6 +81,13 @@ Allowing beyond "once" is stored in `$XDG_STATE_HOME/samagotchi/guardrails/appro
81
81
  | repo | this exact call in this repo (the cwd outside a repo), any session |
82
82
  | rule | anything this rule asks about in this repo |
83
83
 
84
+ "This repo" is the git repository, not the folder: an approval given in one
85
+ worktree holds in the main checkout and every other worktree of it (it is
86
+ stored with the repository's common git dir as `repo`, and the worktree as
87
+ `repo_root`). The ask names the repository (`Allow this call in this repo
88
+ (samagotchi)`). An entry stored before chi 0.20 has no `repo` and still only
89
+ matches its exact folder.
90
+
84
91
  A stored approval only relaxes an ask; a deny rule is never approvable.
85
92
  A file that doesn't parse is moved aside to `approvals.json.corrupt-<UTC time>`
86
93
  with one warning, and chi starts with no stored approvals (more asks, nothing lost).
@@ -137,7 +144,7 @@ grants no permission, so a parent may answer it either way by default.
137
144
  `turn.parent_continue: false` (config.yml only, like `parent_approvals`)
138
145
  makes parents stop-only. See [chi as a sub-agent](sub-agent.md#the-step-limit).
139
146
 
140
- The guardrails settings (`enabled`, `small_models`, `parent_approvals`) are
147
+ The guardrails settings (`enabled`, `mode`, `small_models`, `parent_approvals`) are
141
148
  read from config.yml only: they have no environment variable, and a worker
142
149
  chi starts unsets every `SAMAGOTCHI_GUARDRAILS_*` it would inherit. What a
143
150
  parent's environment does choose is the config dir: `XDG_CONFIG_HOME` picks
@@ -182,6 +189,7 @@ commands that name them.
182
189
  ```yaml
183
190
  guardrails:
184
191
  enabled: true # false: no rules, and hooks' asks are dropped (a deny still applies); config.yml only
192
+ mode: auto # or strict: also the rules tagged modes: [strict]; config.yml only
185
193
  rules:
186
194
  - id: git-push
187
195
  tool: shell # execute + task_create; or a tool name, a glob, or a list
@@ -201,7 +209,41 @@ guardrails:
201
209
  scopes: [once, session, rule]
202
210
  ```
203
211
 
204
- A rule gives at least one of `tool`, `command`, `path` and `git`.
212
+ A rule gives at least one of `tool`, `command`, `path`, `git`, `touches` and `rm`.
213
+
214
+ `rm: outside_tmp` (shell tools) matches a command whose `rm -r -f` reaches
215
+ outside the tmp folders (`$TMPDIR`, `/tmp`, `/var/tmp`): every such `rm` in a
216
+ chain must name only paths inside one, resolved like `touches` (so
217
+ `/tmp/../etc` and a link from `/tmp` to `/` are outside). The tmp folder
218
+ itself, a glob (`/tmp/*`), a `$VAR` other than `$HOME`/`$TMPDIR`, an `rm`
219
+ behind `sudo`/`xargs`/`sh -c` or in `$(…)`, and the tmp folder the session's
220
+ repo is in count as outside.
221
+
222
+ `touches: chi_dirs` (shell tools) matches a command that names a path in chi's
223
+ own folders: the config folder (`config.yml`, `memories/`), the hooks folder,
224
+ the approval store, installed bundles (`memories/.bundles`), the session repo's
225
+ git hooks (`core.hooksPath` honoured) or any `.git/hooks` outside a tmp folder.
226
+ Each word is read as a path against the folder the command is in by then
227
+ (`cd`, `pushd`/`popd` and `( … )` followed), with `~`, `$HOME` and
228
+ `$XDG_CONFIG_HOME`/`$XDG_STATE_HOME` expanded and symlinks resolved. So
229
+ `/tmp/x/config/samagotchi/config.yml` and chi's own source
230
+ (`lib/samagotchi/hooks/…`) don't match. A word it can't resolve (another
231
+ `$VAR`, `$(…)`, a glob, a relative path after `cd $X`) or one that reads as a
232
+ script (`sh -c '…'`, `ruby -e '…'`) is matched as text instead (`.config/samagotchi`,
233
+ `samagotchi/config.yml`, `samagotchi/hooks`, `samagotchi/guardrails`,
234
+ `memories/.bundles`, `.git/hooks`).
235
+
236
+ `skip_read_only: true` lets a shell command through when it only reads:
237
+ every command in it (split at `;`, `&&`, `||`, `|`) is `ls`, `cat`, `head`,
238
+ `tail`, `wc`, `stat`, `du`, `grep`, `rg`, `find`, `tree`, `file`, `sort`,
239
+ `uniq`, `cut`, `diff`, `echo`, `cd`, `sed`, `awk` or a reading `git`
240
+ (`log`, `show`, `diff`, `status`, `branch` listing, …), with no option that
241
+ writes or runs something (`sed -i`, `sed 's/a/b/w f'`, `find -delete`/`-exec`,
242
+ `rg --pre`, `sort -o`, `awk` with `>`/`|`/`system(`, `git -c`, `git diff
243
+ --ext-diff`, …) and no redirection other than to `/dev/null` or another fd.
244
+ `$(…)`, backticks, `$VAR` (other than `$HOME`, `$XDG_*`, `$TMPDIR` and `$PWD`
245
+ at a word's start), an env prefix, `sh -c`, `xargs`, `eval`, `sudo` and the
246
+ like make a command not read-only. It errs towards asking.
205
247
 
206
248
  A tool name may be a glob, so one rule covers a plugin's tools (an MCP server's,
207
249
  say): `tool: "mcp_*"` or `tool: ["mcp_{git,gh}_*", web_fetch]` (`*`, `?`, `[…]` and
@@ -303,6 +345,38 @@ once per bundle version to restart the session (`chi sessions stop ID`, then
303
345
  older than the bundle) denies every tool call, as for a rule that doesn't
304
346
  parse, until chi is updated or the bundle uninstalled.
305
347
 
348
+ ### Modes
349
+
350
+ `guardrails.mode` (config.yml only) picks how much is asked:
351
+
352
+ - `auto`, the default: the rules tagged `modes: [strict]` don't vote. Asks
353
+ are kept for what is hard to undo or reaches chi itself.
354
+ - `strict`: every rule votes.
355
+
356
+ ```yaml
357
+ guardrails:
358
+ mode: strict
359
+ rules:
360
+ - id: no-docker
361
+ tool: shell
362
+ command: '\bdocker\b'
363
+ modes: [strict] # or auto, or [auto, strict] (the same as leaving it out)
364
+ verdict: ask
365
+ ```
366
+
367
+ A rule without `modes:` votes in every mode, so a rule you add to config.yml
368
+ always applies unless you tag it. The core checks (protected paths, hooks)
369
+ vote in both modes. In the guardrails bundle, `git-rebase`,
370
+ `write-outside-repo` and `git-outside-repo` are strict only; its small-model
371
+ rules (`models: small`) are untagged, so they ask a small model in both modes.
372
+ `/guardrails` shows the mode on its first lines and marks the rules this mode
373
+ leaves out `(strict only)`.
374
+
375
+ A rule in config.yml that uses a key this chi doesn't know yet (`modes:`,
376
+ `touches:`, `rm:`, `skip_read_only:` before 0.20) doesn't parse, and a rule
377
+ that doesn't parse denies every tool call (see Failing closed). Bundles say
378
+ which chi they need (`requires_chi`), so their rules don't hit this.
379
+
306
380
  ## The guardrails bundle
307
381
 
308
382
  ```sh
@@ -310,18 +384,22 @@ chi bundle install guardrails # chi bootstrap installs it, with the core prof
310
384
  ```
311
385
 
312
386
  installs a default rule set plus a short memory telling the model not to
313
- route around a deny. It asks before `git push`, `reset --hard`, `clean -f`,
314
- `branch -D`, `rebase`, `filter-branch`/`filter-repo`; `rm -rf` on `/`, `~`,
315
- `$HOME` or `..` paths; `curl … | sh` and `base64 -d … | sh`; writes outside the
316
- session's repo (`write-outside-repo`) and git that changes another checkout
317
- (`git-outside-repo`: `cd ../main && git commit`, `git -C ../main add .`; it
318
- offers once, this session and "rule in this repo", the last stored for later
319
- sessions in that repo); shell commands that name chi's config, hooks or guardrails or
320
- `.git/hooks`; and answering chi's questions around `chi answer`: `chi --attach`
321
- or `chi -p` with stdin from a pipe, a here-string or a file
322
- (`chi-answer-piped`), and `curl`/`wget` to a session's `/answer` route
323
- (`chi-answer-http`), both once or for the session. `chi answer` itself isn't
324
- asked about. It denies writes into `.git/hooks`. The rules are in
387
+ route around a deny. In both modes it asks before `git push`, `reset --hard`,
388
+ `clean -f`, `branch -D`, `filter-branch`/`filter-repo`; `rm -rf` on `/`, `~`,
389
+ `$HOME` or `..` paths, unless every target is inside a tmp folder; `curl … |
390
+ sh` and `base64 -d … | sh`; shell commands that name a path in chi's config,
391
+ hooks, approvals or bundles or a `.git/hooks` (`shell-touches-chi`, `touches:
392
+ chi_dirs`), unless they only read (`skip_read_only`); and answering chi's
393
+ questions around `chi answer`: `chi --attach` or `chi -p` with stdin from a
394
+ pipe, a here-string or a file (`chi-answer-piped`), and `curl`/`wget` to a
395
+ session's `/answer` route (`chi-answer-http`), both once or for the session.
396
+ `chi answer` itself isn't asked about. It denies writes into `.git/hooks`.
397
+
398
+ In strict mode (`guardrails: { mode: strict }`) it also asks before `git
399
+ rebase`, writes outside the session's repo (`write-outside-repo`) and git that
400
+ changes another checkout (`git-outside-repo`: `cd ../main && git commit`, `git
401
+ -C ../main add .`; it offers once, this session and "rule in this repo", the
402
+ last stored for later sessions in that repo). The rules are in
325
403
  `lib/samagotchi/bundles/guardrails/guardrails/rules.yml`.
326
404
 
327
405
  Small models (`models: small`, see above) get two more asks, in
data/docs/plugins.md CHANGED
@@ -524,6 +524,11 @@ ctx.card(id: id, title: "Build finished", body: "no warnings left") # replaces
524
524
  its line; the web shows a bubble only when the command answers with
525
525
  text or fails.
526
526
  - `level:` is `:info` or `:warn` (the warning colour).
527
+ - An `:info` card with no actions and a body of one short line (up to 160
528
+ characters) is a notice: the web shows it as a one-line row,
529
+ `▸ check-in: Nudged the model at 105 tool calls.`, that opens on a click,
530
+ like a resolved question. A warn card, one with actions and one with more
531
+ to read stay framed cards.
527
532
  - `id:` names an earlier card to replace. Without one a new id is made. The
528
533
  web updates the card in place; the terminal prints it again, marked
529
534
  `(updated)`. A card that waits for something (a model's answer) shows
@@ -826,16 +831,22 @@ minutes, until the provider's output cap ends it with no answer. loop-guard
826
831
  watches the thinking while it streams ([Watching the
827
832
  stream](hooks.md#watching-the-stream)) and cuts it:
828
833
 
829
- - The thinking is cut into sentences; short ones (under `min_words`) are
830
- skipped. Two sentences count as the same when their words and word pairs
831
- mostly match (`similarity`), so a reworded round ("Hmm, …", a synonym, a
832
- swapped clause) still counts.
834
+ - The thinking is cut into sentences. Two sentences of `min_words` or more
835
+ count as the same when their words and word pairs mostly match
836
+ (`similarity`), so a reworded round ("Hmm, …", a synonym, a swapped
837
+ clause) still counts.
833
838
  - A loop is a cycle of 1 to `max_period` sentences seen `repeats` times in a
834
839
  row, over at least `min_span_sentences` sentences and `min_span_chars`
835
840
  chars; or one sentence `max_same` times anywhere in the generation. One
836
841
  sentence over and over must be near-identical (`similarity` + 0.4), and a
837
842
  longer cycle must hold different sentences: a list of templated sentences
838
843
  ("Now I need to open the file <path> and …" for 14 paths) is no loop.
844
+ - Short sentences (under `min_words`) are a loop of their own when
845
+ `short_run` of them come in a row with at most `short_distinct` different
846
+ ones among them ("I'll write it. Go. OK. Writing. Go. OK."). Normal
847
+ thinking has short sentences too ("Hmm.", "Fine."), but spread out or all
848
+ different (code lines); a longer sentence ends the run, and lines inside a
849
+ ```` ``` ```` code block don't count.
839
850
  - Nothing triggers before `min_chars` of thinking. The watch sees a loop
840
851
  within one batch (2000 chars, or a second) of its third cycle.
841
852
 
@@ -846,11 +857,16 @@ What happens (`action: retry`, the default):
846
857
  `loop-guard> thinking repeats itself (3 sentences ×3, 4k chars, 8 s): cut`
847
858
  and `↻ cut by loop-guard, asking again (1/1)`.
848
859
  2. If the retry loops too, the turn is stopped ("stopped the turn: …", "✕
849
- turn canceled (hook)") with a card that quotes the repeated sentences.
860
+ turn stopped by loop-guard") with a card that quotes the repeated
861
+ sentences.
862
+ 3. A loop is forgotten after `forget_after` good steps in a row (generations
863
+ with no loop): a model that recovered and made progress gets its next
864
+ loop cut and retried again, not the turn stopped. `forget_after: 0`
865
+ never forgets: the turn's second loop stops it.
850
866
 
851
867
  The cut uses the `retry.empty_answer` budget: with `retry.empty_answer: 0`
852
- the first loop ends the turn as cancelled (hook), with the notice and no
853
- card. `action: stop` stops the turn at the first loop; `action: notify` only
868
+ the first loop ends the turn as stopped by loop-guard, with the notice and
869
+ no card. `action: stop` stops the turn at the first loop; `action: notify` only
854
870
  warns, once per generation.
855
871
 
856
872
  ```yaml
@@ -866,7 +882,10 @@ bundles:
866
882
  min_span_sentences: 6 # a loop spans at least this many sentences…
867
883
  min_span_chars: 600 # …and this many chars
868
884
  max_same: 8 # one sentence this many times in one generation is a loop
869
- min_words: 5 # shorter sentences are ignored
885
+ min_words: 5 # shorter sentences only count toward a short run
886
+ short_run: 24 # this many short sentences in a row…
887
+ short_distinct: 6 # …with at most this many different ones is a loop
888
+ forget_after: 10 # good steps in a row that forget a loop; 0: never
870
889
  ```
871
890
 
872
891
  Not watched:
data/docs/sessions.md CHANGED
@@ -15,7 +15,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
15
15
 
16
16
  **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up. The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, reminders, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
17
17
 
18
- **Empty sessions:** a session nothing happened in is deleted when it is left, so `chi` → `/exit` leaves no trace. Empty means: no messages, no turn tried (a failed turn leaves a `last_prompt` and keeps it), no memory used, no pending question, nothing in `input/`, `notes/` or `images/`, nothing in its dir beyond the worker's skeleton, mode `assist`, and the default model. A session on another model (`/model`, `--model`) counts as prepared and stays (`SessionManager.discardable?`). The worker checks as it idle-exits or leaves on a client's request and deletes it once its lock is free, checking again first. The attached `/exit` then says `Detached; the session was empty, so it is discarded.` The REPL (`--no-shared`) does the same at `/exit` or Ctrl-D. The retention sweep catches those killed first: empty sessions over an hour old with no live owner, whatever `DAYS`/`MAX`, and skeleton-only dirs with no `*.json`. `session.keep_empty: true` (env `SAMAGOTCHI_SESSION_KEEP_EMPTY`) turns all of it off.
18
+ **Empty sessions:** a session nothing happened in is deleted when it is left, so `chi` → `/exit` leaves no trace. Empty means: no messages, no turn tried (a failed turn leaves a `last_prompt` and keeps it), no memory used, no pending question, nothing in `input/`, `notes/` or `images/`, nothing in its dir beyond the worker's skeleton, mode `assist`, and the default model. A session on another model (`/model`, `--model`) counts as prepared and stays (`SessionManager.discardable?`). The worker checks as it idle-exits or leaves on a client's request and deletes it once its lock is free, checking again first. The attached `/exit` then says `Detached; the session is empty: it will be discarded if nothing arrives before its worker stops.` (a note or prompt landing first keeps it). The REPL (`--no-shared`) does the same at `/exit` or Ctrl-D. The retention sweep catches those killed first: empty sessions over an hour old with no live owner, whatever `DAYS`/`MAX`, and skeleton-only dirs with no `*.json`. `session.keep_empty: true` (env `SAMAGOTCHI_SESSION_KEEP_EMPTY`) turns all of it off.
19
19
 
20
20
  **Attached by default:** plain `chi` and `chi --resume ID` run their session in a background worker and attach to it (`session.shared`, default `true`; env `SAMAGOTCHI_SESSION_SHARED`). The worker runs in the session's `working_directory`, so `!commands` and tools see the directory the session was started in. `session.shared: false` keeps the in-process REPL, `--no-shared` does for one run; a session the REPL has open can't be attached ("close it there first"). See [CLI: Sharing a session](cli.md#sharing-a-session).
21
21
 
@@ -25,7 +25,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
25
25
 
26
26
  **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 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` (200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
27
27
 
28
- <a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered (the step-limit question too); a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn refuses it), and an empty session is discarded instead; `chi scratch` refuses it. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and "include archived" by the all-sessions search shows them, dimmed with an `archived` badge. The info bar marks a session whose live worker runs an older chi than the newest installed (`worker_version` in its summary): `chi 0.18.1 · Restart`, which asks to confirm and calls `POST /api/sessions/:id/restart` (a refusal's reason shows in a toast); a worker from before restarts shows `chi sessions stop ID` instead. The info bar reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
28
+ <a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a prompt queued in `input/` (it would run on the next resume) or a live worker's step-limit question still open, in it or a child (409 `busy` too), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered (the step-limit question too); a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn, a queued prompt or an open step-limit question refuses it), and an empty session is discarded instead; `chi scratch` refuses it. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and "include archived" by the all-sessions search shows them, dimmed with an `archived` badge. The info bar marks a session whose live worker runs an older chi than the newest installed (`worker_version` in its summary): `chi 0.18.1 · Restart`, which asks to confirm and calls `POST /api/sessions/:id/restart` (a refusal's reason shows in a toast); a worker from before restarts shows `chi sessions stop ID` instead. The info bar reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
29
29
 
30
30
  **Lazy sweep:** automatic prune runs only while `chi web` runs, at most once per `session.sweep_interval_hours` (default 24): the web's session hub gives it a chance on its periodic full scan, and `GET /api/sessions` does too. No cron, and a terminal-only chi never sweeps. Manual prune is always available.
31
31
 
@@ -65,7 +65,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
65
65
  **Ordering:**
66
66
 
67
67
  - `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.
68
- - 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 chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own 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 (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again); "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). 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 chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). The info bar copies `chi --attach <id>` for a terminal. The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; 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; 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}], warning?}`; 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`), and `POST /api/sessions` takes `model` (blank means the default). 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 choice; 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).
68
+ - 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 chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own 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 (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again); "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). 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 chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). The info bar copies `chi --attach <id>` for a terminal. 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. The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; 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; 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, sampling?}], warning?}`; `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`), and `POST /api/sessions` takes `model` (blank means the default). 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 choice; 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).
69
69
  - 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).
70
70
  - 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), `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), `format.js` (pure formatters such as `previewOf`), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
71
71
  - 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).
@@ -95,20 +95,31 @@ module Samagotchi
95
95
  # +base+, with -2, -3… when the name is taken or is the prefix of a
96
96
  # model id (chi would read "qwen3:8b" as host qwen3, model 8b).
97
97
  def host_name(base, requested: nil, model_ids: [])
98
+ host_name_with_reason(base, requested: requested, model_ids: model_ids).first
99
+ end
100
+
101
+ # [name, reason]: reason is :taken when the name was suffixed because
102
+ # config.yml already has it, :model_prefix when a model id's prefix
103
+ # would read as a host, nil when the name is free as given.
104
+ def host_name_with_reason(base, requested: nil, model_ids: [])
98
105
  taken = current_hosts.keys
99
106
  if requested
100
107
  name = requested.to_s.strip
101
108
  raise Error, "--name must match #{ConfigFile::HOST_NAME_RE.source}" unless name.match?(ConfigFile::HOST_NAME_RE)
102
109
  raise Error, "config.yml already has a host named '#{name}'; pick another --name" if taken.include?(name.downcase)
103
110
 
104
- return name
111
+ return [name, nil]
105
112
  end
106
113
 
107
114
  prefixes = [*model_ids, configured_default_model].compact.map { |id| id.to_s.split(":", 2).first.downcase }
108
115
  name = base
109
116
  n = 1
110
- name = "#{base}-#{n += 1}" while taken.include?(name) || prefixes.include?(name)
111
- name
117
+ reason = nil
118
+ while taken.include?(name) || prefixes.include?(name)
119
+ reason ||= taken.include?(name) ? :taken : :model_prefix
120
+ name = "#{base}-#{n += 1}"
121
+ end
122
+ [name, reason]
112
123
  end
113
124
 
114
125
  # Write the entry. @param fields [Hash] the hosts entry (string keys,