samagotchi 0.89.0 → 0.91.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -1
  3. data/bin/chi +28 -3
  4. data/docs/architecture.md +27 -5
  5. data/docs/bundle-authoring.md +36 -36
  6. data/docs/cli.md +4 -1
  7. data/docs/configuration.md +5 -3
  8. data/docs/internals/background-tasks.md +27 -2
  9. data/docs/internals/bundles.md +1 -1
  10. data/docs/internals/engine.md +2 -2
  11. data/docs/internals/prompt-caching.md +4 -0
  12. data/docs/sessions.md +2 -2
  13. data/lib/samagotchi/bridge/client_event.rb +15 -7
  14. data/lib/samagotchi/bridge.rb +7 -2
  15. data/lib/samagotchi/bundle_command/try/trace.rb +25 -8
  16. data/lib/samagotchi/bundle_command.rb +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/self_map.md +5 -0
  19. data/lib/samagotchi/client_id.rb +3 -0
  20. data/lib/samagotchi/config.rb +3 -0
  21. data/lib/samagotchi/context_note.rb +13 -6
  22. data/lib/samagotchi/engine.rb +9 -3
  23. data/lib/samagotchi/last_turn.rb +3 -2
  24. data/lib/samagotchi/llm/model_result.rb +2 -2
  25. data/lib/samagotchi/task_finish_note.rb +90 -0
  26. data/lib/samagotchi/task_watch.rb +185 -0
  27. data/lib/samagotchi/terminal_ui/attached_loop.rb +19 -4
  28. data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
  29. data/lib/samagotchi/tool_declarations.rb +16 -0
  30. data/lib/samagotchi/tool_runner.rb +1 -0
  31. data/lib/samagotchi/tools/builtins.rb +5 -1
  32. data/lib/samagotchi/tools/task_record.rb +9 -0
  33. data/lib/samagotchi/tools/task_wait.rb +10 -3
  34. data/lib/samagotchi/version.rb +1 -1
  35. data/lib/samagotchi/web/public/notify.js +4 -4
  36. data/lib/samagotchi/web/public/turn_events.js +16 -8
  37. data/lib/samagotchi/worker.rb +177 -7
  38. data/lib/samagotchi/worker_idle_exit.rb +11 -3
  39. data/lib/samagotchi/worker_inbound.rb +18 -4
  40. data/lib/samagotchi/worker_wakes.rb +14 -4
  41. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f7e58a54e40f49c1617636172def63d9e6a3eaa3384ac33e26c0b55c75332555
4
- data.tar.gz: 063a36d57a613a117e62b2882883c22e1ce413044c1e6d18e66aa1c8af0d6d83
3
+ metadata.gz: e4a20c3555c1f3e592919853f9dc59a5067e7e42251d11dad2f3be844f118f90
4
+ data.tar.gz: 817bb797b870985aeb21789377eae7770225265d03c527bb9218a1ecd3c2376e
5
5
  SHA512:
6
- metadata.gz: 879572f97fd05fdff6f15d4c91c3f3ce30d480ff0022b1ad3fb0220423c124004c67908f97e85ad6309302720671938cc90da80c6315de0134c9240a9b477feb
7
- data.tar.gz: 0faf651033f823e4eadc87926d4c370e3e8eb714a4f8106d735110bfe7c8517972a70a4e960448ccda3b8ac1679c796e93b587070255803eb57e24e8c984a2b1
6
+ metadata.gz: 74b69c624e9e43882940e48bdf00d0bedb1172fd53f4094c30ef4250e15a4e7ae9fedba4fc9704b923d3b622bcad47950432d450c1c3c219f42a54186dacad58
7
+ data.tar.gz: ac112286f3eaa6364bd68cee410e4b580f8246215222a17ecd3781f2980d828eb12d47cc3b64c800e201f9a81662a0c2ee61627a1c485e4249f409c771b65744
data/CHANGELOG.md CHANGED
@@ -8,6 +8,42 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.91.0] - 2026-10-19
12
+
13
+ ### Added
14
+
15
+ - A background task the model left running (`task_create`, an `execute` handed over at its limit) that finishes
16
+ unseen wakes the idle session for a short turn opened by a `TASK FINISHED` note (exit code, duration, log tail).
17
+ `session.task_wakes`: `wake` (default), `queue` (a note for the next turn) or `off`.
18
+
19
+ ### Changed
20
+
21
+ - The bundle-authoring doc says `chi bundle try` is the live check; its `chi send` recipe for checking the installed
22
+ copy is gone.
23
+
24
+ ### Fixed
25
+
26
+ - `chi bundle try --json` reports always carry `timed_out`; a run cut by `--timeout` lists the tool calls it made
27
+ (name, status and duration; their arguments aren't saved) instead of an empty `tools`.
28
+
29
+ Update with `chi update` (the system bundle 0.91.0), then restart `chi web`.
30
+
31
+ ## [0.90.0] - 2026-10-19
32
+
33
+ ### Changed
34
+
35
+ - SSE `tool_call_started` carries no `call`, `turn_completed` no `result`; read `tool`/`params`/`view` and
36
+ `turn_summary.output`. `?raw=1` on the worker's stream shows them.
37
+ - docs/bundle-authoring.md (the guide chi reads to write a bundle) makes edit → `check --fix` → `test` → `try` the
38
+ loop, with `install --dev .` while iterating and `try` as the live check; its test example runs as written.
39
+
40
+ ### Fixed
41
+
42
+ - `chi -p` followed by one of chi's own flags (`chi -p --non-interactive "text"`, `-p -v`) now exits 2 with a usage
43
+ error instead of taking the flag as the prompt.
44
+
45
+ Update with `chi update` (the system bundle 0.90.0), then restart `chi web`.
46
+
11
47
  ## [0.89.0] - 2026-10-19
12
48
 
13
49
  ### Added
@@ -3513,7 +3549,9 @@ and long-lived sessions.
3513
3549
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3514
3550
  hotkey panel that send selected text or the clipboard to your sessions.
3515
3551
 
3516
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.89.0...HEAD
3552
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.91.0...HEAD
3553
+ [0.91.0]: https://github.com/dm1try/samagotchi/compare/v0.90.0...v0.91.0
3554
+ [0.90.0]: https://github.com/dm1try/samagotchi/compare/v0.89.0...v0.90.0
3517
3555
  [0.89.0]: https://github.com/dm1try/samagotchi/compare/v0.88.0...v0.89.0
3518
3556
  [0.88.0]: https://github.com/dm1try/samagotchi/compare/v0.87.0...v0.88.0
3519
3557
  [0.87.0]: https://github.com/dm1try/samagotchi/compare/v0.86.0...v0.87.0
data/bin/chi CHANGED
@@ -74,6 +74,7 @@ options = { verbose: false, no_interrupt: Samagotchi::Config.get("no_interrupt")
74
74
  cli_overrides = {}
75
75
 
76
76
  # Strict kebab enforcement: --recap_base_url is unknown (OptionParser would otherwise accept _ as -)
77
+ known = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token] + Samagotchi::Config.cli_entries.map(&:cli_flag)
77
78
  ARGV.each do |arg|
78
79
  next unless arg.start_with?("--") && arg.include?("_")
79
80
 
@@ -82,7 +83,6 @@ ARGV.each do |arg|
82
83
  base = arg.split("=", 2).first
83
84
  # If dashed version is a known flag (registry or hardcoded), reject underscore variant
84
85
  dashed = base.tr("_", "-")
85
- known = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token] + Samagotchi::Config.cli_entries.map(&:cli_flag)
86
86
  if known.include?(dashed) || known.include?(base)
87
87
  warn "Unknown option: #{arg} (did you mean #{dashed}?)"
88
88
  exit 1
@@ -191,8 +191,33 @@ end
191
191
  # value (-p, --model …: the parser's own list) keeps its next word, so
192
192
  # `chi -p "--no-tool=x is broken"` is a prompt. A tool name never starts
193
193
  # with "-": `--no-tool --` or `--no-tool --model` is a missing argument.
194
- takes_value = parser.instance_variable_get(:@stack).flat_map do |list|
195
- list.list.select { |switch| switch.is_a?(OptionParser::Switch::RequiredArgument) }.flat_map { |switch| switch.long + switch.short }
194
+ switches = parser.instance_variable_get(:@stack).flat_map { |list| list.list }
195
+ .select { |switch| switch.is_a?(OptionParser::Switch) }
196
+ switch_flags = switches.flat_map { |switch| switch.long + switch.short }
197
+ known_flags = (known + switch_flags).uniq
198
+ takes_value = switches.select { |switch| switch.is_a?(OptionParser::Switch::RequiredArgument) }
199
+ .flat_map { |switch| switch.long + switch.short }
200
+ reject_prompt_flag = lambda do |flag, value|
201
+ if known_flags.include?(value)
202
+ warn "Error: #{flag} needs the prompt text right after it (got the flag #{value}) (see chi --help)"
203
+ exit 2
204
+ end
205
+ end
206
+ index = 0
207
+ while index < ARGV.size && ARGV[index] != "--"
208
+ arg = ARGV[index]
209
+ if %w[-p --prompt].include?(arg)
210
+ reject_prompt_flag.call(arg, ARGV[index + 1])
211
+ index += 2
212
+ elsif arg.start_with?("--prompt=")
213
+ reject_prompt_flag.call("--prompt", arg.delete_prefix("--prompt="))
214
+ index += 1
215
+ elsif arg.start_with?("-p") && arg != "-p"
216
+ reject_prompt_flag.call("-p", arg.delete_prefix("-p").delete_prefix("="))
217
+ index += 1
218
+ else
219
+ index += takes_value.include?(arg) ? 2 : 1
220
+ end
196
221
  end
197
222
  missing_no_tool = lambda do
198
223
  warn "Error: missing argument: --no-tool (see chi --help)"
data/docs/architecture.md CHANGED
@@ -187,10 +187,15 @@ higher-level events so UIs get clean turn boundaries without inferring them:
187
187
  model's thinking, `0` = never; plus `continue: true` for a continue turn, `resumed: true` for a step-limit Continue,
188
188
  `images:` with the prompt's image refs)
189
189
  - `:turn_completed` — `{ result:, turn_summary:, display_pending: }` (`result`: the final
190
- `LLM::ModelResult`; `display_pending`: `after_turn` hooks may still present the answer)
190
+ `LLM::ModelResult`; Engine event (clients get it without `result`); `display_pending`: `after_turn` hooks may still
191
+ present the answer)
191
192
  - `:turn_canceled` — `{ cancellation_reason:, cancelled_by:, duration_ms: }` (`cancelled_by`: the bundle or hook label that
192
193
  stopped it, for `cancellation_reason: :hook`; else nil)
193
194
 
195
+ These are the Engine's raw events. The worker's SSE projection omits `call` from
196
+ `:tool_call_started` and `result` from `:turn_completed`; the answer remains in
197
+ `turn_summary.output`.
198
+
194
199
  A turn started with `origin:` carries it on each of these (and `:turn_failed`).
195
200
 
196
201
  Every event is a `Hash` with a `:type` symbol key; the sink must not raise (the Engine
@@ -246,7 +251,8 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
246
251
  `session_state_snapshot`. `?snapshot=1` joins with a snapshot frame instead of a replay;
247
252
  `?client_id=` names whose stream it is (`Bridge#open_streams_except`, used by `POST /exit`).
248
253
  Each event goes out as clients get it (`Bridge::ClientEvent.project`, in `SSEWriter#write_event`, live
249
- and replayed alike); only a `generation_chunk` changes, to chi's own format (the chunk contract below).
254
+ and replayed alike); `generation_chunk`, `tool_call_started` and `turn_completed` are projected to the
255
+ contracts below. The ring retains raw events for replay and `?raw=1`.
250
256
  `?raw=1` writes events as the engine emitted them, for debugging on the worker's port: `chi web`'s
251
257
  `/api/sessions/:id/stream` never passes it on (`Web::App#stream_query`).
252
258
  An attached terminal's stream (`EventStream` with `rediscover:`) looks for the session's live
@@ -265,6 +271,22 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
265
271
  "event_seq":812}
266
272
  ```
267
273
 
274
+ A `tool_call_started` frame keeps its row fields but omits the model's `call`:
275
+
276
+ ```
277
+ event: tool_call_started
278
+ data: {"type":"tool_call_started","iteration":2,"call_index":1,"tool":"write",
279
+ "params":"path=notes.txt","event_seq":812}
280
+ ```
281
+
282
+ A `turn_completed` frame omits `result`; `turn_summary.output` carries the answer:
283
+
284
+ ```
285
+ event: turn_completed
286
+ data: {"type":"turn_completed","turn_summary":{"output":"Done."},
287
+ "display_pending":false,"event_seq":813}
288
+ ```
289
+
268
290
  `counts` is `ContextUsage.normalize` of the provider's payload without `ctx_pct` (normalize computes it
269
291
  without the session's LLM context budget; a client counts `total_tokens` against the window or budget, as
270
292
  `ContextStatus#generation_done` does for the TUI) and with its nil keys left out: cumulative for the
@@ -337,8 +359,8 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
337
359
  - `POST /session/:id/exit` — ask the worker to exit now (`{client_id:}`). The worker checks with
338
360
  the event log held (`WorkerIdleExit#hold_for_request`): `200 {status: "exiting"}` and it leaves
339
361
  like an idle exit, or `409 {status: "held", reason:}` with `turn_running`, `input_queued`,
340
- `continue_offered`, `client_connected` (a stream not named by the asker), `reminders` or
341
- `starting`.
362
+ `continue_offered`, `client_connected` (a stream not named by the asker), `reminders`,
363
+ `background_tasks` (with `tasks: [{id, command}]`: tasks a finish wake watches) or `starting`.
342
364
  - `GET /session/:id/state` — `session_state_snapshot` (JSON).
343
365
  - `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.
344
366
  - `GET /session/:id/snapshot` — the snapshot frame's content as one request (the web server renders
@@ -559,7 +581,7 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
559
581
 
560
582
  | Component | Main file(s) | What it is |
561
583
  |---|---|---|
562
- | Worker | `worker.rb` (`worker_idle_exit.rb`, `worker_inbound.rb`, `worker_signals.rb`) | A session's background process: builds the `Engine` and `Bridge`, runs queued turns, exits when idle. A spawned one traps TERM, HUP and INT (`WorkerSignals`, installed by `run_session_loop` when `SAMAGOTCHI_WORKER_PROCESS=1`): the running turn stops as a Stop with reason `signal`, is saved, and the worker leaves (`:signal`, exit 0; a new worker only for input already queued). A running turn is checkpointed at its iteration boundaries (`TurnCheckpoint`, the `interrupted_turn` mark), and the next worker settles a turn its predecessor died in (`TurnCheckpoint.settle`). The session is saved from the turn's thread, Bridge threads and a signal's thread; `Session#save` takes a per-session lock around its snapshot and write, so an older snapshot never lands over a newer one. |
584
+ | Worker | `worker.rb` (`worker_idle_exit.rb`, `worker_inbound.rb`, `worker_signals.rb`, `worker_wakes.rb`, `task_watch.rb`) | A session's background process: builds the `Engine` and `Bridge`, runs queued turns and the wake turns nobody typed (delegate reports, a background task that finished unseen: [Finish wake](internals/background-tasks.md#finish-wake), attached context), exits when idle. A spawned one traps TERM, HUP and INT (`WorkerSignals`, installed by `run_session_loop` when `SAMAGOTCHI_WORKER_PROCESS=1`): the running turn stops as a Stop with reason `signal`, is saved, and the worker leaves (`:signal`, exit 0; a new worker only for input already queued). A running turn is checkpointed at its iteration boundaries (`TurnCheckpoint`, the `interrupted_turn` mark), and the next worker settles a turn its predecessor died in (`TurnCheckpoint.settle`). The session is saved from the turn's thread, Bridge threads and a signal's thread; `Session#save` takes a per-session lock around its snapshot and write, so an older snapshot never lands over a newer one. |
563
585
  | Config | `config.rb` | Every setting as an `Entry`: env, config.yml and CLI layers ([configuration.md](configuration.md)). |
564
586
  | Paths | `paths.rb` (`Paths`) | The XDG base dirs chi's files live under, read from the env on every call: `Paths.state_home` (`$XDG_STATE_HOME`, else `~/.local/state`) and `Paths.config_home` (`$XDG_CONFIG_HOME`, else `~/.config`), both defaults under `Dir.home`; a blank variable counts as unset, a relative one is made absolute from the current directory. `Paths.tilde` shows a path with `~` for the home folder. `SessionManager` hands a spawned worker the spawner's `XDG_STATE_HOME` / `XDG_CONFIG_HOME`, when set, made absolute, so a relative one isn't read from the session's folder. |
565
587
  | MemoryBundle | `memory_bundle.rb`, `memory_bundle/{installer,builder,installed_bundle,plugin_ref,bundle_hook}.rb` (`Installer`, `Builder`, `InstalledBundle`, `PluginRef`, `BundleHook`) | Installing, building and reading bundles of memories, hooks and plugins. |
@@ -9,23 +9,22 @@ files, what a hook sees, how to test, and what goes wrong. The full references a
9
9
  ```
10
10
  chi bundle new my-bundle # ./my-bundle: manifest.yml, plugin.rb, test/my_bundle_test.rb, README.md
11
11
  cd my-bundle # edit plugin.rb and the test
12
- chi bundle test # the tests, on chi's own Ruby and lib (exit status: the tests')
12
+ chi bundle install --dev . # once while iterating: link this folder; edits load in the next session
13
13
  chi bundle check --fix # manifest, files, shas (--fix rewrites them), requires_chi, install + load in a sandbox
14
+ chi bundle test # tests on chi's own Ruby and lib (exit status: the tests')
14
15
  chi bundle try . -p "Say hello" # one real model turn; trace the bundle in an isolated install sandbox
15
- chi bundle install . # into the user's chi; a NEW session loads it (a running one doesn't)
16
- chi bundle install --dev . # or as a link to this folder: edits apply at the next session, no re-install
16
+ chi bundle uninstall my-bundle && chi bundle install . # at the end: leave dev mode, copy it in
17
17
  ```
18
18
 
19
- While you write it, `install --dev .` (once; `uninstall NAME` leaves dev mode) then try it in chi: a new session
20
- loads the plugin and hooks from the folder as they are, with no sha check (that bundle only; memories are copied).
21
- Re-install with `--force` from the same PATH to pick up memory edits.
22
- Dev-linked scripts aren't chmodded, since that would change the source. Chi warns with a `chmod +x PATH/scripts/x`
23
- hint when a listed script isn't executable; set that bit on the source script.
19
+ The loop is **edit → `check --fix` → `test` → `try`**. `install --dev .` links the folder once, so a new session
20
+ loads your edits with no reinstall (a running one doesn't; `try` doesn't need the link: it installs into its own
21
+ sandbox). Memories are copied: re-install with `--force` from the same PATH to pick up memory edits. At the end,
22
+ `uninstall NAME` leaves dev mode and a plain `install .` copies the bundle in (chi refuses it over a dev link).
23
+ Dev-linked scripts aren't chmodded: chi warns with a `chmod +x PATH/scripts/x` hint; set the bit on the source.
24
24
 
25
- `check` prints one finding per line, `error|warn|info CODE file[:line]: message (fix: hint)`, and exits 1 on any
26
- error. Run it after every edit: an install takes exactly what it checks. It installs into a temp sandbox, not
27
- the user's config or state, but `check` and `test` run the bundle's plugin and hooks in their own process, as
28
- written: run them on code you'd install.
25
+ `check` prints one finding per line (`error|warn|info CODE file[:line]: message (fix: hint)`), exits 1 on any error
26
+ and installs into a temp sandbox, not the user's config or state; run it after every edit. `check` and `test` run
27
+ the bundle's plugin and hooks in their own process, as written: run them on code you'd install.
29
28
 
30
29
  ## Trying a bundle
31
30
 
@@ -87,29 +86,36 @@ that list is never fired (`check` warns). The two a bundle wants most, for an `e
87
86
  Match on `result:`, not `output:`. An execute's result is its `stdout:` and `stderr:` blocks (each left out when
88
87
  empty), then `exit: N` (`exit: 0 (no output)` when both were empty); a non-zero exit makes `status:` `"error"`.
89
88
  Act through `ctx`: `ctx.notify(text, level: :warn)`, `ctx.steer(text)` (a line the model reads before its next
90
- step), `ctx.card(…)`, `ctx.stop_turn(reason)`, `ctx.ask_user(…)`, `ctx.data_dir` (the bundle's own folder),
89
+ step), `ctx.card(…)`, `ctx.stop_turn(reason)`, `ctx.ask_user(…)`, `ctx.data_dir` (the bundle's state folder),
91
90
  `ctx.cwd`, `ctx.log`. In a test, each recorded notice, steer or stop names its hook as `"plugin.rb (bundle
92
91
  my-bundle)"`, the plugin's label (a `hooks/x.rb` file's is `"x.rb (bundle my-bundle)"`).
93
92
 
94
93
  ## Testing it
95
94
 
96
- `chi bundle new` writes a Minitest test that passes as written: copy its shape. The kit (`samagotchi/testing`)
97
- loads the bundle's source through chi's own loaders, in a sandbox (temp HOME, config and state), and records what
98
- it did at chi's edges:
95
+ `chi bundle new` writes a Minitest test that passes as written: copy its shape. The kit (`samagotchi/testing`) loads
96
+ the bundle's source through chi's own loaders in a sandbox (temp HOME, config and state) and records what it did:
97
+ `load_bundle`, then `bundle.command` / `bundle.fire`, and the effects in `bundle.notices`. `bundle.ctx.data_dir` and
98
+ `sandbox.state_dir` are that test's temporary state, never the user's (the scaffold's plugin, tested):
99
99
 
100
100
  ```ruby
101
- bundle = load_bundle(BUNDLE, settings: { "greeting" => "hi" }) # in a Samagotchi::Testing::Case test
102
- bundle.command("/my-bundle chi") # → what the command returned
103
- bundle.fire(:after_tool_call, tool: "execute", status: "error", result: "exit: 1 (no output)", call: { … })
104
- bundle.tool_call({ name: "execute", content: "rm -rf /" }).deny? # before_tool_call hooks, through the real gate
105
- bundle.tool("my_tool", path: "a.txt") # a chi.tool, as the model calls it
106
- bundle.notices, bundle.steers, bundle.cards, bundle.stops, bundle.questions # what it did
107
- bundle.answer({ selected: "yes" }); bundle.model_answers << "text" # ask_user / ask_model answers
108
- bundle.http.stub(:get, "https://api.example/x", json: { ok: true }) # ctx.http, never the network
109
- bundle.set(cwd: dir, running: false); sandbox.config(hash); sandbox.fake_executable("gh", "echo '{}'")
110
- bundle.install # installed as chi installs it: warnings, load failures
101
+ require "minitest/autorun"
102
+ require "samagotchi/testing/minitest"
103
+
104
+ class MyBundleTest < Samagotchi::Testing::Case
105
+ def test_command_and_failed_execute_notice
106
+ bundle = load_bundle(File.expand_path("..", __dir__), settings: { "greeting" => "hi" })
107
+ assert_equal "hi, there", bundle.command("/my-bundle")
108
+ bundle.fire(:after_tool_call, tool: "execute", status: "error", call: { name: "execute", content: "false" },
109
+ result: "exit: 1 (no output)", output: "[execute]\nexit: 1 (no output)")
110
+ assert_equal "the command exited 1", bundle.notices.last.text
111
+ assert_equal File.join(sandbox.state_dir, "plugins", bundle.name), bundle.ctx.data_dir
112
+ end
113
+ end
111
114
  ```
112
115
 
116
+ `bundle.tool_call` runs guarded hooks; `bundle.tool` calls a registered tool. `bundle.steers`, `cards`, `stops` and
117
+ `questions` record effects; `bundle.answer` / `model_answers` provide `ask_user` / `ask_model` answers.
118
+ `bundle.http.stub` stubs `ctx.http`; `sandbox.config`, `sandbox.fake_executable` and `bundle.install` cover setup.
113
119
  The full list is in [plugins.md, Testing a bundle](plugins.md#testing-a-bundle-experimental). The kit is
114
120
  experimental (it may change before chi 1.0). If a test needs a fake of chi's own (a stub of a `Samagotchi::`
115
121
  class, a hand-built `ctx`), that is a gap in the kit: say so to the user rather than reaching into chi.
@@ -132,13 +138,7 @@ class, a hand-built `ctx`), that is a gap in the kit: say so to the user rather
132
138
 
133
139
  ## Verifying it live
134
140
 
135
- After `chi bundle install .`, one real session shows it works with the user's chi:
136
-
137
- ```
138
- chi send --new --wait --format json --dir /tmp/try -m "/my-bundle chi" # the JSON names the session id
139
- grep plugin_loaded ~/.local/state/samagotchi/samagotchi.log | tail -1 # bundle=my-bundle commands=1 hooks=1
140
- chi sessions delete --force SESSION_ID # leave no test session behind
141
- ```
142
-
143
- (`$XDG_STATE_HOME/samagotchi/samagotchi.log` when `XDG_STATE_HOME` is set.) Then tell the user what it adds,
144
- how to use it, and that a new session picks it up.
141
+ `chi bundle try . -p "..."` **is** the live check: a real model turn on the installed bundle, with its trace
142
+ (tools, plugin, notices, cards). Don't repeat it with `chi send` sessions on the user's own install. After the
143
+ final `chi bundle install .`, `chi bundle list` showing the bundle and its version is enough. Then tell the user
144
+ what it adds, how to use it, and that a new session picks it up.
data/docs/cli.md CHANGED
@@ -149,7 +149,10 @@ non-interactive mode. `--keep` retains the temporary sandbox and prints its path
149
149
  one experimental JSON object to stdout and human trace lines to stderr; its keys are `session_id`, `exit_code`,
150
150
  `answer`, `tools` (`index`, `name`, `args`, `status`, `duration_ms`), `notices`, `cards`, `questions`,
151
151
  `interactive_blocked`, `used_memories`, `tokens` (`prompt`, `completion`), `cost`, `cost_is_estimate`, `model`,
152
- `sandbox`, `denied` (1-based tool indexes) and `stderr_tail` (last 4,000 characters). Exit status: 2 for usage,
152
+ `sandbox`, `denied` (1-based tool indexes), `stderr_tail` (last 4,000 characters) and `timed_out`. A run past
153
+ `--timeout` is interrupted like a Ctrl-C and still prints the trace it got to, with `timed_out: true` (text output:
154
+ the trace lines, then `timed out after N seconds` on stderr). That turn's messages aren't saved, so its tool calls
155
+ come from their timing records: name, status and duration, with empty `args`. Exit status: 2 for usage,
153
156
  1 for install/turn failure or timeout, 130 for Ctrl-C, otherwise the child's status.
154
157
 
155
158
  `try` runs the bundle's code and the model's tools with your real HOME, account and keys. Its temporary working
@@ -73,7 +73,8 @@ session:
73
73
  # keep_empty: false # true keeps sessions nothing happened in (default: deleted when left)
74
74
  # max_children: 4 # running sessions one session may have delegated at a time (the delegate tool)
75
75
  # delegate_reports: wake # a child's reply reaches its parent by itself: wake (an idle parent runs a turn), queue, off
76
- # max_wakes: 10 # wake turns in a row (delegate reports, context, peers) with no human input
76
+ # task_wakes: wake # a background task finishing unseen: wake (an idle session runs a turn), queue, off
77
+ # max_wakes: 10 # wake turns in a row (delegate reports, finished tasks, context, peers) with no human input
77
78
 
78
79
  # Baseline memories preloaded into the system prompt (same shape as --memory);
79
80
  # name entries you have, or chi warns at each start.
@@ -160,7 +161,7 @@ or `chi web` restart.
160
161
  | `hooks`, `bundles` | Worker start | Hooks, plugins and their bundle settings load with the engine. |
161
162
  | `recap.*` | Worker start | The engine builds its recap job once. |
162
163
  | `session.shared` | Next `chi` invocation | It selects attached-worker or in-process mode for that invocation. |
163
- | `session.idle_exit_minutes`, `session.keep_empty`, `session.max_children`, `session.delegate_reports`, `session.max_wakes` | Worker start or next matching lifecycle decision | Existing workers keep their idle-exit timer; the other values are read for the next corresponding decision. |
164
+ | `session.idle_exit_minutes`, `session.keep_empty`, `session.max_children`, `session.delegate_reports`, `session.task_wakes`, `session.max_wakes` | Worker start or next matching lifecycle decision | Existing workers keep their idle-exit timer; the other values are read for the next corresponding decision. |
164
165
  | `session.retention_*`, `session.max_count`, `session.keep_status`, `session.sweep_interval_hours` | Next retention sweep | `chi web` sweeps on its configured schedule and on `GET /api/sessions`; `chi sessions prune` uses the values for that invocation. The sweep interval is checked on the next hub tick. |
165
166
  | `web.*` | `chi web` start | Restart `chi web` to change its bind address, port or page settings. |
166
167
  | `log.*` | Process start | Each process holds its configured log sink and level after first use; restart that process. |
@@ -1622,7 +1623,8 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
1622
1623
  | `session.keep_empty` | `false` | | Keep sessions nothing happened in. |
1623
1624
  | `session.max_children` | `4` | | Running delegated sessions one session may have. |
1624
1625
  | `session.delegate_reports` | `wake` | | When a delegate child ends a turn, its reply reaches the parent: `wake` runs an idle parent's turn for it, `queue` waits for the parent's next turn, `off` leaves it to `delegate_result`. |
1625
- | `session.max_wakes` | `10` | | Wake turns a session runs in a row with no human input: for delegate reports, attached context and peer answers; past it reports wait for the next turn and context updates arrive as notes. |
1626
+ | `session.task_wakes` | `wake` | | When a background task the session started (`task_create`, an `execute` handed over at its limit) finishes and the model hasn't seen it finish: `wake` runs an idle session's turn opened by a `TASK FINISHED` note, `queue` leaves the note for the next turn, `off` leaves it to `task_wait`. See [Finish wake](internals/background-tasks.md#finish-wake). |
1627
+ | `session.max_wakes` | `10` | | Wake turns a session runs in a row with no human input: for delegate reports, finished background tasks, attached context and peer answers; past it reports wait for the next turn and task finishes and context updates arrive as notes. |
1626
1628
  | `session.retention_days` | `14` | yes | Delete sessions not updated for N days; `0` = forever. See [Sessions](sessions.md). |
1627
1629
  | `session.max_count` | `500` | yes | Keep the newest N; `0` = uncapped. |
1628
1630
  | `session.keep_status` | none | yes | Comma list of statuses never pruned (a session a worker or `chi` has open is never pruned anyway). |
@@ -19,7 +19,7 @@ Behavior:
19
19
 
20
20
  - `execute`, `task_create` and the `!cmd` shell bang run the command in a non-login shell: `/bin/sh -c` on Linux; on macOS, when `/bin/zsh` is there, `/bin/zsh --emulate sh +o ignore_braces +o sh_glob -o bash_rematch +o bsd_echo -c` (macOS's `/bin/sh` is bash 3.2, which can't parse an apostrophe in a heredoc inside `"$( )"`, the usual commit message shape). That mode reads no rc files and keeps POSIX word splitting, unmatched globs left as is, `{1..3}`, `[[ … =~ (…) ]]` with `BASH_REMATCH`, arrays, `$'…'`, `&>` and echo expanding `\t`. Bash-only builtins and variables (`shopt`, `BASH_VERSION`, `${!prefix*}`, `read -p`) are not there.
21
21
  - Task metadata and output are persisted under `tmp/tasks/`: each task's `tmp/tasks/<id>/task.json` is a `TaskRecord` (`lib/samagotchi/tools/task_record.rb`, a value object: `.from_file` reads it, `#to_file` is the JSON object written, an update is a `#with`). Its keys keep their order; `stderr_path` and `handed_over_by` (a handed-over execute) and `stop_requested_by` (a stop under way or done) are left out until set. `bundles/slow-commands` reads these files on its own, so the shape is a contract.
22
- - A task record keeps its process's pid and start time (`pid_started`). Before `task_stop` signals, the pid must still lead its own process group and must not have started at another time; otherwise the pid was reused by another process, nothing is signalled and the task ends as failed. A start time that can't be read at that moment (a failed `ps`) falls back to the group check, as does a record without `pid_started` (from an older chi). `task_get`, `task_list`, `task_wait`'s polls and the running-task lists (a cancelled turn's note, a worker's idle exit) make the same check, so a task whose pid was reused reads as exited (failed) at once, not as running until a `task_stop`; on macOS that is one `ps` per running task per check (~5 ms).
22
+ - A task record keeps its process's pid and start time (`pid_started`). Before `task_stop` signals, the pid must still lead its own process group and must not have started at another time; otherwise the pid was reused by another process, nothing is signalled and the task ends as failed. A start time that can't be read at that moment (a failed `ps`) falls back to the group check, as does a record without `pid_started` (from an older chi). `task_get`, `task_list`, `task_wait`'s polls and the running-task lists (a cancelled turn's note, a worker's restart hold, the finish wake's watch) make the same check, so a task whose pid was reused reads as exited (failed) at once, not as running until a `task_stop`; on macOS that is one `ps` per running task per check (~5 ms).
23
23
  - A stop (`task_stop`, the user's Stop of an `execute`, the execute limit in kill mode) TERMs the task's process group, then KILLs it after a grace. The groups its processes started are stopped too: a GNU `timeout N cmd` (without `--foreground`) moves itself and its command to a group of its own. `ProcessGroup.stop` finds them with one `ps -A -o pid=,ppid=,pgid=` (~40 ms on macOS) before the TERM and again before the KILL, walking down from the group's members. A process whose parent already exited (reparented to init, e.g. a setsid'd `cmd &`, or a backgrounded `timeout N cmd &` after its shell exited) is not found and runs on.
24
24
  - Task listing is workspace-scoped (current project only).
25
25
  - `execute` runs its command with stdout and stderr in two logs under `$XDG_STATE_HOME/samagotchi/execute/<id>/` (always an absolute path: a relative `XDG_STATE_HOME` is taken from the directory chi started in; outside the workspace: `git status` and `rm -rf tmp` never see them), stdin `/dev/null` and the same `exit_code` wrapper as a task. Its own copies of the logs hold a shared `flock` across the spawn: while a fresh exclusive `flock` fails, some process the command started still has them open. When the shell exits, a background child still holding them (`server &`) gets 1.5 s, then its group is stopped; one holding none (`nohup server >log 2>&1 &`) is left alone. A holder that left the group (setsid) is out of the stop's reach: it runs on, the result ends with the "left running" hint, and its dir stays for it to write to (the sweep removes it once no one holds the logs). Otherwise a finished command's dir is removed. Known gap on Linux: a command that writes through `/dev/stdout` or `/dev/stderr` (`echo b >/dev/stdout`) reopens the log file itself with `O_TRUNC`, so the output before it is lost (macOS dups the descriptor; the fix would be a relay pipe). The check needs the state dir on a local file system: over NFS, `flock` is emulated with `fcntl` locks, which children don't inherit.
@@ -27,11 +27,36 @@ Behavior:
27
27
  - A stop of an `execute` (the user's Stop, the limit in kill mode) is done once the shell is reaped and nothing holds the logs: a child that ignores TERM after its shell died gets the KILL.
28
28
  - A command still running at `execute.timeout_sec` is handed over as a task (`execute.on_timeout: task`, the default; `kill` stops it instead, and the `!cmd` bang always kills): the result starts `Still running at the 120s limit: moved to a background task, not killed.`, then `task_id: …` and the tails of both logs so far. A timeout during the 1.5 s background grace is a kill (the shell is gone, nothing to hand over). If the record can't be written, the command is killed. `TaskRuntime.adopt` writes a record for the process execute already started, with `handed_over_by: "execute"`. Its logs stay where execute wrote them, so the record's paths are absolute: `output_path` (stdout), `stderr_path` (stderr, a log of its own) and `exit_code_path` (a `task_create` record's `exit_code_path` is absolute too; its `output_path` is relative). `started_at` (and `created_at`) is the spawn time, not the hand-over's; `pid_started` is read at the hand-over, while execute's detach waiter is still running. From there it is an ordinary task: `task_wait`, `task_get`, `task_list` and `task_stop` treat it as any other. Its dir gets a `task` back-pointer file holding the record's absolute path. Once per process, the first `execute` sweeps `…/execute/`: a dir whose logs no process holds is removed when it has no back-pointer and is a day old (chi died mid-command), when its record is gone, or when it is 14 days old. The records live in each project's `tmp/tasks`, which the state dir can't list: hence the back-pointer.
29
29
  - `task_wait` and `task_get` show `stderr_path` when the record has one; `task_wait`'s output tail is followed by a `stderr_tail:` of at most 10 lines when that log isn't empty. A `task_create` task has one log (stdout and stderr together) and no `stderr_path`. A `task_wait` result longer than `max_tool_output_chars` keeps its header and the tail's end, with a gap line naming `output_path`, where the whole log already is (and `stderr_path`, when the record has one) (`ToolOutputCut`'s `:head_tail_ref`; nothing is copied; see [Tool output guardrails](tool-guardrails.md#the-runners-cut-max_tool_output_chars)).
30
- - Which tasks are a session's own (a cancelled turn's note, the turn-end note, a worker's idle exit, the web's stop-task button): the ids its `task_create` results and its handed-over executes name (`TaskRuntime.created_ids_in`, `created_id`). An execute's id is read only after its first line (`Execute::HANDED_OVER`, through `TaskRuntime.handed_over_id`; the web has a JS twin in `task_stop.js`), not from a `task_id:` line inside a run, which the command can print. (A command that prints the `---` separator chi joins a turn's results with, then a hand-over line, still fakes one: a known gap.) Either result may start with the `ran as: …` line of a gate-corrected call.
30
+ - Which tasks are a session's own (a cancelled turn's note, a worker's restart hold, the finish wake below, the web's stop-task button): the ids its `task_create` results and its handed-over executes name (`TaskRuntime.created_ids_in`, `created_id`). An execute's id is read only after its first line (`Execute::HANDED_OVER`, through `TaskRuntime.handed_over_id`; the web has a JS twin in `task_stop.js`), not from a `task_id:` line inside a run, which the command can print. (A command that prints the `---` separator chi joins a turn's results with, then a hand-over line, still fakes one: a known gap.) Either result may start with the `ran as: …` line of a gate-corrected call.
31
31
  - `task_get` returns metadata and `output_path`; use `read` for output contents.
32
32
  - `task_wait` accepts `timeout`, `tail_lines` (maximum 100), and `done_pattern` (a regular expression string).
33
33
  - `task_create` accepts `env` as a JSON object string for deterministic overrides such as `PATH`; use an absolute interpreter path when that is simpler. Ruby/Bundler isolation variables remain protected.
34
34
  - `execute` and `task_create` export chi's own markers into the command, set over the model's `env` (which can't set them: `Error: env key is reserved`): `SAMAGOTCHI_PARENT_SESSION` (the session's id, `chi` without one; a chi started there answers as a parent agent), `SAMAGOTCHI_SESSION_MODEL` (the resolved ref the session runs on, read at each call so it follows `/model`; unset when unknown) `SAMAGOTCHI_SESSION_THINKING` (the session's own thinking level; unset when it has none) and `SAMAGOTCHI_SESSION_REASONING_HISTORY` (its own reasoning_history level, the same way). `chi self` reads the last three for its model, thinking level and reasoning history. The four are `TaskRuntime::MARKER_ENV_KEYS` (and `SessionManager::SESSION_MARKER_ENV`, which a spawned worker unsets). A session's worker never inherits them from whoever started it (a `chi send` run by another session's `execute`): its other commands (a user's `!cmd`, an MCP server, a context command) have none.
35
35
  - They also pass on a set `XDG_STATE_HOME` / `XDG_CONFIG_HOME` made absolute (`Builtins.xdg_absolute`, via `Paths`), so a chi the command runs in another `cwd` (`chi send`) reads this chi's state and config. These aren't reserved: an XDG dir the model sets in `task_create`'s `env` stays its own.
36
36
 
37
+ ## Finish wake
38
+
39
+ A task the model leaves running (it ends its turn: "I'll check when it's finished") is reported when it ends, so the result doesn't wait for the user's next message. `session.task_wakes` (env `SAMAGOTCHI_SESSION_TASK_WAKES`): `wake` (default), `queue` or `off` (no watch, as before).
40
+
41
+ - **The watch** (`TaskWatch`, `task_watch.rb`) is the Worker's, in memory: the in-process REPL (`--no-shared`) has none, and a crash loses it (those tasks behave as before). The Worker's Engine observer feeds it the raw `:tool_call_completed` events, the input `Bridge#own_task_ids` reads too. A `task_create` result and an `execute` hand-over add the task (`TaskRuntime.created_id`: a `task_id:` the command printed doesn't count), the hand-over's limit kept for the note. What shows the model the end removes it: a `task_wait` with a final status, or with `wait_result: pattern_matched` (the model chose its own "done" marker); a `task_get` with a final status; any `task_stop`. `task_list` and a `read` of `output.log` don't: a model that read the log still gets the wake once. Tasks a tool starts inside an anytime command (`/btw` and the like) reach the same observer, so they are watched too.
42
+ - **The idle tick**: with something watched, each idle pass (after delegate reports, before due reminders) refreshes the watched records (`TaskRuntime.get_record`, one `ps` per running task) and takes the finished ones, keeping only the ids the conversation still holds (`TaskRuntime.created_ids_in`: a `!rollback` or a restored turn may have dropped the result that started one; an LLM context strategy can't, since its stale and forget stubs are drawn in the request, never written over the saved result). A note that can't be written puts its task back for the next pass. An empty watch costs nothing. A task that finishes during a turn and is still unseen when the turn ends wakes on the next idle pass. One the user stopped with the web's stop-task button is dropped without a note.
43
+ - **The note** is a context note under its own header (`ContextNote::TASK_HEADER`; `TaskFinishNote`), `note_id: task-<id>`, `source: task <id8>`:
44
+
45
+ ```
46
+ [TASK FINISHED from task 3f9a2c1d, 14:20]
47
+ Background task 3f9a2c1d finished: exit 0 after 6m12s (handed over by execute at its 120s limit).
48
+ command: bundle exec rspec spec
49
+ <task_wait's own text (TaskWait.finished_text): status, the log's last 10 lines, a stderr_tail>
50
+ chi started this turn because a background task you started finished; your user didn't write anything.
51
+ Carry on with what you planned to do with its result. If you planned nothing, tell your user in a sentence or two how it went.
52
+ [END NOTE]
53
+ ```
54
+
55
+ The duration runs from `started_at` to the `exit_code` file's mtime (the shell's EXIT trap wrote it as it ended; `finished_at` is when a refresh first noticed the end), else `finished_at`. The body is cut to `max_tool_output_chars` as a `task_wait` result is (`ToolOutputCut`'s `:head_tail_ref`, naming the logs).
56
+ - **The wake turn**: the first finished task runs a continue turn from client `task:<id>` (`last_turn.origin: "task"`, labelled `task <id8> finished` in the TUI and the web; the web's bell rings at its end however short), whose first message is the note (`turn_start`, `turn_id`, written before the turn's checkpoint so a restore keeps it). It counts toward `session.max_wakes` with the other wakes (`WorkerWakes#task_open?`: no continue offer, not paused, past the 2 s start grace). Several tasks finishing together: one wake turn, the others plain notes before it.
57
+ - **A plain note instead** (the same text without the last two lines, no turn start), written at once, with `queue`, the budget spent (the UIs hear `N finished background tasks noted; the model reads them with your next message` once per human input, the flag shared with the other wakes), wakes paused, a continue offer waiting, input queued, an exit asked for, or a **note-only** task: one started or waited on in a turn the user stopped (reasons `user`, `manual`, `ctrl_c`), since a wake telling the model to carry on would undo that Stop. A prompt's turn takes the finished ones as plain notes first.
58
+ - **Failure**: a wake turn that fails and kept nothing, or is stopped and restored, turns its note back into the plain one (`WorkerInbound#unmark_wake`, as for a context wake) and pauses wakes until the user writes.
59
+ - **Idle exit**: with `wake`, a watched task still running holds the worker's idle exit and `/exit` (`WorkerIdleExit`'s `:background_tasks`; the 409 lists the tasks, `{id, command}`, with a `hint` on stopping them, and the attached terminal names them and says to use the web's stop-task button or ask for `task_stop`), as reminders do. With `queue` they don't: nothing would wake for the end, so keeping the worker up isn't worth it; a task that ends after the worker left gets no note (the model sees its end at its next look, as before). A forgotten dev server keeps the worker up until it is stopped; a crash of that server wakes the session.
60
+ - **The tool descriptions**: a worker's `execute` and `task_create` say "if you end your turn before it finishes, chi tells you when it does" (left out under `off` and in the REPL), fixed when the worker builds its Engine.
61
+
37
62
  Work that needs a model, not a shell command, goes to a child chi session instead: `delegate` / `delegate_result` have the same shape (start, then wait) and return only the child's final reply; see [Sessions: Delegating](../sessions.md#delegating).
@@ -24,7 +24,7 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
24
24
  |---|---|
25
25
  | `BundleCommand` (`lib/samagotchi/bundle_command.rb`) | `chi bundle install/upgrade/uninstall/status/diff/list/build/trash/new/test/check`; runs the Installer, prints its summary, runs the conflict agent step; refuses a source already installed under another name (`installed_under_another_name?`: a git URL bundle recorded under its repo's name before the manifest named it) |
26
26
  | `BundleCommand::Check` (bundle_command/check.rb) | `chi bundle check [PATH] [--fix] [--set K=V]`: a bundle being written, checked as an install takes it (docs/cli.md lists the codes); parses the manifest from `Shas.parseable` (a placeholder sha is a `sha` finding, not a YAML error), reads the user's installed bundles only for `clash`, and installs and loads through `Testing::Install` in a `Testing::Sandbox` with `force: true`, the log's echoes swallowed |
27
- | `BundleCommand::Try` (`bundle_command/try/`) | `chi bundle try PATH -p TEXT`: filtered-config `Sandbox` installs the tried bundle and shipped `guardrails` / `cost-cap` in a separate child, then `Run` starts one non-interactive chi process from its realpath work dir; `TraceReader` reads that session's analytics, cards and session JSON by id (never `Session.list`), joining timing and tool args for text or experimental JSON output. `Sandbox#child_env` is a Hash, never an ENV mutation; it keeps ordinary user model settings and Keychain access while dropping the spawner's session/guardrail/worker markers. |
27
+ | `BundleCommand::Try` (`bundle_command/try/`) | `chi bundle try PATH -p TEXT`: filtered-config `Sandbox` installs the tried bundle and shipped `guardrails` / `cost-cap` in a separate child, then `Run` starts one non-interactive chi process from its realpath work dir; `TraceReader` reads that session's analytics, cards and session JSON by id (never `Session.list`), joining timing and tool args for text or experimental JSON output (tool records the messages lack, from a turn a `--timeout` interrupt cut, become rows without args; `Run::Result#timed_out` becomes `timed_out`). `Sandbox#child_env` is a Hash, never an ENV mutation; it keeps ordinary user model settings and Keychain access while dropping the spawner's session/guardrail/worker markers. |
28
28
  | `BundleCommand::Scaffold` (bundle_command/scaffold.rb) | `chi bundle new NAME`: the starting files (block-form `plugin:` entry, `requires_chi` at this chi, a kit test that passes as written); the manifest's shas are filled through `Shas.refresh` |
29
29
  | `BundleCommand::TestRun` (bundle_command/test_run.rb) | `chi bundle test`: `RbConfig.ruby -I <chi's lib>` on `test/**/*_test.rb` (Minitest) or `spec/` (RSpec, as `rake bundles:specs`), under `Bundler.with_unbundled_env` when Bundler's runtime is loaded |
30
30
  | `Shas` (shas.rb) | a manifest's `sha256:` lines: `.refresh(dir[, text])` rewrites them line by line (the layout kept; a `files:` entry it can't place raises `Shas::Error`), `.stale` → `[Stale(file, old, new)]` (`old` `""` for a placeholder, nil for a plugin / hook / script entry with no sha line), `.parseable` (a placeholder `sha256: sha256:` isn't YAML). Plain Ruby: `rake bundles:sha` / `bundles:check` (`script/release/release_tools.rb`) and `chi bundle check --fix` share it |
@@ -489,12 +489,12 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
489
489
  | `:thinking_refused` / `:reasoning_history_refused` | the chat loop asked a step again without the thinking fields / the reasoning sent back; the Engine logs it and makes a once-per-session-and-host `:hook_notice` of it, the event itself never reaches the sink | `{iteration, model, level, detail}` |
490
490
  | `:pending_input_merged` | queued input joined the conversation | `{iteration, count, content, steers?, answer}` |
491
491
  | `:tool_dispatch_started` / `:tool_dispatch_completed` | around a batch | `{iteration, call_count}` |
492
- | `:tool_call_started` | before a call runs | see ToolRunner |
492
+ | `:tool_call_started` | before a call runs | Engine event includes `call` (see ToolRunner); the Bridge's SSE frame omits it |
493
493
  | `:tool_call_completed` | after a call ran | see ToolRunner |
494
494
  | `:used_memories_updated` | a memory was read, and after the loop | `{used_memory_names, read_names?}` |
495
495
  | `:hook_notice` | a hook's or plugin's notify during a turn | `{hook, text, level}` (announced with `between_turns: true` outside a turn) |
496
496
  | `:guardrail_warning` | a guardrails line during a turn | `{message}` |
497
- | `:turn_completed` | normal end | `{result, turn_summary, display_pending}` |
497
+ | `:turn_completed` | normal end | Engine event `{result, turn_summary, display_pending}`; the Bridge's SSE frame omits `result` (answer remains in `turn_summary.output`) |
498
498
  | `:turn_canceled` | Stop, hook stop, Ctrl-C | `{cancellation_reason, cancelled_by?, duration_ms}` |
499
499
  | `:turn_failed` | an error | `{error_class, message, duration_ms, error_kind?, retryable?, host?, summary?}` |
500
500
  | `: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) |
@@ -83,6 +83,10 @@ right-hand column happens.
83
83
  finishes booting between load and the first turn changes search answers only, so two sessions on the same
84
84
  toolkit with the same MCP cache send the same bytes. Whether find_tools/call_tool are declared at all reads the
85
85
  same two things (`Registry#index_needed?`), never live groups.
86
+ - A worker's `execute` and `task_create` descriptions carry the finish wake's sentence ("if you end your turn before
87
+ it finishes, chi tells you when it does"; `Engine.new(task_finish_notes:)`, set once as the worker builds its
88
+ Engine); the `--no-shared` REPL's, and a worker's under `session.task_wakes: off`, don't. A session that moves
89
+ between the two, or whose worker restarts after `task_wakes` changed, misses the cache once.
86
90
  - On remote Claude, `LLM::PromptCache.mark` sends the system message as two text parts. The stable part carries
87
91
  breakpoint #1, so a **new session** reads it from the cache. The last non-system message carries breakpoint
88
92
  #2, so the **next request in the session** reads everything up to it. Mid and tail system messages never take
data/docs/sessions.md CHANGED
@@ -17,7 +17,7 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
17
17
 
18
18
  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.
19
19
 
20
- **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up. The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, reminders, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
20
+ **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, no background task it watches for a [finish wake](internals/background-tasks.md#finish-wake) still running (with `session.task_wakes: wake`) 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 (for background tasks, which ones and how to stop them). The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, reminders, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
21
21
 
22
22
  **Empty sessions:** a session nothing happened in is deleted when it is left, so `chi` → `/exit` leaves no trace. Empty means: no messages, no turn tried (a failed turn leaves a `last_prompt` and keeps it), no memory used, no pending question, nothing in `input/`, `notes/` or `images/`, nothing in its dir beyond the worker's skeleton, mode `assist`, and the default model. A session on another model (`/model`, `--model`) counts as prepared and stays (`SessionManager.discardable?`). The worker checks as it idle-exits or leaves on a client's request and deletes it once its lock is free, checking again first. The attached `/exit` then says `Detached; the session is empty: it will be discarded if nothing arrives before its worker stops.` (a note or prompt landing first keeps it). The REPL (`--no-shared`) does the same at `/exit` or Ctrl-D. The retention sweep catches those killed first: empty sessions over an hour old with no live owner, whatever `DAYS`/`MAX`, and skeleton-only dirs with no `*.json`. `session.keep_empty: true` (env `SAMAGOTCHI_SESSION_KEEP_EMPTY`) turns all of it off.
23
23
 
@@ -143,7 +143,7 @@ system message marked `kind: turn_note` (the UIs don't show it; they showed the
143
143
  the user, who may send it again.]` in the REPL and attached mode, where the prompt is given back;
144
144
  `… The user's last message was not answered.]` after `-p`, where the prompt stays in the session;
145
145
  `… The wake turn for the change in attached context pr-7 was not answered; chi starts no other wake turn until
146
- the user writes.]` after a wake turn chi started (a context source's change, a delegate's report), not the user
146
+ the user writes.]` after a wake turn chi started (a context source's change, a delegate's report, a background task that finished), not the user
147
147
  (for a delegate's report it adds `chi keeps the report and brings it again with the user's next message.`);
148
148
  - cancelled (Ctrl-C, the web's stop): `[SYSTEM: the previous turn was cancelled (ctrl-c) after 12s; the reply
149
149
  above ends where it was cut off.]` (an answer or only narration had streamed), or `…; no answer had been shown.]` when only thinking had streamed;
@@ -10,8 +10,10 @@ module Samagotchi
10
10
  # the provider's raw `payload`, and no raw `content` (the chat loop's is
11
11
  # reasoning + content again, the native loop's the raw stream with its
12
12
  # tool-call bytes and thought tags): clients read `text`, `thinking`,
13
- # `tool_call` and `counts`, never a provider's payload shape. `?raw=1` on
14
- # the worker's stream skips this (debugging).
13
+ # `tool_call` and `counts`, never a provider's payload shape.
14
+ # :tool_call_started omits the model's `call`; clients render its row from
15
+ # the other fields. :turn_completed omits `result`; `turn_summary.output`
16
+ # carries the answer. `?raw=1` on the worker's stream skips this (debugging).
15
17
  #
16
18
  # Pure: the event is the one hash SessionObserver hands every observer
17
19
  # (the ring, TurnAccumulator, the other writers, SessionMetrics, hooks),
@@ -28,13 +30,19 @@ module Samagotchi
28
30
  # @param event [Hash] the engine's event (symbol keys; its payload has
29
31
  # string keys)
30
32
  # @return [Hash] the event as clients get it: a copy for a
31
- # :generation_chunk, the same object for every other event
33
+ # projected event, the same object for every other event
32
34
  def project(event)
33
- return event unless event.is_a?(Hash) && event[:type].to_s == "generation_chunk"
35
+ return event unless event.is_a?(Hash)
34
36
 
35
- projected = event.except(:payload, :content)
36
- found = counts(event[:payload])
37
- found ? projected.merge(counts: found) : projected
37
+ case event[:type].to_s
38
+ when "generation_chunk"
39
+ projected = event.except(:payload, :content)
40
+ found = counts(event[:payload])
41
+ found ? projected.merge(counts: found) : projected
42
+ when "tool_call_started" then event.except(:call)
43
+ when "turn_completed" then event.except(:result)
44
+ else event
45
+ end
38
46
  end
39
47
 
40
48
  # The payload's normalized counts, nil keys left out; nil when the
@@ -805,7 +805,8 @@ module Samagotchi
805
805
  # event log held, so no POST /turn lands between its check and its
806
806
  # answer; it leaves from its loop after this reply.
807
807
  # 200 {status: "exiting", discard?: the session is empty and goes} ({status: "restarting"} for a restart), or 409
808
- # {status: "held", reason:} naming what keeps it up. A past +deadline+
808
+ # {status: "held", reason:} naming what keeps it up (with tasks: [{id:,
809
+ # command:}] and a hint: how to stop them, for background_tasks). A past +deadline+
809
810
  # (see #handle_post_turn), checked with the event log held: 408
810
811
  # deadline_passed, the worker not asked (its client said it didn't stop).
811
812
  # Returns [headers, status, body].
@@ -838,7 +839,11 @@ module Samagotchi
838
839
  return [{}, 200, body]
839
840
  end
840
841
 
841
- [{}, 409, { status: "held", reason: reason.to_s, session_id: @session_id }]
842
+ # A hold may name what it waits for ({reason:, tasks:}: the background
843
+ # tasks a finish wake watches).
844
+ held = reason.is_a?(Hash) ? reason : { reason: reason }
845
+ [{}, 409, { status: "held", reason: held[:reason].to_s, tasks: held[:tasks], hint: held[:hint],
846
+ session_id: @session_id }.compact]
842
847
  end
843
848
 
844
849
  # Queue a turn for this session through its file IPC (the worker polls