samagotchi 0.17.0 → 0.18.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +98 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +25 -2
  5. data/docs/cli.md +41 -9
  6. data/docs/configuration.md +4 -2
  7. data/docs/guardrails.md +59 -8
  8. data/docs/hooks.md +4 -1
  9. data/docs/memory.md +5 -3
  10. data/docs/plugins.md +3 -2
  11. data/docs/sessions.md +6 -5
  12. data/docs/sub-agent.md +42 -4
  13. data/lib/samagotchi/answer_command.rb +23 -2
  14. data/lib/samagotchi/answer_tail.rb +52 -0
  15. data/lib/samagotchi/bridge.rb +49 -8
  16. data/lib/samagotchi/bridge_client.rb +18 -3
  17. data/lib/samagotchi/bundle_command.rb +108 -2
  18. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  19. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  20. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  22. data/lib/samagotchi/client.rb +58 -10
  23. data/lib/samagotchi/commands/registry.rb +28 -0
  24. data/lib/samagotchi/config.rb +7 -1
  25. data/lib/samagotchi/continue_offer.rb +177 -0
  26. data/lib/samagotchi/engine.rb +68 -22
  27. data/lib/samagotchi/guardrail_wiring.rb +56 -3
  28. data/lib/samagotchi/guardrails/outside.rb +46 -0
  29. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  30. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  31. data/lib/samagotchi/guardrails/rules.rb +17 -5
  32. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  33. data/lib/samagotchi/guardrails/targets.rb +30 -13
  34. data/lib/samagotchi/guardrails.rb +2 -0
  35. data/lib/samagotchi/iteration_limit.rb +38 -0
  36. data/lib/samagotchi/kernel_loop.rb +2 -1
  37. data/lib/samagotchi/llm/backend.rb +2 -1
  38. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  39. data/lib/samagotchi/llm/http.rb +9 -3
  40. data/lib/samagotchi/llm/native_backend.rb +2 -1
  41. data/lib/samagotchi/memory_bundle/trash.rb +81 -2
  42. data/lib/samagotchi/model_profile.rb +2 -2
  43. data/lib/samagotchi/output_formatter.rb +9 -4
  44. data/lib/samagotchi/parent_report.rb +39 -9
  45. data/lib/samagotchi/question_desk.rb +205 -46
  46. data/lib/samagotchi/reply_wait.rb +10 -4
  47. data/lib/samagotchi/send_command.rb +2 -1
  48. data/lib/samagotchi/served_model.rb +14 -4
  49. data/lib/samagotchi/session.rb +3 -2
  50. data/lib/samagotchi/session_commands.rb +3 -2
  51. data/lib/samagotchi/session_manager.rb +4 -1
  52. data/lib/samagotchi/sessions_command.rb +3 -2
  53. data/lib/samagotchi/system_prompt.rb +1 -1
  54. data/lib/samagotchi/terminal_ui/attached_loop.rb +33 -8
  55. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
  56. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  57. data/lib/samagotchi/terminal_ui.rb +75 -17
  58. data/lib/samagotchi/tool_declarations.rb +4 -4
  59. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  60. data/lib/samagotchi/tools/edit.rb +6 -1
  61. data/lib/samagotchi/tools/execute.rb +102 -23
  62. data/lib/samagotchi/tools/read.rb +2 -1
  63. data/lib/samagotchi/turn_flow.rb +8 -7
  64. data/lib/samagotchi/turn_note.rb +7 -0
  65. data/lib/samagotchi/version.rb +1 -1
  66. data/lib/samagotchi/web/app.rb +187 -42
  67. data/lib/samagotchi/web/public/app.js +122 -25
  68. data/lib/samagotchi/web/public/data.js +12 -13
  69. data/lib/samagotchi/web/public/index.html +6 -1
  70. data/lib/samagotchi/web/public/notify.js +5 -2
  71. data/lib/samagotchi/web/public/question_card.js +28 -1
  72. data/lib/samagotchi/web/public/question_cards.js +74 -3
  73. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  74. data/lib/samagotchi/web/public/timing.js +41 -0
  75. data/lib/samagotchi/web/public/turn_events.js +61 -0
  76. data/lib/samagotchi/web/public/update_notice.js +15 -0
  77. data/lib/samagotchi/worker.rb +30 -58
  78. data/lib/samagotchi/worker_sidecar.rb +6 -3
  79. metadata +9 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '056529144efb1765ab53c0ce9e88a9ea979ecd8c202d3e41a12a794827fee260'
4
- data.tar.gz: 054d2a858289e543a8085f0ce774935ea164a41e98467e8fb099452a81eb4729
3
+ metadata.gz: 34f4c3e1960aa23902e27160bd1f325ba035731c283a93b2cd819676481c90a2
4
+ data.tar.gz: 708e58e5bfc26d10fe9f20bd1b696f6355e2a25749a36ca6503e82ca9077fa64
5
5
  SHA512:
6
- metadata.gz: 0a88b140031661b2dd2903df3ca74e50587971cba38029e65f8dcf8ec5e1e2a098fbfa4247c2a0954b5f249ca70d85a30600236f728e6131056f4defb6058a5b
7
- data.tar.gz: 5a2d8a1fac55ad2d4a69ec0002a99a856e82de8b0a91a4c09e4a05c04a10a27d634febf0d77d5dfdfb94a7fd5b3c8f88397ed0affb13384e4394a33c7d21424e
6
+ metadata.gz: 56c634e50c34f3fd027ca9ed7208d7edaab21a639050fe321c1d9fa5e8a0e818a31ae6c6351f8c21c0417984f3235a1a44b14aaa3d415b392a85d3bff2916aa1
7
+ data.tar.gz: 41862c98747027f791e4aa59b4c6d2eda092d818e3492212fbc79a24970c74734c3196c480511876aa97b95a8e1245c8bfd6fceccb73d47ade67d3f96ced89fa
data/CHANGELOG.md CHANGED
@@ -8,6 +8,101 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.18.1] - 2026-10-03
12
+
13
+ ### Fixed
14
+
15
+ - The `execute` tool no longer hangs when a command leaves a process in the background (`server &`, or
16
+ `cd DIR && nohup server > log 2>&1 &`) that keeps its output open: after a short grace it stops that process and
17
+ returns the output, with a note to start servers and long jobs with `task_create`. Stop and
18
+ `execute.timeout_sec` work during the wait too.
19
+ - A running session no longer denies every tool call after an installed bundle's rules are upgraded to a version
20
+ that needs a newer chi (`requires_chi`): it keeps the rules it loaded before and says once to restart the session
21
+ (`chi sessions stop ID`, then `chi --resume ID`).
22
+ - `chi -p … --non-interactive` keeps stdout for the answer alone: the `Session:` (or `Resumed session:`) line goes
23
+ to stderr, before the turn. A failed turn now says after its error that the session is kept with the prompt and
24
+ how to continue it (`chi --resume ID`).
25
+ - Ctrl-C in `chi -p … --non-interactive` keeps the session: the prompt and a cancel note are saved, so
26
+ `chi --resume ID` has them, and stderr says so instead of a Ruby backtrace. The exit status stays 130.
27
+ - `chi -p` shows the after_turn hooks' notices (source-links' `sources:`, any notifier), which it lost: with
28
+ `--non-interactive` on stderr after the answer; attached (input from a pipe) it waits for them and shows them
29
+ before it detaches.
30
+ - `chi -p … --non-interactive` prints the retry lines on stderr (`↻ empty answer, asking again (1/1)`, `retrying
31
+ (1/5 in 0.5s): …`), as the REPL shows them, instead of nothing.
32
+ - A native llama.cpp turn sends `GET /props` once, not twice.
33
+ - After a failed `/props` probe, a server that then answers is probed again at the next turn, instead of the
34
+ failure being reused for 30 s.
35
+ - A request to a model server is sent only as many times as chi's own retries say: Ruby's hidden extra retry is off.
36
+ - The served-model notice no longer treats `gpt-4` and `gpt-4o` as the same model: a served name counts as the
37
+ asked one only when what follows starts with `:`, `-`, `@` or `/` (`qwen3` = `qwen3:latest`).
38
+ - A Gemma thought channel (`<|channel>thought` … `<channel|>`) is stripped as a whole block, so the model's
39
+ reasoning no longer shows up in the answer.
40
+ - An unknown model profile name falls back to `qwen36`, the same default as everywhere else, instead of `gemma4`.
41
+
42
+ Update with `gem update samagotchi` (or `chi update`); no bundle changed.
43
+
44
+ ## [0.18.0] - 2026-10-03
45
+
46
+ ### Added
47
+
48
+ - The guardrails bundle (0.4.0) asks before git that changes another checkout: `cd ../main && git commit`,
49
+ `git -C ../main add .`, an `execute` with `cwd:` there (commit, add, reset, checkout, switch, rebase, merge, push,
50
+ stash, rm, mv, cherry-pick, revert, pull, restore, am). Read-only git and tests there still run unasked. Its card
51
+ offers once, this session, or "rule in this repo" (stored, so later sessions there don't ask); switch it off with
52
+ `guardrails.disable: [git-outside-repo]`. Run `chi update` (or `chi bundle upgrade guardrails`) to get it.
53
+ - Web: a tab left open across a `chi web` upgrade says so: when its event stream reconnects to a newer chi, a toast
54
+ `chi was updated to X` offers a Reload button (it never reloads by itself; ✕ dismisses it).
55
+ - `chi bundle trash` lists the bundle trash (moved files from uninstalls/upgrades) oldest first, with file count,
56
+ size, and age; `--empty` deletes all folders, `--older-than DAYS` keeps only recent ones, `--dry-run` previews.
57
+ - A turn that runs out of iterations now waits as a question (kind `continue`, the step-limit question):
58
+ `chi send --wait` exits 3 with it, `chi answer --option Continue|Stop` answers it, and the web badge and bell show
59
+ it. The web draws it as a card with Continue, Stop and a reason for Stop; `chi --attach` reads the continue words
60
+ at its prompt (Enter continues). A parent agent may Continue unless `turn.parent_continue: false` (config.yml only);
61
+ a delegate's goes back to the parent model.
62
+ - `turn.max_iterations` (default 100, env `SAMAGOTCHI_TURN_MAX_ITERATIONS`) sets the per-turn step limit;
63
+ `--no-interrupt` turns get the larger of 1000 and it.
64
+
65
+ ### Changed
66
+
67
+ - `write-outside-repo` and any `path: outside_repo` rule measure from the session's repo (not a plugin call's own
68
+ `cwd:`), with symlinks resolved: `/tmp/x` and `/private/tmp/x` are the same
69
+ folder, and a link in the repo that points elsewhere counts as outside. Writes into tmp folders (`$TMPDIR`, `/tmp`)
70
+ no longer ask, unless the session itself lives there. Hook events' `targets` gain `git_dirs`.
71
+ - A one-word `/word` that no command answers (`/modle`) is no longer sent to the model as a prompt: every UI
72
+ (the REPL, the attached TUI, the web composer) prints `Unknown command /modle. Did you mean /model? /help lists
73
+ the commands.` instead, with the "Did you mean" part only when a command name is close. Lines that are prompts
74
+ (`/foo bar`, `/usr/bin/env is missing`) still go to the model.
75
+ - `chi send --wait` reports `limit` instead of `no_answer` when a turn ran out of iterations and nobody can answer
76
+ (an older worker), with how to continue it.
77
+ - Stop at the step limit (`/continue no`) keeps the turn's work, with a note for the model, instead of erasing the
78
+ prompt and everything the turn did; `!rollback` still erases it. Reminder turns follow `turn.max_iterations`.
79
+ - The web page's end-of-turn and card re-reads fetch only what they show: the session's state, the last answer,
80
+ that turn's timings and the cards, from a new light worker read (`GET /session/:id/tail`) instead of the
81
+ whole conversation and timing history, so they stay the same size however long the session runs.
82
+ - `execute` gives a command 120 s by default (was 30 s) before it is killed; `execute.timeout_sec` /
83
+ `SAMAGOTCHI_EXECUTE_TIMEOUT_SEC` still set it.
84
+ - An `execute` command killed at its time limit says how to run a long one: a second line
85
+ `(killed at the limit; for a long command use task_create, then task_wait)` follows `Error: command timed out after Ns`.
86
+ - `read` with only `end_line` reads from line 1 instead of failing with "start_line must be provided". A range
87
+ `edit` with only `end_line` still refuses (it would overwrite the top of the file) and now says what to pass:
88
+ `pass start_line too (1-based, the first line to replace)`.
89
+ - In a worktree or subfolder, the system prompt's "Project root" line now says it is only where shared project
90
+ memories come from, and to read, edit, run and commit in the current working directory; a model took the root for
91
+ its workspace and committed to the main checkout.
92
+
93
+ ### Fixed
94
+
95
+ - Web: a session waiting on a plugin card (check-in's Nudge / Keep going / Stop) shows its "needs you" badge as a
96
+ pill on its session card again; it was drawn as a large empty circle over the card.
97
+ - Web: with a prompt queued behind a turn, the next turn's live timing line no longer freezes or takes the
98
+ previous turn's number when the previous turn's re-read lands late.
99
+ - `chi sessions list` shows the first message of a session that has no turn yet, such as one started with
100
+ `chi send --new -m "/model x"` or from the web start page; its row was blank.
101
+ - Web: a terminal-only command (`/stats`, `/exit`, `/recap` …) typed as the start page's first message is answered on
102
+ the start page; it no longer starts a session just to show that reply.
103
+
104
+ Update with `chi update`: the guardrails bundle moves to 0.4.0 (the `git-outside-repo` rule; it needs chi 0.18.0).
105
+
11
106
  ## [0.17.0] - 2026-10-03
12
107
 
13
108
  ### Added
@@ -940,7 +1035,9 @@ and long-lived sessions.
940
1035
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
941
1036
  hotkey panel that send selected text or the clipboard to your sessions.
942
1037
 
943
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.17.0...HEAD
1038
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.18.1...HEAD
1039
+ [0.18.1]: https://github.com/dm1try/samagotchi/compare/v0.18.0...v0.18.1
1040
+ [0.18.0]: https://github.com/dm1try/samagotchi/compare/v0.17.0...v0.18.0
944
1041
  [0.17.0]: https://github.com/dm1try/samagotchi/compare/v0.16.0...v0.17.0
945
1042
  [0.16.0]: https://github.com/dm1try/samagotchi/compare/v0.15.0...v0.16.0
946
1043
  [0.15.0]: https://github.com/dm1try/samagotchi/compare/v0.14.0...v0.15.0
data/bin/chi CHANGED
@@ -314,6 +314,7 @@ if launch == :attached
314
314
  exit Samagotchi::TerminalUI::AttachLauncher.exit_status(ended)
315
315
  end
316
316
 
317
+ require "samagotchi/cli/exit"
317
318
  begin
318
319
  ended = Samagotchi::TerminalUI.new(
319
320
  prompt: options[:prompt],
@@ -326,8 +327,10 @@ begin
326
327
  model_name: options[:model],
327
328
  scratch: scratch
328
329
  ).run
329
- # `chi -p X </dev/null`: the turn failed (nothing was sent again).
330
- exit 1 if ended == :turn_failed
330
+ # `chi -p X </dev/null`: the turn failed (nothing was sent again); -p
331
+ # --non-interactive: the turn ended with no answer, or Ctrl-C cancelled it.
332
+ exit 1 if %i[turn_failed empty_answer].include?(ended)
333
+ exit Samagotchi::CLI::Exit::INTERRUPTED if ended == :interrupted
331
334
  rescue Samagotchi::TerminalUI::SessionBusy, Samagotchi::TerminalUI::SessionNotFound, Samagotchi::ModelProfile::MissingModel => e
332
335
  warn "Error: #{e.message}"
333
336
  exit 1
data/docs/architecture.md CHANGED
@@ -177,7 +177,7 @@ rescues sink errors). A new UI (web, API) supplies its own `on_event` and
177
177
  renders whatever it needs from the stream + final `Result`. The public Engine API:
178
178
 
179
179
  ```ruby
180
- engine.run_turn(session, prompt, on_event: nil, max_iterations: 100, cancel_controller: nil)
180
+ engine.run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil) # nil: turn.max_iterations
181
181
  engine.run(session: nil, prompt: "...", on_event: nil) # create/resume session + run
182
182
  engine.system_prompt # fully built system prompt string
183
183
  engine.session # current session (Engine owns create/resume)
@@ -256,7 +256,13 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
256
256
  - `GET /session/:id/state` — `session_state_snapshot` (JSON).
257
257
  - `GET /session/:id/stats` — `Engine#stats_snapshot` for attached `/stats`: the metrics, with the context window and prompt profile asked from the server before the first turn.
258
258
  - `GET /session/:id/snapshot` — the snapshot frame's content as one request (the web server renders
259
- the messages itself, then streams from its `event_seq`).
259
+ the messages itself, then streams from its `event_seq`). Read by the web's full show
260
+ (`GET /api/sessions/:id`, `?parts=1`) and the plugin's `list_sessions`; every message is copied.
261
+ - `GET /session/:id/tail` — the page's light re-read (`Bridge#tail_frame`): `session_state_snapshot`,
262
+ `answer` (the last message a UI shows as an answer, `AnswerTail`: the one message, copied alone),
263
+ `cards` (`CardStore#list`), `event_seq`, `event_id`, all taken in one event-log hold; no message
264
+ list, so it costs the same however long the session is. `?turn_id=` answers that turn's answer
265
+ (an unknown or missing id: the newest); the only route that reads its query.
260
266
  - `OPTIONS *` — CORS preflight (`Access-Control-Allow-Origin: *`).
261
267
 
262
268
  The metrics in `/state`, `/snapshot`, `/stats` and the SSE `snapshot`/`reset` frames
@@ -265,6 +271,21 @@ newest turn the worker finished and any it hasn't saved yet (at most 20 turns),
265
271
  calls and the running turn's finished ones. The full history is the session's `analytics.json`,
266
272
  which `SessionMetrics#persist` rewrites after each turn; the web server merges the two by id.
267
273
 
274
+ The page's frequent reads of `GET /api/sessions/:id` stay light too:
275
+ - `?tail=1&recent=1` (each turn's end, a cancel, an answer display): `{tail, session: {id, status,
276
+ used_memory_names}, messages: [the answer], markdown_warning, timing}`, from the worker's `/tail`
277
+ alone (no session file parsed, no `analytics.json`); `timing` carries the worker's recent records
278
+ and `turn_count` (finished turns). The page merges the records by id (`timing.js mergeTiming`) and
279
+ finishes the ended turn's own line by its `turn_id` (sent as `&turn_id=`, which also picks that
280
+ turn's answer). A plain `?tail=1` (a tab opened before this) gets the whole timing.
281
+ - `?timing=1`: the whole timing (`analytics.json` merged with the worker's `/state`) and
282
+ `turn_count`, when the page's merge came up short.
283
+ - `?cards=1`: the cards, from `/tail`; the session file is only stat'ed for the 404.
284
+
285
+ A worker without `/tail` (an older chi; `404 not_found`) is read through `/snapshot` in the same
286
+ shape; any other failure (no worker, a 500, a timeout) reads the disk, trimmed to the newest
287
+ turn's records with `recent=1`.
288
+
268
289
  Every route goes through `Bridge#dispatch`: an id other than the bridge's own session is
269
290
  `404 unknown_session`, and a handler that raises answers `500 bridge_error` (logged as
270
291
  `handler_failed`) rather than dropping the connection.
@@ -280,6 +301,8 @@ never pulls from the page: `chi web` runs one `Samagotchi::Web::SessionHub` (a t
280
301
  the server, no daemon) that keeps an in-memory projection of the session list and pushes
281
302
  changes to every open tab over `GET /api/events` (SSE: a `snapshot` frame on every connect,
282
303
  then `session` for an upsert and `session_gone` for a removal, `: ping` while idle, no replay).
304
+ The snapshot names chi's version: a tab served by another one (its `<body data-version>`) offers a
305
+ reload in a toast, since an open tab keeps its old JS across a `chi web` upgrade.
283
306
  Files stay the source of truth and workers don't know the hub. Its watcher is a 1 s tick that
284
307
  stats the sessions dir (every session writer goes tmp + rename, which bumps the dir's mtime) and
285
308
  each `<id>/` folder (recap.json, bridge.json, the stopped and archived markers), re-parsing only the files whose mtime or size
data/docs/cli.md CHANGED
@@ -23,7 +23,7 @@
23
23
  - `chi models [--format text|json] [--timeout S] [TEXT]` — list the models every configured host offers, as the names `--model` takes (see [Listing the models](#listing-the-models))
24
24
  - `chi self` — print version, source dir (checkout or installed gem), config/memory/session paths, model/host and bundles. Run by a session's `execute`, the `model` row (and host, loop, profile, thinking, served model) is **that session's** model, labelled `(this session <id8>; default …)`; elsewhere it is the default, `(default)`. `model key` is the memory overlay key (`<name>.<key>.md`). `chi self --model` prints only the model a new session starts on, an alias resolved to its target (the desktop helper's hint)
25
25
  - `chi update [--dry-run] [--no-gem] [--no-bundles] [--no-desktop]` — update an installed chi: the gem, the system bundle, the shipped bundles you installed and the desktop helper, in one table (see [Updating](#updating))
26
- - `chi bundle install|upgrade|uninstall|status|diff|list|build` — manage memory bundles (see [Bundle hooks](hooks.md#bundle-hooks-unified-workflow-bundle)); `list` shows the installed ones and the ones shipped with chi, which `install <name>` installs; `core` and `dev` are profiles that install a set of them (see [Bundle profiles](memory.md#bundle-profiles-core-and-dev), [Guardrails](guardrails.md), [Plugins](plugins.md#the-btw-bundle), [the mcp bundle](plugins.md#the-mcp-bundle) [the loop-guard bundle](plugins.md#the-loop-guard-bundle), [the check-in bundle](plugins.md#the-check-in-bundle) and [the skills bundle](plugins.md#the-skills-bundle)). A usage error (an unknown subcommand or flag, a missing argument, a bad `build --scope`) exits 2, as for the other subcommands. Plain `chi` and `chi web` exit 1 on an unknown flag, a flag missing its value or a stray argument
26
+ - `chi bundle install|upgrade|uninstall|status|diff|list|build|trash` — manage memory bundles (see [Bundle hooks](hooks.md#bundle-hooks-unified-workflow-bundle)); `list` shows the installed ones and the ones shipped with chi, which `install <name>` installs; `core` and `dev` are profiles that install a set of them (see [Bundle profiles](memory.md#bundle-profiles-core-and-dev), [Guardrails](guardrails.md), [Plugins](plugins.md#the-btw-bundle), [the mcp bundle](plugins.md#the-mcp-bundle) [the loop-guard bundle](plugins.md#the-loop-guard-bundle), [the check-in bundle](plugins.md#the-check-in-bundle) and [the skills bundle](plugins.md#the-skills-bundle)). `trash` lists and empties the bundle trash (moved files from uninstalls/upgrades). A usage error (an unknown subcommand or flag, a missing argument, a bad `build --scope`) exits 2, as for the other subcommands. Plain `chi` and `chi web` exit 1 on an unknown flag, a flag missing its value or a stray argument
27
27
 
28
28
  ### First setup
29
29
 
@@ -224,7 +224,13 @@ Notes:
224
224
  messages as turn context (a `-p` run on a resumed session never clobbers its
225
225
  conversation).
226
226
  - Non-interactive runs (`-p` with `--non-interactive`, or bare `--non-interactive`)
227
- print only the final result output — no spinner, status line, or REPL.
227
+ print only the final result output on stdout — no spinner, status line, or REPL.
228
+ Everything else goes to stderr: the `Session: ID` line first, before the turn;
229
+ retry lines and hooks' notices as they come, the after_turn hooks' ones
230
+ (source-links' `sources:`) after the answer; and the error of a failed turn,
231
+ then `chi: the session is kept with your prompt; continue it with: chi
232
+ --resume ID` (exit 1). Ctrl-C saves the session with the prompt and a
233
+ cancel note and exits 130.
228
234
 
229
235
  ### Scratch sessions
230
236
 
@@ -285,7 +291,10 @@ In an attached terminal:
285
291
  worker, and every UI sees their output; `/stats` and `/recap` work too. The
286
292
  Web UI's composer takes the same commands, and so do `-p "/model x"` and
287
293
  `chi send -m "/model x"`. A `/word` the session doesn't know (a path, a
288
- typo) goes to the model as a prompt.
294
+ typo) goes to the model as a prompt; a one-word `/word` that no command
295
+ answers (`/modle`) is not sent: the UI prints `Unknown command /modle. Did
296
+ you mean /model? /help lists the commands.` (the "Did you mean" part only
297
+ when a name is close).
289
298
  - `!commands` and the model's tools run in the session's directory (where it
290
299
  was started), whichever terminal you attach from.
291
300
  - `-p` sends its prompt once attached, `--model` switches the worker's model
@@ -397,7 +406,9 @@ REPL alike:
397
406
  - `/stats` and `/recap` answer at once, and in an attached terminal so do `/help`
398
407
  and a plugin's anytime command (`/btw`). Other commands (`!cmd`, `/model`, `/models`,
399
408
  `!rollback`, `/continue`, `/guardrails`) say `busy: wait for the turn to end`
400
- and go back into the prompt, so Enter runs them once the turn ends.
409
+ and go back into the prompt, so Enter runs them once the turn ends. A
410
+ one-word `/word` no command answers (`/modle`) is not steering text either:
411
+ the hint prints and the line is dropped.
401
412
  - A question (`ask_user_question`, a guardrails approval) turns the prompt into
402
413
  a yellow `? ` and lists its choices under it, fitted to the terminal; only a
403
414
  line submitted there answers it (a number, `1,3`, a label, `y`/`n` for an
@@ -803,8 +814,29 @@ Behavior notes:
803
814
 
804
815
  ## Iteration Limit Behavior
805
816
 
806
- - `max_iterations` remains a hard safety cap on tool-call rounds.
807
- - Tool side effects that already ran before the cap are not rolled back.
808
- - `Samagotchi::KernelLoop#run` now returns a resumable result object with the visible output plus the accumulated conversation.
809
- - If the cap is reached while tool calls are still pending, the result is marked resumable so callers can continue from the saved conversation instead of restarting from scratch.
810
- - In assist mode, the CLI then asks at the `? ` prompt, with the choices listed under it: `yes` (Enter alone, or `/continue`) resumes, `no` cancels, and `no, <explanation>` cancels while keeping the reason in conversation context. Once answered, one line stays: `? The turn ran out of iterations. Continue it? → no, too slow`.
817
+ - A turn's step limit (model ↔ tool rounds) is `turn.max_iterations` in config.yml (env
818
+ `SAMAGOTCHI_TURN_MAX_ITERATIONS`, default `100`, an integer of 1 or more). `--no-interrupt` and
819
+ `--non-interactive` turns get the larger of 1000 and it. Reminder turns follow it too.
820
+ - Tool side effects that already ran before the limit are not rolled back.
821
+ - `Samagotchi::KernelLoop#run` returns a resumable result object with the visible output plus the accumulated
822
+ conversation. If the limit is reached while tool calls are still pending, the result is marked resumable so callers
823
+ can continue from the saved conversation instead of restarting from scratch. The session's `last_turn` records it
824
+ (`"exhausted": true, "limit": N`).
825
+ - In a session's worker (plain `chi`, the web, `chi send`) the offer waits as a question between turns, the
826
+ **step-limit question** (kind `continue`, header "Step limit", options Continue / Stop): the session reads
827
+ `waiting` in the lists, the web shows a card with Continue and Stop and a reason box (a reason goes with Stop), the
828
+ session card's badge reads `out of steps` and the bell rings ("hit its step limit"). `chi send --wait` exits 3 with
829
+ it and `chi answer --option Continue|Stop` answers it (see [chi as a sub-agent](sub-agent.md)). It can't be
830
+ dismissed. A new message instead (or a due reminder) drops it and the partial turn stays; a typed `/continue …`
831
+ answers it too. If the worker exits (a stop, a crash), the offer goes with it.
832
+ - In the attached terminal (`chi --attach`) the question's prompt reads the continue words: Enter alone, `yes`, `y`
833
+ or `/continue` continue; `no` stops; `no, <reason>` stops and tells the model why; `1` and `2` pick the options.
834
+ - In the plain REPL (`--no-shared`) the CLI asks at the `? ` prompt, with the choices listed under it: `yes` (Enter
835
+ alone, or `/continue`) resumes, `no` stops, and `no, <explanation>` stops while keeping the reason in conversation
836
+ context. Once answered, one line stays: `? The turn ran out of iterations. Continue it? → no, too slow`.
837
+ - Stop keeps the interrupted turn's work in the conversation, as a Ctrl-C or a new prompt does, with a note for the
838
+ model that it wasn't continued (or your reason); `!rollback` still erases it.
839
+ - `--non-interactive` (`chi -p`) has nobody to ask: the turn ends there. A `chi send --wait` on a session nobody can
840
+ answer (an older worker) reports `limit` (exit 1) with how to continue it: `chi send ID -m '/continue yes'`.
841
+ - The check-in bundle's card closes as the turn ends, before the step-limit question opens, so the two never stand
842
+ together; with check-in's default `after: 50` and the limit of 100 a long turn usually shows a check-in card first.
@@ -869,6 +869,8 @@ described in their own sections.
869
869
  | `retry.base_delay` | `0.5` | yes | |
870
870
  | `retry.max_delay` | `8.0` | yes | |
871
871
  | `retry.empty_answer` | `1` | | Times a turn asks again after an empty answer (at most 3, `0` = off). See "Llama Network Retry Behavior". |
872
+ | `turn.max_iterations` | `100` | | A turn's step limit: model ↔ tool rounds before it stops and asks to continue (an integer ≥ 1). `--no-interrupt` turns get the larger of 1000 and this. See [CLI: Iteration Limit Behavior](cli.md#iteration-limit-behavior). |
873
+ | `turn.parent_continue` | `true` | | Whether a parent agent (`chi answer`) may answer Continue to a session's step-limit question; `false`: Stop only. config.yml only: no environment variable. See [Sub-agent](sub-agent.md) and [Guardrails](guardrails.md#approvals-from-a-parent-agent). |
872
874
  | `update.gem` | `true` | | `false`: `chi update` never installs a newer gem (`--no-gem` for one run). See [CLI: Updating](cli.md#updating). |
873
875
  | `update.bundles` | `true` | | `false`: `chi update` leaves the shipped bundles to `chi bundle upgrade` (`--no-bundles`). |
874
876
  | `update.desktop` | `true` | | `false`: `chi update` leaves the desktop helper alone (`--no-desktop`). |
@@ -882,14 +884,14 @@ described in their own sections.
882
884
  | `execute.truncate_at_bytes` | `65536` | yes | The same for `execute` output. |
883
885
  | `execute.preview_bytes` | `12288` | yes | |
884
886
  | `execute.telemetry_threshold_pct` | `80` | yes | |
885
- | `execute.timeout_sec` | `30` | yes | Seconds one `execute` command may run before it is stopped. |
887
+ | `execute.timeout_sec` | `120` | yes | Seconds one `execute` command may run before it is stopped. |
886
888
  | `web.port` | `4567` | `--port` | `chi web`'s port. See [CLI](cli.md). |
887
889
  | `web.host` | `127.0.0.1` | yes | `127.0.0.1`, `::1` or `localhost`; `lan` (this machine's private IPv4 address) or one of its IPv4 addresses also opens `chi web` to the network, with an access token (`chi web --new-token` replaces it). Anything else binds `127.0.0.1` with a warning. See [CLI: chi web on your phone](cli.md#chi-web-on-your-phone). |
888
890
  | `web.markdown` | `false` | yes | Render answers as Markdown in `chi web`. |
889
891
  | `web.view` | `stage` | yes | How `chi web` draws a turn: `stage` (the running turn pinned above the composer) or `turn` (one block per turn in the history). See [CLI](cli.md#web-views). |
890
892
  | `web.annotate_presets` | `Agreed\|Could you please elaborate?` | yes | Quick replies next to Annotate in `chi web`, `\|`-separated (a YAML list works too); `""` in the file or on the CLI leaves only Annotate (an empty env value means the default). See [CLI](cli.md#web-annotate-presets). |
891
893
  | `history.file` | state dir | | Prompt history path, shared by the TUI and the web composer. |
892
- | `no_interrupt` | `false` | `--no-interrupt` | Raise the tool-call limit of a turn to 1000; a top-level key. |
894
+ | `no_interrupt` | `false` | `--no-interrupt` | Raise the step limit of a turn to 1000 (or `turn.max_iterations`, when larger); a top-level key. |
893
895
  | `no_default_input` | `false` | `--no-default-input` | Don't pre-fill `default.input`; a top-level key. |
894
896
  | `skip_agent_md` | `false` | | Don't load AGENT.md; a top-level key (see below). |
895
897
 
data/docs/guardrails.md CHANGED
@@ -118,6 +118,12 @@ directory, the rule) stay with the web and the terminal. An approval that
118
118
  doesn't offer "once" can't be allowed from a parent, and neither can one whose
119
119
  offered scopes are missing or don't fit its options: then only Deny goes.
120
120
 
121
+ A child that has to work in another folder (`write-outside-repo`,
122
+ `git-outside-repo`) is denied this way too. Start it in that folder instead
123
+ (`chi send --new --dir PATH`), or have your user allow "rule … in this repo"
124
+ once on the web (it is stored for later sessions in that repo), or switch the
125
+ rules off: `guardrails: {disable: [write-outside-repo, git-outside-repo]}`.
126
+
121
127
  Whatever the setting, a parent can't allow a call on chi's own files: a
122
128
  `write`, `edit` or `execute` on the config dir (config.yml, installed bundles'
123
129
  rules), the hooks dir or the approval store, by its rule (`chi-config`,
@@ -126,6 +132,11 @@ config.yml rewrite would allow everything from then on, so those stay with the
126
132
  user: `chi answer` and the worker refuse the allow with `only the user can
127
133
  allow it`.
128
134
 
135
+ A turn's step-limit question (kind `continue`) isn't an approval: Continue
136
+ grants no permission, so a parent may answer it either way by default.
137
+ `turn.parent_continue: false` (config.yml only, like `parent_approvals`)
138
+ makes parents stop-only. See [chi as a sub-agent](sub-agent.md#the-step-limit).
139
+
129
140
  The guardrails settings (`enabled`, `small_models`, `parent_approvals`) are
130
141
  read from config.yml only: they have no environment variable, and a worker
131
142
  chi starts unsets every `SAMAGOTCHI_GUARDRAILS_*` it would inherit. What a
@@ -182,9 +193,16 @@ guardrails:
182
193
  tool: [write, edit]
183
194
  path: outside_repo # or a glob: "**/.git/hooks/**", "/etc/**", "config/*.yml"
184
195
  verdict: ask
185
- reason: writes outside the repository
196
+ reason: writes outside this session's repo
197
+ - id: git-outside-repo
198
+ tool: shell
199
+ git: outside_repo # a shell call runs commit/add/reset/… in another checkout
200
+ verdict: ask
201
+ scopes: [once, session, rule]
186
202
  ```
187
203
 
204
+ A rule gives at least one of `tool`, `command`, `path` and `git`.
205
+
188
206
  A tool name may be a glob, so one rule covers a plugin's tools (an MCP server's,
189
207
  say): `tool: "mcp_*"` or `tool: ["mcp_{git,gh}_*", web_fetch]` (`*`, `?`, `[…]` and
190
208
  `{a,b}`, matched with `File.fnmatch`). `/guardrails` lists the glob as given.
@@ -203,11 +221,30 @@ All the fields a rule gives must match. Absolute and `**/` globs match the
203
221
  resolved path; other globs match the path relative to the repo root. Paths
204
222
  resolve the way the tools resolve them (against the cwd; `~` expanded).
205
223
 
206
- `outside_repo` leaves out a memory's file: a `*.md` right in the project or
207
- system memories folder (not `index.md`, not a hidden file). `write`/`edit`
208
- there is what `memory_write` does unasked, and the model is told to `edit` a
209
- memory for a small change. The memories folder's `index.md`, `.bundles/`, and
210
- the rest of chi's config folder still count as outside.
224
+ `outside_repo` is measured from the session's repo: the git work tree its
225
+ folder is in (a linked worktree is its own), or the folder itself outside a
226
+ repo. Not the call's own `cwd:`: an `execute` with `cwd:` in another checkout
227
+ is outside. Symlinks are resolved on both sides (`/tmp` and `/private/tmp` are
228
+ one folder; a link in the repo that points elsewhere is outside). Never outside:
229
+
230
+ - a memory's file: a `*.md` right in the project or system memories folder (not
231
+ `index.md`, not a hidden file). `write`/`edit` there is what `memory_write`
232
+ does unasked, and the model is told to `edit` a memory for a small change.
233
+ The memories folder's `index.md`, `.bundles/`, and the rest of chi's config
234
+ folder still count as outside, as does chi's state folder (sessions,
235
+ approvals);
236
+ - a tmp folder (`$TMPDIR`, `/tmp`, `/var/tmp`), unless the session's repo is
237
+ itself in that tmp folder: a sandbox there still gets asked about its siblings.
238
+
239
+ `git: outside_repo` (shell tools only) reads the command for git subcommands
240
+ that change a checkout: `commit`, `add`, `reset`, `checkout`, `switch`,
241
+ `rebase`, `merge`, `push`, `stash` (not `stash list`/`show`), `rm`, `mv`,
242
+ `cherry-pick`, `revert`, `pull`, `restore`, `am`. It follows `cd`,
243
+ `pushd`/`popd`, `( … )`, `git -C`, `--git-dir`/`--work-tree`, `GIT_DIR`/`GIT_WORK_TREE`
244
+ and the call's `cwd:`, and matches when one of them runs outside the session's
245
+ repo (the same `outside_repo` as above). `cd /other && git status`, `git -C
246
+ /other log` and `cd /other && bundle exec rspec` don't match. A folder the text
247
+ doesn't tell (`cd $REPO`, `cd "$(…)"`, `cd -`) doesn't either; see Limits.
211
248
 
212
249
  To switch off single rules (a bundle's, say) without editing its files, list
213
250
  them under `disable:`. A plain id switches off every rule with that id; `bundle:id`
@@ -258,6 +295,14 @@ restart. A rule that doesn't parse (an unknown key, a bad regex, no
258
295
  verdict, a `disable:` that isn't an id or a list of ids) makes chi **deny every tool call** and say why, rather than run
259
296
  without it.
260
297
 
298
+ A bundle whose `requires_chi` the running chi doesn't meet (installed by a
299
+ newer chi while the session's worker kept running) isn't read again: the
300
+ rules the session loaded from it before stay, and a `guardrails>` line says
301
+ once per bundle version to restart the session (`chi sessions stop ID`, then
302
+ `chi --resume ID`). A chi that never loaded that bundle's rules (it starts
303
+ older than the bundle) denies every tool call, as for a rule that doesn't
304
+ parse, until chi is updated or the bundle uninstalled.
305
+
261
306
  ## The guardrails bundle
262
307
 
263
308
  ```sh
@@ -268,7 +313,10 @@ installs a default rule set plus a short memory telling the model not to
268
313
  route around a deny. It asks before `git push`, `reset --hard`, `clean -f`,
269
314
  `branch -D`, `rebase`, `filter-branch`/`filter-repo`; `rm -rf` on `/`, `~`,
270
315
  `$HOME` or `..` paths; `curl … | sh` and `base64 -d … | sh`; writes outside the
271
- repo; shell commands that name chi's config, hooks or guardrails or
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
272
320
  `.git/hooks`; and answering chi's questions around `chi answer`: `chi --attach`
273
321
  or `chi -p` with stdin from a pipe, a here-string or a file
274
322
  (`chi-answer-piped`), and `curl`/`wget` to a session's `/answer` route
@@ -354,6 +402,9 @@ it as `~` or `$HOME`, so the model rarely has to spell it.
354
402
 
355
403
  Text matching on shell commands stops accidents, not a model set on getting
356
404
  around it: `sh -c`, base64, a script written earlier, `git -C` variants and
357
- aliases can get past a regex. `!cmd` lines typed by you are not checked. The only
405
+ aliases can get past a regex. `outside_repo` covers the file tools and git
406
+ only: `sed -i`, `cp` or `cat > file` into another folder from the shell aren't
407
+ asked about, and neither is git run through `sh -c`, a script, an alias, `make`
408
+ or a folder in a variable. `!cmd` lines typed by you are not checked. The only
358
409
  real defence against an adversarial model is isolation (a sandbox, a git
359
410
  identity without push rights).
data/docs/hooks.md CHANGED
@@ -341,7 +341,10 @@ the voter's own (a guard that wants the model to retry a corrected call:
341
341
  `event[:context]` is `{cwd:, repo_root:, branch:, session_id:, interface:, origin:}`
342
342
  (`interface` is `:repl`, `:worker` or `:non_interactive`). `event[:targets]` is
343
343
  what the call acts on, resolved as the tools resolve it:
344
- `{command:, paths:, cwd:, repo_root:, outside_repo:}`.
344
+ `{command:, paths:, cwd:, repo_root:, outside_repo:, git_dirs:}`. `outside_repo`
345
+ is measured from the session's repo, not the call's `cwd:`; `git_dirs` lists where
346
+ a shell call runs git that changes a checkout (`"unknown"` for a folder the
347
+ text doesn't tell; `[]` for other tools).
345
348
 
346
349
  The older flag still works: `event[:blocked] = true` with an optional
347
350
  `event[:block_reason]`. It is folded into the verdict after each hook (so it
data/docs/memory.md CHANGED
@@ -103,9 +103,11 @@ for a file another bundle installed.
103
103
  and the output names them and the dir:
104
104
  `Moved to the trash: notes.md (…/.trash/team-notes-20261003-120000)`. So
105
105
  does an upgrade's removal of a file the new version no longer ships. A
106
- file you edited still needs `--force`. Nothing empties the trash: delete
107
- the dirs when you're sure. Hooks, rules and the plugin live in the
108
- bundle's own dir and are deleted with it.
106
+ file you edited still needs `--force`. `chi bundle trash` lists the trash
107
+ (oldest first, with file count, size, and age); `--empty` deletes all
108
+ folders, `--older-than DAYS` keeps only recent ones, and `--dry-run`
109
+ previews. Hooks, rules and the plugin live in the bundle's own dir and
110
+ are deleted with it.
109
111
  - **A file two bundles list stays** until the last of them is uninstalled
110
112
  (`Kept identity.md: bundle samagotchi-system has it too`); its index line
111
113
  then names the bundle that still has it.
data/docs/plugins.md CHANGED
@@ -229,8 +229,9 @@ chi's own. Path and command rules need to know what a call acts on, and that
229
229
  is what `targets:` says:
230
230
 
231
231
  - `paths:`: files the call reads or writes, absolute or relative to `cwd:`
232
- (else the session's directory). `path:` globs, `outside_repo` and the
233
- protected paths (chi's config, …) match them.
232
+ (else the session's directory). `path:` globs, `outside_repo` (measured
233
+ from the session's repo, whatever the call's `cwd:`) and the protected
234
+ paths (chi's config, …) match them.
234
235
  - `command:`: a shell command the call runs; `command:` rules match it.
235
236
  - `cwd:`: where it runs, for the repo root and relative paths.
236
237
 
data/docs/sessions.md CHANGED
@@ -13,7 +13,7 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
13
13
 
14
14
  A session is deleted if **expired by age OR overflow by count** (unless `keep_status` or the live-owner guard: a session a worker or `chi` still has open is never pruned). A session's `status` is its turn state (`idle`/`running`), not whether a worker is alive. Orphan dirs without a `*.json` are never deleted, except skeleton-only ones (see *Empty sessions*). Deletion removes both `*.json` and sidecar dir atomically. Set both `DAYS=0` and `MAX=0` to retain forever.
15
15
 
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 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`.
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`.
17
17
 
18
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.
19
19
 
@@ -23,7 +23,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
23
23
 
24
24
  **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).
25
25
 
26
- <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; 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 reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
26
+ <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 reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
27
27
 
28
28
  **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.
29
29
 
@@ -40,7 +40,7 @@ chi sessions prune [--dry-run] [--days N] [--keep N] [--keep-status running,...]
40
40
  chi sessions clean [--dry-run] [--days N] # test sessions: all, or older than N days
41
41
  ```
42
42
 
43
- A session whose worker waits for an answer (a question, a hook's question or a guardrail approval) reads `waiting` in the status column, and json has `waiting: "question"|"approval"|"hook"` and `waiting_id`, the question's id for `chi answer --question` (both null otherwise). A delegate's approval its parent relayed to its own card (see *Delegating*) reads `waiting (in parent ab12)`, json `relayed_to: "ab12…"` (the parent's short id, else null). Only while a worker runs it: a question a dead worker left in the file shows the saved status. Answer it with `chi answer`, the web or `chi --attach ID`; `chi sessions stop ID` frees the worker instead.
43
+ A session whose worker waits for an answer (a question, a hook's question, a guardrail approval, or the step-limit question of a turn that ran out of steps, which waits between turns) reads `waiting` in the status column, and json has `waiting: "question"|"approval"|"hook"|"continue"` and `waiting_id`, the question's id for `chi answer --question` (both null otherwise). A delegate's approval its parent relayed to its own card (see *Delegating*) reads `waiting (in parent ab12)`, json `relayed_to: "ab12…"` (the parent's short id, else null). Only while a worker runs it: a question a dead worker left in the file shows the saved status. Answer it with `chi answer`, the web or `chi --attach ID`; `chi sessions stop ID` frees the worker instead.
44
44
 
45
45
  Examples:
46
46
 
@@ -63,7 +63,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
63
63
  **Ordering:**
64
64
 
65
65
  - `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.
66
- - 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 `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).
66
+ - 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).
67
67
  - 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).
68
68
  - 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`).
69
69
  - 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).
@@ -169,7 +169,7 @@ chi send --wait 3fa2 # sends nothing: waits fo
169
169
  - `--new` takes no ids (one new session per call). `--dir DIR` is its folder, and so its project (default the current one); `--model M` its model (default the configured one; the name isn't checked up front, a wrong one fails the worker's first turn).
170
170
  - `--new` prints `<full id> started` on stdout. With `--wait` that line (and the `sent` line for an existing session) goes to stderr, so stdout is exactly the answer: the reply of the turn the message started, in full. It is the worker's `output/` file, the same one `delegate` reads; a turn that ends with only tool calls and no text has none.
171
171
  - `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; wait or attach`, exit 1): the message would run after it, and its reply would come back as the answer.
172
- - Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question or a guardrail approval), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
172
+ - Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question, a guardrail approval, or the step-limit question: see [chi as a sub-agent](sub-agent.md#the-step-limit)), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
173
173
  - `--format json` (with `--wait`) prints one JSON object on stdout however the wait ends, for a script or a parent agent (see [chi as a sub-agent](sub-agent.md)): `{"status":"answered","session_id":…,"text":…}`; `{"status":"question",…,"question":{"id","kind","header","text","options","multi_select","allow_freeform","approval"},"answer_with":"chi answer ID --question QID --option N"}`, where `kind` is `question` (the model's), `hook` or `approval` (with the tool, command, cwd, rule, reason and offered scopes); `{"status":"running",…,"detail":…}` (exit 4, and on Ctrl-C); and `failed`, `canceled`, `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1). A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
174
174
  - `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. A running session is fine (that is the point), so after exit 3, 4 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
175
175
  - The 16 KiB cap applies: `git diff | chi send --new …` on a big diff is refused; name the branch in the message instead and let the session read it.
@@ -182,6 +182,7 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
182
182
  - `delegate(task, model:, session:, wait:, timeout:)`: starts a child and, by default, waits for its reply (`wait: false` returns at once, so several children can run side by side; `timeout`, default 600 s, leaves a slow child running; 0 checks once without waiting). With `session:` (a child's id or prefix) the task goes to that child as a follow-up instead, delivered like `chi send` (client id `delegate:<parent short id>`, shown as `delegate>` in the child's UIs); only this session's children take one.
183
183
  - `delegate_result(session:, timeout:)`: waits for a child's next reply: the one named, or the newest running child (`timeout` as for `delegate`: 0 checks once and returns at once). The reply comes back once; a later call waits for the next one.
184
184
  - What a wait returns, `session: <id>` then `status:` and the text, with the status words `chi send --wait --format json` uses: `answered` with the reply after a `---` line; `question` as soon as the child opens a question (the model's own, or a hook's), with the question's text, options and the `chi answer` command for it (nothing is answered for it: `chi answer`, attach or use the web, then wait again); `failed`, `canceled` or `no_answer` when the child's turn ended with nothing to say (its session shows what happened); `error` (its worker crashed) or `stopped` (`chi sessions stop`) at once; `worker_gone` when the child has had no worker for 15 seconds (it crashed without saying so, or idle-exited): `delegate` with `session:` starts it again; `running` on timeout, or when the parent's own turn is canceled (the child keeps running; the next turn can wait again). A reply saved before the child's worker idle-exited is returned at once.
185
+ - **A child's step-limit question goes back to the parent model**, not to its user: `delegate_result` returns `status: question` with the `chi answer` commands for Continue and Stop, and says the model may instead send a narrower follow-up (`delegate session:`, which drops the question) or report back. A parent's `chi answer` follows `turn.parent_continue` (see [chi as a sub-agent](sub-agent.md#the-step-limit)).
185
186
  - **A child's approvals go to the parent's user** (the approval relay). While the parent waits (`delegate`, `delegate_result`), a guardrail approval the child opens reopens as the parent session's own approval: the same card in the parent's web page and attached terminal, with the same facts and diff, a line naming the delegate and its task (`delegate ab12 · fix the flaky spec…`, linked to its session) and the header `Approve delegate ab12's tool call?`. The options are the child's, by index; "for the session" says it is the delegate's session (the session scope keys on the child; repo and rule approvals are one shared store). The user answers once there; the parent's model never sees the approval, only one line per relayed approval in the tool's result, before the reply: `approval relayed to your user: execute: git push origin main → allowed once` (or `denied ("why")`, `dismissed (denied)`, `answered on the child`, `not delivered (the child's worker is gone)`, `refused by the child …`, `still open (your turn was stopped)`). The time the card is open doesn't count against `timeout`. While the parent waits on one child, its other running children's approvals are relayed too, oldest first, one card at a time (`+1 more delegate waiting`); each outcome comes in that child's next result.
186
187
  - The child's own card stays answerable (the first answer wins: answered there, the parent's card closes as "answered in ab12"). While relayed it says `Waiting for approval in parent ab12: answering here works too`; `chi sessions list` shows the child as `waiting (in parent ab12)` (`--format json`: `relayed_to`), and the web's bell rings for the parent's card, not the child's.
187
188
  - How the child trusts it: the parent's POST that says "answered" (Bridge `POST /session/<child>/relay`) carries no answer. The child asks its parent session's live Bridge (`POST /session/<parent>/relay/status`), which reports only what the parent's question flow took from a UI (`RelayDesk`, in memory only), and takes it only for this child, the question pending now, answered. A parent agent's answer on the relay card (a piped or marked `chi --attach PARENT`, `chi answer`) is held to `guardrails.parent_approvals` by the parent and again by the child.