samagotchi 0.59.0 → 0.61.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/README.md +3 -2
  4. data/bin/chi +23 -2
  5. data/docs/architecture.md +11 -6
  6. data/docs/cli.md +9 -3
  7. data/docs/configuration.md +62 -12
  8. data/docs/hooks.md +82 -5
  9. data/docs/internals/engine.md +50 -13
  10. data/docs/internals/gemma4-contract.md +1 -0
  11. data/docs/internals/prompt-caching.md +6 -3
  12. data/docs/memory.md +1 -1
  13. data/docs/plugins.md +57 -4
  14. data/docs/sessions.md +7 -3
  15. data/docs/sub-agent.md +2 -2
  16. data/docs/testing.md +4 -2
  17. data/lib/samagotchi/answer_display.rb +4 -3
  18. data/lib/samagotchi/answer_refs.rb +210 -0
  19. data/lib/samagotchi/bootstrap_command.rb +2 -1
  20. data/lib/samagotchi/bridge/card_store.rb +6 -1
  21. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  22. data/lib/samagotchi/bundles/agents-md/manifest.yml +11 -0
  23. data/lib/samagotchi/bundles/agents-md/plugin.rb +198 -0
  24. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
  26. data/lib/samagotchi/bundles/core/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  28. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  29. data/lib/samagotchi/config.rb +33 -5
  30. data/lib/samagotchi/continue_offer.rb +5 -2
  31. data/lib/samagotchi/engine.rb +101 -23
  32. data/lib/samagotchi/hooks/registry.rb +3 -1
  33. data/lib/samagotchi/interrupted_turn.rb +39 -0
  34. data/lib/samagotchi/iteration_limit.rb +3 -2
  35. data/lib/samagotchi/kernel_loop.rb +40 -12
  36. data/lib/samagotchi/llm/backend.rb +6 -1
  37. data/lib/samagotchi/llm/chat_loop.rb +16 -8
  38. data/lib/samagotchi/llm/model_result.rb +11 -2
  39. data/lib/samagotchi/llm/native_backend.rb +3 -2
  40. data/lib/samagotchi/log_subscriber.rb +8 -4
  41. data/lib/samagotchi/plugin/context.rb +16 -3
  42. data/lib/samagotchi/prompt_files.rb +127 -0
  43. data/lib/samagotchi/ref.rb +141 -0
  44. data/lib/samagotchi/reply_wait.rb +6 -2
  45. data/lib/samagotchi/session.rb +53 -5
  46. data/lib/samagotchi/session_manager.rb +64 -6
  47. data/lib/samagotchi/session_metrics.rb +8 -2
  48. data/lib/samagotchi/session_setup.rb +11 -5
  49. data/lib/samagotchi/system_prompt.rb +71 -15
  50. data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -9
  51. data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
  52. data/lib/samagotchi/terminal_ui.rb +21 -5
  53. data/lib/samagotchi/turn_checkpoint.rb +128 -0
  54. data/lib/samagotchi/turn_flow.rb +22 -3
  55. data/lib/samagotchi/version.rb +1 -1
  56. data/lib/samagotchi/web/app.rb +5 -0
  57. data/lib/samagotchi/web/public/answer_refs.js +217 -0
  58. data/lib/samagotchi/web/public/app.js +15 -18
  59. data/lib/samagotchi/web/public/favicon.svg +1 -0
  60. data/lib/samagotchi/web/public/index.html +6 -3
  61. data/lib/samagotchi/web/public/ref_menu.js +9 -1
  62. data/lib/samagotchi/web/public/ref_scan.js +5 -3
  63. data/lib/samagotchi/web/public/refs.js +129 -15
  64. data/lib/samagotchi/web/public/stream_handlers.js +5 -3
  65. data/lib/samagotchi/web/public/turn_view.js +17 -7
  66. data/lib/samagotchi/worker.rb +169 -21
  67. data/lib/samagotchi/worker_signals.rb +81 -0
  68. metadata +11 -1
@@ -16,25 +16,28 @@ Engine#run_turn → backend.complete(...) → KernelLoop#run (native) or ChatLoo
16
16
 
17
17
  ```
18
18
  Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:, max_tool_output_chars:,
19
- pending_input:, continue:, origin:, images:, id:)
19
+ pending_input:, continue:, origin:, images:, id:, resumed:, resume_from:)
20
20
  │
21
21
  ├── begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id:)
22
+ │ # (then, on a continue, turn.resumed = resumed (a step-limit Continue) and
23
+ │ # turn.resume_from = resume_from (its run's start; nil: it starts cold))
22
24
  │ # @session = session, session.status = RUNNING, used memories absorbed,
23
25
  │ # TurnState#begin!(controller:, sink:) (a new CancellationController when none is given),
24
26
  │ # a recap in flight invalidated, GuardrailWiring#begin_turn(origin)
25
27
  │ # Returns a Turn {session, prompt (nil on a continue), continue, on_event, controller,
26
- │ # origin, started_at, id (the caller's id:, else a new uuid)}
28
+ │ # origin, started_at, id (the caller's id:, else a new uuid), resumed, resume_from}
27
29
  │
28
30
  ├── Client.swap_probe_cancel(turn.controller) # a Stop cuts this turn's /props probes
29
31
  │
30
32
  ├── prepare_turn(turn, images)
31
33
  │ # Emits :turn_started {session_id, prompt, turn_id, continue: true (only on a continue),
34
+ │ # resumed: true (only on a step-limit Continue: resumed: given),
32
35
  │ # images (only when there are any), cut_after (the model's cut wait, s)}
33
36
  │ # refresh_profile! (may ask the server's /props once)
34
37
  │ # turn.settings = LLM::TurnSettings (vision, sampling, thinking; model_name is set in generate)
35
38
  │ # announces guardrail load failures, starts and awaits plugin init tasks, applies staged tools
36
39
  │ # fires the :session_start hook on the Engine's first turn, then the :before_turn hook
37
- │ # (a frozen copy of the history, and the prompt)
40
+ │ # (a frozen copy of the history, the prompt, and resumed)
38
41
  │ # turn_messages: system head + history + due reminders (:reminder_injected) + the user message
39
42
  │ # Returns false when a Stop came during the probes or the init wait, else true
40
43
  │
@@ -53,9 +56,11 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
53
56
  │ # end_turn(turn, "completed" | "canceled"): under the event lock the session gets the kept
54
57
  │ # messages, goes IDLE, records last_turn, and :turn_completed {result, turn_summary,
55
58
  │ # display_pending} or :turn_canceled {cancellation_reason, cancelled_by, duration_ms} is emitted
56
- │ # then the :after_turn hook (status, messages, present: the AnswerDisplay presenter)
57
- │ # store_answer_display (:answer_display to the observers when a hook changed the answer,
58
- │ # or display: nil when the web was told to wait for it)
59
+ │ # then the :after_turn hook (status, messages, present: the AnswerDisplay presenter,
60
+ │ # refs: the AnswerRefs adder)
61
+ │ # store_answer_display (one replace of the answer: `display` and/or `display_refs`;
62
+ │ # :answer_display {display, refs: N} to the observers when a hook changed the answer or
63
+ │ # attached refs, or display: nil when the web was told to wait for it)
59
64
  │ # then the :session_end hook
60
65
  │
61
66
  ├── warm_up_next_turn(turn, result)
@@ -83,7 +88,7 @@ StandardError in run_turn:
83
88
  # error_kind, retryable, host and summary.
84
89
  → re-raised
85
90
 
86
- Interrupt (Ctrl-C):
91
+ Interrupt (Ctrl-C; the REPL only: a worker traps INT, see the signal row below):
87
92
  → if turn.ended: re-raised only
88
93
  → turn.controller.cancel!(:ctrl_c)
89
94
  → end_turn(turn, "canceled"): ctrl_c_messages keeps turn.messages with TurnNote.cancelled(:ctrl_c)
@@ -99,6 +104,17 @@ A Stop (Engine#cancel_current_turn!, e.g. the Bridge's POST cancel) or a hook's
99
104
  → complete_turn keeps the loop's conversation (tool iterations included) with a
100
105
  TurnNote.cancelled note; a text streamed before the cancel stays, marked [interrupted]
101
106
 
107
+ A worker's TERM, HUP or INT (WorkerSignals, trapped by a spawned worker only):
108
+ → a Stop with reason :signal (Worker#signalled, off the trap, retried until the loop leaves)
109
+ → :turn_canceled {cancellation_reason: :signal}, saved by the Worker as after any turn;
110
+ Worker#run returns :signal (:stopped with the stop marker), takes no new work
111
+ → not ended within Worker::SIGNAL_GRACE (a turn that ended and runs its after_turn hooks gets
112
+ SIGNAL_HARD_EXIT more, uncanceled): the session saved as it is, SignalException raised in the
113
+ loop's thread, only while the loop runs (never into start, serve or the teardown, which runs whole)
114
+ → the process ends SIGNAL_HARD_EXIT (10 s) after that at the latest (exit!); a signal after the
115
+ loop left brings only that hard exit, and run_session_loop restores the default handlers
116
+ → run_session_loop: exit 0, no discard; input queued behind the stopped turn wakes a new worker
117
+
102
118
  Stopped before the model was asked (prepare_turn returned false):
103
119
  → stopped_before_model(turn) → end_turn(turn, "canceled")
104
120
  → nothing kept, :turn_canceled {cancellation_reason, cancelled_by, duration_ms}
@@ -128,10 +144,13 @@ parses tool calls out of the generated text.
128
144
 
129
145
  ```
130
146
  KernelLoop#run(messages, max_iterations:, on_stream_event:, cancel_controller:,
131
- model_name:, max_tool_output_chars:, pending_input:)
147
+ model_name:, max_tool_output_chars:, pending_input:, reasoning_since:)
132
148
  │
133
149
  │ turn = start_turn(...) # turn.conversation = prepare_conversation(messages): a copy,
134
- │ # old model messages without their thought blocks
150
+ │ # old model messages without their thought blocks, but a resumed
151
+ │ # run's (a step-limit Continue's reasoning_since:, #thought_floor:
152
+ │ # all of them, a steer merged mid-run included; only a user entry
153
+ │ # added after the run's last reply starts them past itself)
135
154
  │ each iteration:
136
155
  │ 1. Queued input (user lines, plugin steers) goes in at the boundary (Steer.inject!)
137
156
  │ 2. Format the conversation as prompt text (Prompt.format_with_images); a context
@@ -179,8 +198,10 @@ The differences from the native loop:
179
198
  `Session#reasoning_history`, `/reasoning-history`): `all` on every model message, `turn` past
180
199
  `ChatLoop#reasoning_boundary`: the later of the last query (a `role: "user"` entry: a prompt, a steer, merged input,
181
200
  the chat template's `last_query_index`; or a `turn_start` entry) and the Run's start (`Run`'s `@start`, the
182
- conversation length at `#complete`), so a continue turn (reminder, context wake, continue after a stop) replays
183
- nothing of the run before. `Run#prompt_chars` counts it (`#reasoning_chars`). The native loop differs on a steer:
201
+ conversation length at `#complete`), so a continue turn (reminder, context wake, delegate report wake) replays
202
+ nothing of the run before. A step-limit Continue's Run starts where the run it resumes did: `#complete`'s
203
+ `reasoning_since:` (the Engine's `resume_from`, checked against the messages' length in `#resumed_run_start`) is
204
+ the Run's `@start`. `Run#prompt_chars` counts it (`#reasoning_chars`). The native loop differs on a steer:
184
205
  `KernelLoop#inject_pending_input!` doesn't re-sanitize, so native keeps the turn's reasoning across a mid-turn
185
206
  steer, where the chat loop starts again (as the template does).
186
207
  - A 400 about reasoning (`BadRequest#reasoning_refused?`) for a request that sent reasoning back asks the step again
@@ -291,6 +312,14 @@ end
291
312
  turn through the Bridge (`POST cancel` → `Engine#cancel_current_turn!`).
292
313
  - `on_event` is the turn's sink.
293
314
  - `ended` is set by `end_turn`.
315
+ - The live conversation: both loops register the array they append to on the kernel
316
+ (`KernelLoop#llm_context_turn`, `LLMContextForget::Turn#conversation`; `KernelLoop#start_turn`,
317
+ `ChatLoop::Run#initialize`). `Engine#live_conversation` returns it while a turn runs (nil otherwise: the kernel
318
+ never clears it). A worker's drain (`Worker#pending_input_drain`, on the turn's thread at every iteration
319
+ boundary) copies it, with the lines that boundary merges, into `Session#turn_checkpoint` (`TurnCheckpoint`):
320
+ every save while the turn runs writes that copy with an `interrupted_turn` mark instead of the session's
321
+ messages, written at the first boundary and then at most every 10 s, so a killed worker loses at most that much
322
+ of its turn. `session.messages` itself is untouched until the turn ends.
294
323
 
295
324
  ### GenerationPhase
296
325
 
@@ -322,7 +351,7 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
322
351
 
323
352
  | Event | When | Payload |
324
353
  |---|---|---|
325
- | `:turn_started` | `prepare_turn` | `{session_id, prompt, turn_id, continue?, images?, cut_after}` |
354
+ | `:turn_started` | `prepare_turn` | `{session_id, prompt, turn_id, continue?, resumed?, images?, cut_after}` (`resumed`: a step-limit Continue) |
326
355
  | `:reminder_injected` | due reminders went into the turn | `{reminders}` |
327
356
  | `:context_status` | every request (unless `context.status` is off), the current estimate before it | `{iteration, status, usage, bucket, source}` (internals/context-telemetry.md) |
328
357
  | `:generation_started` | a request begins | `{iteration, context_window_tokens, context_window_source, price}` (`price`: the turn's `ModelPrice#to_h`, nil without one; native adds `profile`, `profile_source`) |
@@ -344,7 +373,7 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
344
373
  | `:turn_completed` | normal end | `{result, turn_summary, display_pending}` |
345
374
  | `:turn_canceled` | Stop, hook stop, Ctrl-C | `{cancellation_reason, cancelled_by?, duration_ms}` |
346
375
  | `:turn_failed` | an error | `{error_class, message, duration_ms, error_kind?, retryable?, host?, summary?}` |
347
- | `:answer_display` | after the after_turn hooks (observers only, not the sink) | `{display}` (a string, or nil) |
376
+ | `:answer_display` | after the after_turn hooks (observers only, not the sink) | `{display, refs?}` (display a string, or nil; refs the count of the answer's refs, only when a hook attached some) |
348
377
 
349
378
  The boundary events (`:turn_started`, `:turn_completed`, `:turn_canceled`, `:turn_failed`) also carry `origin`
350
379
  when the turn was queued with one.
@@ -402,6 +431,14 @@ puts the hook runtime on the event: `event[:hook]` (the hook's label) and five h
402
431
 
403
432
  From `:after_turn` and `:session_end` there is no turn left: `stop_turn` and `steer` return false.
404
433
 
434
+ `:after_turn` adds two of its own, built by `complete_turn` on the stored conversation's last message when it is the
435
+ model's: `event[:present]` (`AnswerDisplay`, the answer's `display` text) and `event[:refs]` (`AnswerRefs`, refs kept
436
+ as `display_refs`: caps, validation, relative file paths resolved against the session's folder, `refs_rejected`
437
+ logged). Both are display only: `AnswerDisplay::DISPLAY_ONLY` lists `display`, `empty_answer` and `display_refs`, which
438
+ `strip`/`strip_all` drop from every model-facing copy (hooks' `messages:`, the recap, `ctx.messages`,
439
+ after_generation), while `KernelLoop#sanitize_history` and `ChatLoop#plain` copy them so the stored conversation keeps
440
+ them across turns. `Web::App#messages_for_display` sends `display_refs` re-validated (`AnswerRefs.for_display`).
441
+
405
442
  ### Hook lifecycle
406
443
 
407
444
  - **Bundle hooks** (`Registry#register_bundle`) and **config hooks** (`register_persistent`, from config.yml)
@@ -18,6 +18,7 @@ Thought handling follows the Gemma guidance:
18
18
  - When thinking mode is active, the model may emit internal reasoning as `<|channel>thought ... <channel|>`.
19
19
  - Standard multi-turn: prior model thoughts are stripped from conversation history before the next turn.
20
20
  - Function/tool-calling exception: during a single turn that includes tool calls, thoughts are not stripped between those tool-call rounds.
21
+ - A step-limit Continue is that same turn going on: the run it resumes keeps its thoughts (`KernelLoop#run`'s `reasoning_since:`, from the Engine's `resume_from`), from its start, a steer merged inside that run included (nothing re-sanitizes within a run, so the prompt before the limit had them); only a user entry added after the run's last reply (a `!cmd`'s output) starts them past itself. Every other turn (a prompt, a reminder, a wake) strips them as above.
21
22
  - Final model output returned to the caller is thought-stripped.
22
23
  - While a generation streams, the thought channel goes to the stream's `thinking:` lane, not `text:` (`ThoughtStreamSplitter.for_profile`, fresh per generation), so the UIs show it as thinking and `:generation_progress` hooks count it in `thinking_chars`.
23
24
 
@@ -31,8 +31,9 @@ right-hand column happens.
31
31
  │ system identity (+ model overlay) │ /model, editing a
32
32
  │ model notes (model_notes_* matching the model) │ memory / AGENTS.md
33
33
  │ explicit (preloaded) memories │
34
- │ AGENTS.md │ (picked up by a new
35
- │ project + system memory indexes │ process or a rebuild)
34
+ │ AGENTS.md (root → cwd) │ (picked up by a new
35
+ │ appended files (append_system_prompt:, --append-system-prompt) │ process or a rebuild)
36
+ │ project + system memory indexes │
36
37
  │ ◄── remote Claude: cache_control breakpoint #1 │
37
38
  ├──────────────────────────────────────────────────────────────────────────┤
38
39
  │ SYSTEM, volatile tail (per session, least → most changing) │
@@ -126,7 +127,9 @@ is prefilled once. The native and the chat loop use the same plan.
126
127
  a per-session value in the stable part. A guard spec checks that model, location and session are the last
127
128
  sections (`spec/system_prompt_model_spec.rb`).
128
129
  2. **History is append-only.** Never rewrite an earlier message. Add a note at the tail instead. If a model's
129
- spec forces a rewrite (e.g. stripping previous-turn thinking), accept the re-prefill and note it.
130
+ spec forces a rewrite (e.g. stripping previous-turn thinking), accept the re-prefill and note it. A worker's
131
+ mid-turn checkpoint (`TurnCheckpoint`) is append-only too (the history plus what the loop appended) and
132
+ never changes the request bytes.
130
133
  3. **Deterministic order.** Tools, memory lists and anything else that's rendered from a hash or a set needs a
131
134
  fixed sort. Plugin tools are sorted by (bundle, name) whatever order their plugins finished starting in
132
135
  (R5a).
data/docs/memory.md CHANGED
@@ -269,7 +269,7 @@ profiles, meta bundles that install a set of them:
269
269
 
270
270
  | profile | bundles |
271
271
  |---|---|
272
- | `core` | loop-guard, check-in, guardrails, slow-commands: the recommended safety set |
272
+ | `core` | loop-guard, check-in, guardrails, slow-commands, agents-md: the recommended safety set |
273
273
  | `dev` | known-names, mcp, btw, skills, source-links, github-pr, coordinator |
274
274
 
275
275
  `chi bootstrap` installs `core` and, on a terminal, offers `dev`. An existing
data/docs/plugins.md CHANGED
@@ -330,7 +330,8 @@ end
330
330
  This is a bundle hook, the same as a `hooks/*.rb` file. See
331
331
  [hooks.md](hooks.md#hook-events) for the events; on `:after_turn`,
332
332
  `event[:present]` sets how the answer is shown in the web
333
- ([Presenting the answer](hooks.md#presenting-the-answer-display-only)). Its label is
333
+ ([Presenting the answer](hooks.md#presenting-the-answer-display-only)) and `event[:refs]`
334
+ attaches refs to it ([Refs on the answer](hooks.md#refs-on-the-answer-display-only)). Its label is
334
335
  `plugin.rb (bundle my-bundle)`. The block may take only the event. If it
335
336
  raises, the error is logged and the hook is skipped.
336
337
 
@@ -515,7 +516,8 @@ full label, `plugin.rb (bundle my-bundle)`.
515
516
  | `ctx.log` | `ctx.log.info(:event, key: value)`: debug-log records tagged `plugins`, with `bundle=<bundle>` |
516
517
  | `ctx.messages` | the conversation, as a frozen copy, without the system prompt. While a turn runs, a session worker's (attached, web) adds that turn so far: its prompt, the model's text and the lines merged into it (no tool calls or thinking); the REPL's is the conversation before that turn |
517
518
  | `ctx.messages_partial?` | whether `ctx.messages` leaves out a running turn (the REPL mid-turn), so a plugin can say what its answer is about |
518
- | `ctx.notify(text, level: :info, fallback_for: nil)` | one line to the user, labelled by the bundle (`my-bundle> …`). Every UI shows it, during a turn (a tool, a hook) or between turns (a command). `fallback_for: :display` marks a line that repeats the answer's display (`event[:present]`), which a UI that renders the display leaves out: see [Hooks](hooks.md#what-a-hook-can-do-the-runtime). The keyword needs chi 0.35.0 (it raises on an older chi) |
519
+ | `ctx.prompt_files` | the files whose text is in the session's system prompt now: the AGENTS.md chain (the work tree top down to the cwd) and the appended files (`append_system_prompt:`, `--append-system-prompt`); absolute paths, in prompt order, `[]` before the first build |
520
+ | `ctx.notify(text, level: :info, fallback_for: nil)` | one line to the user, labelled by the bundle (`my-bundle> …`). Every UI shows it, during a turn (a tool, a hook) or between turns (a command). `fallback_for: :display` marks a line that repeats the answer's display (`event[:present]`), which a UI that renders the display leaves out (`fallback_for: :editor`: a file the viewer could open from a ref in its editor): see [Hooks](hooks.md#what-a-hook-can-do-the-runtime). The keyword needs chi 0.35.0 (it raises on an older chi) |
519
521
  | `ctx.card(title:, body: "", actions: [], level: :info, id: nil)` | a card in every UI, returning its id: see [Cards](#cards) |
520
522
  | `ctx.ask_user(question:, options:, header: nil, allow_freeform: false)` | a single-select question through the question flow: `{selected:, freeform:, selected_indices:}`, or nil (no one to ask, cancelled, bad options) |
521
523
  | `ctx.cancelled?` | whether the running turn was cancelled (a long tool should stop) |
@@ -1126,13 +1128,16 @@ what it has found. check-in counts the turn's tool calls, and when there are
1126
1128
  and its last few tools, and three actions:
1127
1129
  - **Nudge** (`/checkin nudge`): puts `message` into the running turn
1128
1130
  (`ctx.steer`); the model reads it at its next step and answers or says
1129
- what is left. Every UI shows `check-in> nudged: …`.
1131
+ what is left. Every UI shows `check-in> nudged: …`. One nudge per
1132
+ check-in: another click (or a typed `/checkin nudge`) answers
1133
+ "check-in: already nudged at N tool calls".
1130
1134
  - **Keep going** (`/checkin later`): closes the card until the next
1131
1135
  check-in.
1132
1136
  - **Stop** (`/checkin stop`): stops the turn.
1133
1137
 
1134
1138
  When the turn ends the card loses its actions ("The turn ended after N tool
1135
- calls"), so no stale buttons stay. In the attached terminal the actions are
1139
+ calls"), so no stale buttons stay; a card left by a worker that exited
1140
+ mid-turn comes back without them too. In the attached terminal the actions are
1136
1141
  `→ /checkin nudge` lines to type; the command runs beside the turn.
1137
1142
  - **`mode: nudge`**: nudges the model by itself, with a notice line.
1138
1143
  - **`mode: notify`**: a notice line only.
@@ -1254,6 +1259,54 @@ bundles:
1254
1259
  | `/slow-commands` | this project's slow commands, numbered |
1255
1260
  | `/slow-commands forget 2` / `forget all` | drop one (a wrong entry) or all of them |
1256
1261
 
1262
+ ## The agents-md bundle
1263
+
1264
+ `chi bundle install agents-md` (or the `core` profile) installs the bundle shipped
1265
+ with chi (`lib/samagotchi/bundles/agents-md/plugin.rb`): two `chi.on` hooks
1266
+ (`:before_turn` rebuilds the steered set, `:after_tool_call` steers), and no memory file.
1267
+
1268
+ chi's system prompt holds only the `AGENTS.md` chain from the checkout top down
1269
+ to the session's cwd, so an `AGENTS.md` in a directory the session doesn't run
1270
+ in (say `pkg/inner/` while the session runs in `pkg/`) is never seen. The
1271
+ bundle closes that gap in history, not in the system prompt: the first `read`,
1272
+ `write` or `edit` (its `tools` setting) of a file under a directory with an
1273
+ `AGENTS.md` steers that file into the running turn, at the loop's next
1274
+ boundary, as its own user message — with a first line that says which
1275
+ directory it belongs to:
1276
+
1277
+ ```
1278
+ AGENTS.md for pkg/inner/ (applies to work under pkg/inner/):
1279
+ <content>
1280
+ ```
1281
+
1282
+ - It steers the **outermost candidates first** (each directory from the
1283
+ touched file's directory up to, but not including, the checkout top), so a
1284
+ deep file can bring in several `AGENTS.md`s at once, outermost to nearest.
1285
+ - It **never re-injects**: each directory steers once per session, and a
1286
+ `before_turn` pass over the conversation rebuilds that set from the steers
1287
+ already there. A steer the model answered past (never injected) is absent,
1288
+ so it fires again on the next touch; a resume, a woken worker or a fork
1289
+ pick the set up from their messages. It never stops generation or thinking.
1290
+ - **Scope:** the touched file's own git root must be the session's checkout:
1291
+ a file in another repository, a nested repository or a submodule is
1292
+ ignored. Paths compare by `File.realpath`, so a symlinked cwd (a macOS
1293
+ `/tmp`) still matches.
1294
+ - Directories whose `AGENTS.md` is already in the system prompt (`ctx.prompt_files`:
1295
+ the chain and appended files) are skipped, as is a file at the checkout top
1296
+ (its `AGENTS.md` is the chain's first).
1297
+
1298
+ ```yaml
1299
+ # config.yml
1300
+ bundles:
1301
+ agents-md:
1302
+ tools: [read, write, edit] # the calls that count as a touch
1303
+ max_chars: 16000 # the content is cut here, with a "[cut at N
1304
+ # of M chars: read <absolute path> for the rest]" last line
1305
+ ```
1306
+
1307
+ `skip_agents_md: true` disables the bundle, as it does the chain's loading
1308
+ (see [Configuration](configuration.md#project-specific-description)).
1309
+
1257
1310
  ## The skills bundle
1258
1311
 
1259
1312
  `chi bundle install skills` (or the `dev` profile) installs the bundle shipped with chi
data/docs/sessions.md CHANGED
@@ -21,7 +21,11 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
21
21
 
22
22
  **Which chi a worker runs:** a new or woken worker runs the newest chi installed for this Ruby (`gem 'samagotchi', '>= 0.a'`, prereleases count), whoever starts it: a `chi web` or terminal still on an older chi starts it on the newest one, so a stale process doesn't spread old code. A worker already running keeps its version until it is restarted. Should the newest install fail to load, the worker runs the starter's own version and logs `boot_fallback` (its stdio is `/dev/null`). Under `bundle exec` the worker drops Bundler's env first, so it activates the newest gem too. Two consequences: a wrapper pin (`chi _0.17.0_ web`) pins only that process, not the workers it starts; and a source checkout (`bin/chi` in a worktree, `bundle exec`) starts its workers on its own `lib/`, but a session it made that is later woken by an installed chi (`chi web`, `chi send`) runs the installed code. The names an older starter hands a newer worker (the boot line, its env keys, `input/` files, the session JSON fields and the Bridge routes sessions use on each other) are pinned in `spec/worker_contract_spec.rb`.
23
23
 
24
- **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's `llm ctx` chip, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
24
+ **A worker outlives whoever started it:** a spawned worker leaves its spawner's process tree (it is forked again, in a session of its own, and reparented to init), so closing the terminal of a `chi web`, or a tool tearing down a background `chi send --wait` and its children, no longer takes the worker and its turn with it.
25
+
26
+ **A worker killed mid-turn:** while a turn runs, its worker keeps it on disk (`TurnCheckpoint`): the session file has the turn so far (the prompt from the turn's start, then the finished tool steps and lines merged into it) and `interrupted_turn: {id, started_at, at, iteration}`, written as the turn starts, at the loop's first iteration boundary and then at most every 10 s (`updated_at` moves with it). The turn's end saves the real messages without the mark. A worker that died without ending its turn (SIGKILL, a crash of the process) leaves the mark, and loses what came after its last written boundary: at most the last 10 s of steps, plus the step in flight (and the turn's metrics: `analytics.json` is written at a turn's end). The next worker to start for the session (a `chi send`, `--resume`, `--attach`, the web), or a `--no-shared` REPL resuming it, settles the turn before anything else (`TurnCheckpoint.settle`, log `interrupted_turn_settled`): a note `the previous turn was cancelled (worker-stopped) after Ns` goes at the tail, the status goes idle and the mark goes; `last_turn` stays the previous turn's (a new one saved before the turn a `chi send --wait` woke the worker for would end that wait at once). The session shows the turn's prompt and steps with that note; the dead turn is not run again, and the model reads on its next turn that it was cut.
27
+
28
+ **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`. A running turn is canceled over the Bridge first; the TERM stops whatever still runs as a Stop with reason `signal` and saves it (a spawned worker traps TERM, HUP and INT: `WorkerSignals`; a turn not ended within 3 s, 0.5 s after a stop or a second signal, is cut and the session saved as it was), and a stopped worker still saves its kept turn, since the marker, not the saved status, says stopped. Any TERM, HUP or INT does the same to a worker outside a stop: it leaves (log `worker stop reason=signal`) and the next send wakes a new one, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's `llm ctx` chip, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
25
29
 
26
30
  **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
31
 
@@ -68,9 +72,9 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
68
72
  **Ordering:**
69
73
 
70
74
  - `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.
71
- - 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). A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links, bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked; a link whose text is such a ref alone (github-pr's link to the PR diff) becomes a ref too, and its plain click still goes to the PR. A ref with more than one target shows a small `▾` on hover: it opens a menu of them, in order **PR diff** (a link's), **Open in editor**, **Show in turn** and **Copy path**, and a plain click runs the first (a link's own, else the editor, else show in turn). **Show in turn** scrolls to the tool row that touched the file (looked for in the ref's own step, then its own turn, then the rows before it, then the nearest after it; there, the last one showing the ref's line, else the last one), opens the steps, the stage's steps and the row's diff (or the read showing the line) and flashes the row and the line; an answer's or a step's ref and an approval card's path have it, not a row's own title or lines. It needs no editor, so a viewer without one (a phone on the LAN) gets an answer's refs with **Show in turn** as their click; a row's title and diff lines stay plain text there. On a touch screen a tap opens the menu. A ref with a target is a tab stop: Enter runs its first target, ↓ opens the menu (↑/↓ move, Enter picks, Esc closes back to the ref). Diff and read lines have their click only. The info bar copies `chi --attach <id>` for a terminal. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. Its `think` chip does the same for the thinking level (`/thinking`), and on a chat host its `reasoning` chip for the reasoning history (`reasoning all · session`, `/reasoning-history`; `session.reasoning_history`, null on a native host). A `/thinking` or `/reasoning-history` change that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the chips' Set sends that line itself; see [Changes that restart the prompt cache](configuration.md#changes-that-restart-the-prompt-cache)). It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`), and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new 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 (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new chat only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
75
+ - 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). A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links, bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked; a link whose text is such a ref alone (github-pr's link to the PR diff) becomes a ref too, and its plain click still goes to the PR. A plugin's refs on an answer (an `after_turn` hook's, see [Refs on the answer](hooks.md#refs-on-the-answer-display-only)) are paired with its links and text whatever the rows touched: they can link a file the session didn't touch, or an issue or a commit, and add their own actions to the ref's menu. A ref with more than one target shows a small `▾` on hover: it opens a menu of them, in order **PR diff** (a link's), **Open in editor**, **Show in turn** and **Copy path**, and a plain click runs the first (a link's own, else the editor, else show in turn). **Show in turn** scrolls to the tool row that touched the file (looked for in the ref's own step, then its own turn, then the rows before it, then the nearest after it; there, the last one showing the ref's line, else the last one), opens the steps, the stage's steps and the row's diff (or the read showing the line) and flashes the row and the line; an answer's or a step's ref and an approval card's path have it, not a row's own title or lines. It needs no editor, so a viewer without one (a phone on the LAN) gets an answer's refs with **Show in turn** as their click; a row's title and diff lines stay plain text there. On a touch screen a tap opens the menu. A ref with a target is a tab stop: Enter runs its first target, ↓ opens the menu (↑/↓ move, Enter picks, Esc closes back to the ref). Diff and read lines have their click only. The info bar copies `chi --attach <id>` for a terminal. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. Its `think` chip does the same for the thinking level (`/thinking`), and on a chat host its `reasoning` chip for the reasoning history (`reasoning all · session`, `/reasoning-history`; `session.reasoning_history`, null on a native host). A `/thinking` or `/reasoning-history` change that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the chips' Set sends that line itself; see [Changes that restart the prompt cache](configuration.md#changes-that-restart-the-prompt-cache)). It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`), and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new 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 (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new chat only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
72
76
  - 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).
73
- - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `day N` chip and rows, the info bar's ← → links, a refused continue), `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), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
77
+ - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `day N` chip and rows, the info bar's ← → links, a refused continue), `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), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
74
78
  - 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).
75
79
 
76
80
  **Test-session hygiene:**
data/docs/sub-agent.md CHANGED
@@ -55,8 +55,8 @@ command, folder, rule and reason, or `continue`, the step-limit question below.
55
55
 
56
56
  ## The step limit
57
57
 
58
- A turn that runs out of steps (`turn.max_iterations`, default 100) before it
59
- answers waits as a question of kind `continue` (`"limit": 100`, options
58
+ A turn that runs out of steps (`turn.max_iterations`, default 300) before it
59
+ answers waits as a question of kind `continue` (`"limit": 300`, options
60
60
  `Continue` and `Stop`). `answer_with` continues it:
61
61
  `chi answer ID --question QID --option Continue`, and the continued turn's
62
62
  reply (or its next question) comes back on that same wait. With
data/docs/testing.md CHANGED
@@ -107,8 +107,10 @@ sessions by model and by the model notes their prompt carried (the session file'
107
107
  prints the numbers a model note is meant to move: the place of the first edit (an `edit` or `write` call) and of the
108
108
  first commit (an `execute` call that runs `git [options] commit`) among the tool calls, commits per 100 steps (model
109
109
  requests), `execute` calls with a bare `&`, the longest run of calls with no edit, and what the session needed from
110
- outside: Continues (the turns in its `analytics.json` marked `continue: true` — the mark a file written after
111
- 2026-10-09 carries; an older file has none, and its turns with no prompt of their own are counted instead), steers (lines that reached a
110
+ outside: Continues (the step-limit Continues: the turns in its `analytics.json` marked `resumed: true`, the mark a
111
+ file written after 2026-10-11 carries; a file from 2026-10-09 on has `continue: true` only, which a reminder's and a
112
+ wake's turn carry too, and that is counted instead, record by record in a file that spans both; an older file has neither, and its turns with no prompt of their
113
+ own are counted), steers (lines that reached a
112
114
  running turn from the user, `chi send` or a parent, and a Continue's text), follow-up prompts and plugin nudges. A
113
115
  group has the medians of the places and runs, commits per 100 steps pooled and the counts summed. `--help` defines
114
116
  each column; `--model GLOB`, `--since DATE` and `--min-steps N` filter, `--json` gives JSON. It reads only and prints
@@ -23,9 +23,10 @@ module Samagotchi
23
23
 
24
24
  # A message as a model-facing reader may see it: without `display`.
25
25
  # The same object when it has none.
26
- # An empty-answer note's marker (TurnNote::EMPTY_ANSWER) is display
27
- # only too: it goes the same way.
28
- DISPLAY_ONLY = [KEY.to_s, "empty_answer"].freeze
26
+ # An empty-answer note's marker (TurnNote::EMPTY_ANSWER) and the
27
+ # answer's refs (AnswerRefs::KEY, display_refs) are display only too:
28
+ # they go the same way.
29
+ DISPLAY_ONLY = [KEY.to_s, "empty_answer", "display_refs"].freeze
29
30
 
30
31
  def self.strip(message)
31
32
  return message unless message.is_a?(Hash) && DISPLAY_ONLY.any? { |key| message.key?(key.to_sym) || message.key?(key) }