samagotchi 0.13.0 → 0.15.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -1
  3. data/bin/chi +4 -7
  4. data/docs/configuration.md +50 -7
  5. data/docs/guardrails.md +2 -1
  6. data/docs/plugins.md +28 -13
  7. data/docs/sessions.md +1 -1
  8. data/docs/sub-agent.md +1 -1
  9. data/lib/samagotchi/bridge/card_store.rb +94 -6
  10. data/lib/samagotchi/bridge.rb +3 -1
  11. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/check-in/plugin.rb +68 -5
  13. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/mcp/plugin.rb +166 -34
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/config.rb +31 -6
  17. data/lib/samagotchi/context_window.rb +30 -5
  18. data/lib/samagotchi/engine.rb +21 -7
  19. data/lib/samagotchi/guardrails/parent_approvals.rb +5 -2
  20. data/lib/samagotchi/host_registry.rb +31 -6
  21. data/lib/samagotchi/kernel_loop.rb +1 -1
  22. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  23. data/lib/samagotchi/llm/turn_settings.rb +7 -5
  24. data/lib/samagotchi/plugin_session_state.rb +30 -0
  25. data/lib/samagotchi/self_report.rb +24 -4
  26. data/lib/samagotchi/session_manager.rb +8 -0
  27. data/lib/samagotchi/session_metrics.rb +1 -1
  28. data/lib/samagotchi/session_retention.rb +6 -1
  29. data/lib/samagotchi/system_prompt.rb +1 -1
  30. data/lib/samagotchi/terminal_ui/attached_loop.rb +65 -13
  31. data/lib/samagotchi/terminal_ui.rb +27 -3
  32. data/lib/samagotchi/tools/delegate_wait.rb +17 -10
  33. data/lib/samagotchi/tools/memory.rb +3 -3
  34. data/lib/samagotchi/utf8_default.rb +29 -0
  35. data/lib/samagotchi/version.rb +1 -1
  36. data/lib/samagotchi/web/app.rb +22 -7
  37. data/lib/samagotchi/web/public/activity.js +6 -1
  38. data/lib/samagotchi/web/public/app.js +46 -10
  39. data/lib/samagotchi/web/public/index.html +10 -1
  40. data/lib/samagotchi/web/public/notify.js +16 -3
  41. data/lib/samagotchi/web/public/question_cards.js +3 -1
  42. data/lib/samagotchi/web/public/sessions_list.js +45 -0
  43. data/lib/samagotchi/web/public/strip.js +8 -1
  44. data/lib/samagotchi/web/public/timing.js +11 -2
  45. data/lib/samagotchi/web/public/turn_view.js +10 -10
  46. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e7f8dcd85ecceeec6695f9ee969633b68c9f4900c597af6ffc87e9b8ee15168
4
- data.tar.gz: d26fd005c3e98b1177de659b41e94ae09a22d19159d74215475db6b5e1777ff3
3
+ metadata.gz: 7cb85a0b5509a00cd396dd2db8c269c7959ccbbdd004649d626476067b652bbd
4
+ data.tar.gz: b2a46cc3632563de6f1d7c0f979b9ac54bccdfa765a643c576a5c24d7b492bf3
5
5
  SHA512:
6
- metadata.gz: aeb8f2f124a5c21f0f5a58ea99c8f473bbde98716ee9abb47478697971a079c32dfd7c36097de58702ec2104ecb8f6846f403019eb9ac66a772c6a14c7b89c0c
7
- data.tar.gz: 1ae7b930ce05499c42e1d2cea7a140261d6d0240291ecbdd58394fe8da5649452f969ecccc219317b77baaa686dddf77107313d2c469aa4c4c3277aa997873fd
6
+ metadata.gz: '07187883e2ffb8d0a0cbeaafd870551df02e166d7301e3696223efeba5d7b4eaab002a4ccc5180dd88c8d20c8cbcec302b2c9932ea99bd0b872b34051375d651'
7
+ data.tar.gz: 759493ed3abbd40da61128bca6cbb2805937697af546739b52e1e365b01867d1c648484aefdd7a4b1512acc50515d2c588f6fc48342c36f238b2c34713827750
data/CHANGELOG.md CHANGED
@@ -8,6 +8,85 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.15.0] - 2026-10-02
12
+
13
+ ### Added
14
+
15
+ - The hidden session strip's pill counts the sessions that wait for you (`▾ 5 sessions · 1 live · 1 waiting`, in the
16
+ warn colour).
17
+ - The all-sessions search finds the sessions that wait for you: `waiting`, `question`, `approval` or `needs you`.
18
+
19
+ ### Fixed
20
+
21
+ - `chi --resume ID` (and `--attach`) shows the last exchange's tool calls as the live tool rows
22
+ (`tool> running command (execute command="true"): ok`) between the prompt and the answer, not the prompt and the
23
+ answer alone.
24
+ - A `--no-shared` REPL keeps the session's `cards.json` as a worker does: its turns count, so the cards and notices an
25
+ earlier worker showed stay where they were in the web, and the REPL's own are saved too.
26
+ - A deleted, discarded or retention-pruned session takes its plugins' state with it: a bundle's
27
+ `plugins/<bundle>/sessions/<id>.json` (or `<id>/`) under the state dir, such as check-in's `/checkin` settings, no
28
+ longer stays behind.
29
+ - `chi self`'s context window row shows the current model's configured window: its `models.<key>.window_tokens`, else
30
+ its host's `hosts.<name>.window_tokens`, else `context.window_tokens` or the default, and says which.
31
+ - The web pairs each prompt with its own turn's timing: a failed turn (its prompt went back to you) no longer shifts
32
+ the timing lines and turn numbers of the turns after it. chi saves a turn id on each prompt; prompts saved before
33
+ this still pair by their place.
34
+
35
+ Update with `chi update` (no bundle changes).
36
+
37
+ ## [0.14.0] - 2026-10-02
38
+
39
+ ### Added
40
+
41
+ - The web marks a session that waits for you: its card gets a warn border, a pulsing dot and a `question`,
42
+ `approval` or `needs you` badge, and it moves to the top of the strip and the all-sessions view until answered
43
+ (the order holds while the pointer is over the list).
44
+ - `models.<key>.window_tokens` and `hosts.<name>.window_tokens` in config.yml set a model's or a host's context window
45
+ for when the server and its model list report none (the model's wins over the host's, and both over
46
+ `context.window_tokens`); the server's `n_ctx` still wins over all of them.
47
+
48
+ ### Fixed
49
+
50
+ - chi starts under `LC_ALL=C` (or another US-ASCII locale): `chi -p` crashed with `Encoding::CompatibilityError` while
51
+ building the system prompt from a memory index with non-ASCII text. chi and its workers read their files as UTF-8
52
+ whatever the locale.
53
+ - The `mcp` bundle (0.4.0, `chi update`) attaches an image an MCP tool names inside its text
54
+ (`Saved screenshot to /tmp/shot.png.`), not only a text that is the bare path; the same checks apply (an image by
55
+ its bytes, under the temp dir or the server's `cwd`).
56
+ - An MCP server that sends `notifications/tools/list_changed` has its tools listed again (`mcp` 0.4.0): its saved list
57
+ and the model's tools are replaced from the next turn on. They were only logged.
58
+ - An MCP server that exits mid-session starts again on its next call (`mcp` 0.4.0), up to 3 times a session; its tools
59
+ failed until chi restarted.
60
+ - A burst of plugin notices no longer pushes cards and questions out of what a reloaded web page or a joining
61
+ `chi --attach` shows: the worker keeps the last 20 of each apart, and never drops a question still waiting for its
62
+ answer.
63
+ - `/checkin off`, `/checkin mode …` and `/checkin 30` last for the session across a worker's restart (an idle exit,
64
+ `chi send` waking it): the `check-in` bundle (0.2.0, `chi update`) saves them per session and reads them back.
65
+ - `printf '2\n' | chi --attach ID` on a session already waiting on a question answers it: the piped line could be read
66
+ before the question arrived and go in as a new prompt, and the attach then hung.
67
+ - A REPL reading its answers from a pipe shows each answer line once (`? 2`), not `? ? 2`, above why it was refused
68
+ or invalid.
69
+ - A plugin's cards and a turn's hook notices (loop-guard's "stopped the turn" card and its `loop: … denied` rows,
70
+ check-in's, any bundle's) stay after the worker goes: the worker saves them in the session's folder
71
+ (`cards.json`, the same last-20 caps), the web shows them in place for a stopped session, and a later worker starts
72
+ from them. They used to vanish with an idle exit, a restart or `chi sessions stop`.
73
+ - The web marks a tool call a guardrail denied (loop-guard, the `guardrails` bundle, a parent's or the user's Deny)
74
+ `blocked` in red, as the TUI does, live and after a reload; it showed a green `done`.
75
+
76
+ ### Changed
77
+
78
+ - A host is remote by its address, not by its API key: an `https` url or a public IP address. A llama.cpp on the LAN
79
+ started with `--api-key` (`api_key_env:`) is now local again: chi asks its `/props` for the context window and the
80
+ served model, keeps its model list for 60 s, and gives it no first-token limit. `hosts.<name>.remote: true|false`
81
+ decides it for a host chi gets wrong (an http host named by a public DNS name counts as local).
82
+ - Codex CLI counts as a parent agent: an answer typed into `chi --attach` or the REPL from a command Codex runs
83
+ (`CODEX_THREAD_ID` set) is held to `guardrails.parent_approvals`, as one from Claude Code is.
84
+ - `delegate` and `delegate_result` report a child's wait with the status words of `chi send --wait --format json`:
85
+ `answered` (was `done`), `question` (was `waiting_for_answer`), `failed`, `canceled` or `no_answer` (was `no_reply`),
86
+ and `running` when the parent's turn is canceled (was `canceled`).
87
+
88
+ Update with `chi update` (bundles: mcp 0.4.0, check-in 0.2.0).
89
+
11
90
  ## [0.13.0] - 2026-10-02
12
91
 
13
92
  ### Added
@@ -729,7 +808,9 @@ and long-lived sessions.
729
808
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
730
809
  hotkey panel that send selected text or the clipboard to your sessions.
731
810
 
732
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.13.0...HEAD
811
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.15.0...HEAD
812
+ [0.15.0]: https://github.com/dm1try/samagotchi/compare/v0.14.0...v0.15.0
813
+ [0.14.0]: https://github.com/dm1try/samagotchi/compare/v0.13.0...v0.14.0
733
814
  [0.13.0]: https://github.com/dm1try/samagotchi/compare/v0.12.0...v0.13.0
734
815
  [0.12.0]: https://github.com/dm1try/samagotchi/compare/v0.11.0...v0.12.0
735
816
  [0.11.0]: https://github.com/dm1try/samagotchi/compare/v0.10.1...v0.11.0
data/bin/chi CHANGED
@@ -3,13 +3,10 @@
3
3
 
4
4
  $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
5
 
6
- # No locale at all (an app started from Finder or launchd, `env -i`): files
7
- # would read as US-ASCII, and chi's session JSON is UTF-8. Workers this
8
- # process spawns inherit the LANG.
9
- if Encoding.default_external == Encoding::US_ASCII && %w[LC_ALL LC_CTYPE LANG].none? { |key| ENV[key] }
10
- ENV["LANG"] = "en_US.UTF-8"
11
- Encoding.default_external = Encoding::UTF_8
12
- end
6
+ # LC_ALL=C or no locale at all: files would read as US-ASCII, and chi's
7
+ # memories and sessions are UTF-8 (Utf8Default).
8
+ require "samagotchi/utf8_default"
9
+ Samagotchi::Utf8Default.apply!
13
10
 
14
11
  require "optparse"
15
12
  require "samagotchi/config"
@@ -232,6 +232,25 @@ hosts:
232
232
  api_key_env: BOX_LLAMA_KEY
233
233
  ```
234
234
 
235
+ **Remote or local.** chi treats a host as a remote provider by its address, not
236
+ by its key: an `https` url, or an `http` IP address outside the loopback,
237
+ private (`10.*`, `172.16–31.*`, `192.168.*`, IPv6 `fc00::/7`), link-local and
238
+ CGNAT/Tailscale (`100.64.0.0/10`) nets. An `http` host given by a name
239
+ (`localhost`, `box`, `mac.local`) is local: chi doesn't look names up. A remote
240
+ host keeps its model list for 10 minutes, gets a 120-second first-token limit,
241
+ and isn't asked for llama.cpp's `/props` (its context window and served model
242
+ come from its model list and each turn). `remote: true` or `remote: false` on a
243
+ host decides it instead, e.g. for a llama.cpp behind an https proxy on the LAN:
244
+
245
+ ```yaml
246
+ hosts:
247
+ lab:
248
+ url: https://llm.lab.example/v1
249
+ api: openai
250
+ api_key_env: LAB_KEY
251
+ remote: false
252
+ ```
253
+
235
254
  A host without `port:` (and without `url:`) uses port 8080. `enabled: false`
236
255
  turns an entry off without deleting it: chi skips it as if it weren't there
237
256
  (not in `chi models`, never the default host; `box:model` then counts as an
@@ -481,7 +500,7 @@ hosts:
481
500
  ```
482
501
 
483
502
  `hosts.<name>.first_token_timeout` wins over `server.first_token_timeout` (`SAMAGOTCHI_SERVER_FIRST_TOKEN_TIMEOUT`),
484
- which applies to every host. With neither set, remote hosts (an API key or an https url) get 120 seconds and local
503
+ which applies to every host. With neither set, remote hosts (see "Remote or local" above) get 120 seconds and local
485
504
  servers no limit: a long prompt evaluation is normal there, and the read timeout catches a dead server.
486
505
 
487
506
  Every chat request carries the session's id as a `Session-Id` header (next to `User-Agent: chi/<version>`). A
@@ -507,11 +526,33 @@ to the default host. Only exact ids count, never a substring: `/model gemma` on
507
526
  a box that lists `gemma-3-12b` needs `box:gemma` or the exact id. The lists are
508
527
  known only after `/models` ran (nothing is fetched before the first turn), so
509
528
  until then an unqualified name goes to the default host; use `host:model` to pin
510
- one. A **remote** host (one with `api_key_env:` or an `https` url) keeps its model
511
- list for 10 minutes (60s for local hosts). For a chat host the context window comes from
512
- the running server (llama.cpp's `/props`), else the window the host's model list
513
- gives (`context_length`, `context_window`, `max_model_len` or llama.cpp's
514
- `meta.n_ctx`), else `context.window_tokens`.
529
+ one. A **remote** host (an `https` url or a public address; see "Remote or local") keeps its model
530
+ list for 10 minutes (60s for local hosts).
531
+
532
+ **The context window** (what the context status and `ctx=` count against) comes from,
533
+ first to last:
534
+
535
+ 1. the running server (llama.cpp's `/props` `n_ctx`; a remote host isn't asked),
536
+ 2. the window the host's model list gives (`context_length`, `context_window`,
537
+ `max_model_len` or llama.cpp's `meta.n_ctx`),
538
+ 3. `models.<key>.window_tokens` (the key as `sampling:` looks a model up), else
539
+ `hosts.<name>.window_tokens`,
540
+ 4. `context.window_tokens`, else 256000.
541
+
542
+ The settings only fill in when the server and its list report nothing: what the
543
+ server runs with is the window that counts. Set one for a provider whose list has
544
+ no window:
545
+
546
+ ```yaml
547
+ hosts:
548
+ work:
549
+ url: https://llm.work.example/v1
550
+ api: openai
551
+ window_tokens: 131072
552
+ models:
553
+ qwen3-8b:
554
+ window_tokens: 32768
555
+ ```
515
556
 
516
557
  Only `:` names a host: `/` is part of a model id (`openai/gpt-4o` is an
517
558
  OpenRouter id and goes to the default host as written, even with a host named
@@ -812,13 +853,15 @@ described in their own sections.
812
853
  | `log.level` | `info` | yes | `debug`, `info`, `warn`, `error`. |
813
854
  | `status.line` | `on` | yes | The status row under the prompt (the REPL's and attached mode's), `on` or `off`. |
814
855
  | `context.status` | `true` | yes | Context-usage telemetry for the model. See [context telemetry](internals/context-telemetry.md). |
815
- | `context.window_tokens` | server's, else 256000 | yes | Context window when the server doesn't report one. |
856
+ | `context.window_tokens` | server's, else 256000 | yes | Context window when the server doesn't report one. `models.<key>.window_tokens` and `hosts.<name>.window_tokens` come first; see "The context window". |
857
+ | `models.<key>.window_tokens`, `hosts.<name>.window_tokens` | none | | A model's or host's context window when the server and its model list report none. |
816
858
  | `context.chars_per_token` | `4.0` | yes | Estimate ratio when the server reports no usage. |
817
859
  | `context.status_thresholds` | `20,40,60,80` | yes | Percentages that trigger a status. |
818
860
  | `context.status_cadence` | `0` | yes | Also every N rounds; `0` = thresholds only. |
819
861
  | `thinking.turn_preamble` | `true` | yes | Ask a `qwen36` model to open its thinking with a short `TURN:` line (the step label). |
820
862
  | `thinking.level` | `default` | `--thinking` | `off`, `low`, `medium`, `high` or `default` for every model; the flag and env outrank the `models:`/`hosts:` entries, the file's value doesn't. See "Thinking". |
821
863
  | `models.<key>.thinking`, `hosts.<name>.thinking` | none | | A model's or host's level. See "Thinking". |
864
+ | `hosts.<name>.remote` | by address | | `true`/`false`: treat the host as a remote provider or a local server. See "Remote or local". |
822
865
  | `max_tool_output_chars` | `10000` | yes | Tool output kept in the conversation; a top-level key (see below). |
823
866
  | `retry.max` | `5` | yes | See "Llama Network Retry Behavior". |
824
867
  | `retry.base_delay` | `0.5` | yes | |
data/docs/guardrails.md CHANGED
@@ -130,7 +130,8 @@ scope.
130
130
  A parent may also type its answer into chi itself: a piped `chi --attach ID`
131
131
  or `printf '2\n' | chi --no-shared -p …`. So chi treats an answer as a
132
132
  parent's, held to the same setting, when its input isn't a terminal or an
133
- agent marker is set: `CLAUDECODE`, `AI_AGENT`, or `SAMAGOTCHI_PARENT_SESSION`,
133
+ agent marker is set: `CLAUDECODE` (Claude Code), `AI_AGENT`, `CODEX_THREAD_ID`
134
+ (Codex CLI), or `SAMAGOTCHI_PARENT_SESSION`,
134
135
  which chi's own `execute` and `task_create` export (the session's id) into the
135
136
  commands they run. The markers survive a PTY wrapper (`script`, `expect`). A
136
137
  person at a terminal with no marker keeps every scope.
data/docs/plugins.md CHANGED
@@ -479,7 +479,7 @@ full label, `plugin.rb (bundle my-bundle)`.
479
479
  | `ctx.cwd` | the session's working directory |
480
480
  | `ctx.repo_root` | the git checkout holding `cwd`, or nil |
481
481
  | `ctx.settings` | the bundle's settings, frozen |
482
- | `ctx.data_dir` | `$XDG_STATE_HOME/samagotchi/plugins/<bundle>/`, created on first use |
482
+ | `ctx.data_dir` | `$XDG_STATE_HOME/samagotchi/plugins/<bundle>/`, created on first use. A session's own state goes in `sessions/<id>.json` or `sessions/<id>/` there: chi removes it when the session is deleted, discarded or pruned |
483
483
  | `ctx.log` | `ctx.log.info(:event, key: value)`: debug-log records tagged `plugins`, with `bundle=<bundle>` |
484
484
  | `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 |
485
485
  | `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 |
@@ -544,12 +544,16 @@ completed turn stays in its step.
544
544
  An [anytime command](#anytime-true)'s cards show as it shows them, after its
545
545
  line, in every UI, whether a turn runs or not.
546
546
 
547
- A worker keeps its last 20 cards and hook notices, for a UI that joins
548
- later. The web shows them where they arrived after a reload (a turn's
547
+ A worker keeps its last 20 cards (questions included) and, apart, its
548
+ last 20 hook notices, for a UI that joins later; a question still waiting
549
+ for its answer is always kept. The web shows them where they arrived after a reload (a turn's
549
550
  notice as a row of its step, above the call it came before); the attached
550
551
  TUI shows the cards and between-turns notices since the last turn when it
551
- joins. They live as long as the worker: an idle exit or a restart forgets
552
- them, and they are not saved with the session.
552
+ joins. The worker saves them in the session's folder (`cards.json`, the same
553
+ caps), so they outlive it: the web shows them in place for a stopped
554
+ session, and the next worker (an idle exit, a restart, `chi sessions stop`
555
+ then a new turn) starts from them. Load warnings and a question still
556
+ waiting when the worker went are not saved.
553
557
 
554
558
  The event is `{type: :card, id:, source:, title:, body:, level:, actions:,
555
559
  in_turn:}` (`source` is the bundle), logged as `card` with its source, id and
@@ -690,6 +694,9 @@ bundles:
690
694
  background task lists the tools again with a server of its own (then
691
695
  stops it), saves them, and replaces the tools if they changed. One worker
692
696
  does it at a time.
697
+ - **A server that says its tools changed** (`notifications/tools/list_changed`)
698
+ is asked for its `tools/list` again; the saved list and the tools are
699
+ replaced from the next turn on.
693
700
  - **A cached server that doesn't start** (the command is gone, it crashes)
694
701
  fails that call with `Error: MCP server x didn't start: …` and one notice;
695
702
  later calls answer the same at once, and its tools are left out from the
@@ -722,15 +729,20 @@ bundles:
722
729
  ([Returning images](#returning-images): at most 4 per call, a line
723
730
  instead when the model can't see images). Its place in the text is a line,
724
731
  `[image 1: image/png, attached]`, so the model knows the order. A text
725
- block that is **only the absolute path of an image file** is attached too
726
- (`[image 1: screenshot.png, attached]` after the path), but only when the
727
- file is under the system temp dir or the server's `cwd`: a server's text
728
- can't pull in any image on disk. `chrome-devtools-mcp --slim` answers
732
+ block that is **the absolute path of an image file** is attached too
733
+ (`[image 1: screenshot.png, attached]` after the text), and so is an
734
+ absolute path ending in `.png`, `.jpg`, `.jpeg`, `.gif` or `.webp` inside
735
+ a sentence (`Saved screenshot to /tmp/shot.png.`; such a path can't hold a
736
+ space). Either way only when the file is an image by its bytes and is
737
+ under the system temp dir or the server's `cwd`: a server's text can't
738
+ pull in any image on disk. `chrome-devtools-mcp --slim` answers
729
739
  `screenshot` that way; without `--slim`, `take_screenshot` returns an image
730
740
  block. `attach_image_paths: false` on a server leaves such paths as text.
731
- - **A server that exits** fails its calls with `Error: MCP server x is not
732
- running (…)`, and there is one notice. It is not restarted until chi
733
- restarts (a new session, or the worker's next start).
741
+ - **A server that exits** fails the call it was on with `Error: MCP server
742
+ x is not running (…)`, and there is one notice. Its next call starts it
743
+ again (its tools stay), up to 3 times a session; after the third restart
744
+ the notice says so, and its calls fail at once until chi restarts (a new
745
+ session, or the worker's next start).
734
746
  - **Stop.** The servers stop with chi ([Shutdown](#shutdown)): stdin is
735
747
  closed, then TERM and KILL go to the server's process group.
736
748
  - **`/mcp`** (anytime) shows a card with the servers, their state (cached
@@ -923,7 +935,10 @@ bundles:
923
935
  and what is left, not "how's it going?": a small model tends to answer that
924
936
  with a line and carry on unchanged.
925
937
 
926
- `/checkin` (anytime) for this session, until its worker restarts:
938
+ `/checkin` (anytime) for this session. What it changes (on/off, the mode,
939
+ the threshold) is saved with the session, in the bundle's data dir
940
+ (`plugins/check-in/sessions/<id>.json`), so it outlasts a worker's restart
941
+ and goes with the session; another session starts from config.yml:
927
942
 
928
943
  | | |
929
944
  |---|---|
data/docs/sessions.md CHANGED
@@ -180,7 +180,7 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
180
180
 
181
181
  - `delegate(task, model:, session:, wait:, timeout:)`: starts a child and, by default, waits for its reply (`wait: false` returns at once, so several children can run side by side; `timeout`, default 600 s, leaves a slow child running; 0 checks once without waiting). With `session:` (a child's id or prefix) the task goes to that child as a follow-up instead, delivered like `chi send` (client id `delegate:<parent short id>`, shown as `delegate>` in the child's UIs); only this session's children take one.
182
182
  - `delegate_result(session:, timeout:)`: waits for a child's next reply: the one named, or the newest running child (`timeout` as for `delegate`: 0 checks once and returns at once). The reply comes back once; a later call waits for the next one.
183
- - What a wait returns, `session: <id>` then `status:` and the text: `done` with the reply after a `---` line; `waiting_for_answer` as soon as the child opens a question or a guardrail approval, with the question's text, options and the `chi answer` command for it (nothing is answered for it: `chi answer`, attach or use the web, then wait again); `canceled` when the parent's own turn is canceled (the child keeps running; the next turn can wait again); `no_reply` when the child's turn ended with nothing to say (canceled, failed or empty: its session shows what happened); `error` (its worker crashed) or `stopped` (`chi sessions stop`) at once; `worker_gone` when the child has had no worker for 15 seconds (it crashed without saying so, or idle-exited): `delegate` with `session:` starts it again; `running` on timeout. A reply saved before the child's worker idle-exited is returned at once.
183
+ - What a wait returns, `session: <id>` then `status:` and the text, with the status words `chi send --wait --format json` uses: `answered` with the reply after a `---` line; `question` as soon as the child opens a question or a guardrail approval, with the question's text, options and the `chi answer` command for it (nothing is answered for it: `chi answer`, attach or use the web, then wait again); `failed`, `canceled` or `no_answer` when the child's turn ended with nothing to say (its session shows what happened); `error` (its worker crashed) or `stopped` (`chi sessions stop`) at once; `worker_gone` when the child has had no worker for 15 seconds (it crashed without saying so, or idle-exited): `delegate` with `session:` starts it again; `running` on timeout, or when the parent's own turn is canceled (the child keeps running; the next turn can wait again). A reply saved before the child's worker idle-exited is returned at once.
184
184
  - Every child starts with the shipped `delegated` system memory preloaded (`preloaded_memory_names: ["system/delegated"]`; `--mute delegated` would hide it): put everything in the final reply, don't delegate further, don't write memories or change config, stay in the folder. Its system prompt also says `Delegated by session <parent id>`. The parent's own `--mute` list does not carry over.
185
185
  - Limits: a child cannot `delegate` (depth 1; the tool refuses), and one session may have at most `session.max_children` (default `4`, env `SAMAGOTCHI_SESSION_MAX_CHILDREN`) children running at a time, counted from the session files (a finished or idle-exited child does not count; one whose worker is still starting does, for 15 s). A refused call creates no session. Nothing stops children with the parent: `chi sessions stop ID` does.
186
186
  - The link is the session's `parent_id`, written before the worker starts, so a respawn keeps it; `GET /api/sessions` and `/api/sessions/:id` carry it, and `SessionManager.children_of` lists a parent's children.
data/docs/sub-agent.md CHANGED
@@ -66,7 +66,7 @@ and neither does any allow on chi's own config, hooks or guardrail rules. See
66
66
 
67
67
  An answer typed into `chi --attach` or the REPL counts as a parent's too when
68
68
  stdin isn't a terminal or an agent marker (`CLAUDECODE`, `AI_AGENT`,
69
- `SAMAGOTCHI_PARENT_SESSION`) is set.
69
+ `CODEX_THREAD_ID`, `SAMAGOTCHI_PARENT_SESSION`) is set.
70
70
 
71
71
  The worker checks it again with its own config.yml. The guardrails settings
72
72
  have no environment variables, and a worker drops any `SAMAGOTCHI_GUARDRAILS_*`
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require "fileutils"
5
+ require_relative "../atomic_file"
3
6
  require_relative "../events"
4
7
 
5
8
  module Samagotchi
@@ -20,32 +23,70 @@ module Samagotchi
20
23
  # A card with an earlier card's id replaces it where it was. Each entry
21
24
  # says where it belongs as the recap does: +turns_since+, the turns
22
25
  # completed after it (after its own turn for a card shown during one),
23
- # and +current+ for a card of the turn running now. Turns count from
24
- # this worker's start; the ones before it are in no entry. A card that
26
+ # and +current+ for a card of the turn running now. A card that
25
27
  # isn't the turn's but comes while one runs (an anytime command's, such
26
28
  # as /btw) goes after that turn: its prompt is already in the history.
27
29
  # A failed turn leaves no prompt, so its cards stay before the next one.
30
+ #
31
+ # Notices (hook notices, load warnings, "asking again" rows) and cards
32
+ # (with questions) are capped apart, +capacity+ each, so a burst of
33
+ # notices can't push out a card. An open question (an approval too) is
34
+ # never pushed out: the oldest other card or answered question goes.
35
+ #
36
+ # With a +path+ (the Bridge's: FILE in the session's folder) the store
37
+ # saves its entries and turn count there on each change, and a later
38
+ # worker's store starts from them, so a turn's cards and notices outlive
39
+ # the worker that showed them; the web reads the file (.saved) for a
40
+ # session no worker runs. Turns count on from the saved count: the
41
+ # placement is relative (turns since), so only the turns after an entry
42
+ # matter. The load warnings stay the worker's own (each start announces
43
+ # its own), and a question still open when the worker went waits for no
44
+ # one: neither is saved or seeded. Same caps as in memory. An entry from
45
+ # an earlier worker lists with +earlier+: true (an attached TUI's resync
46
+ # onto a new worker doesn't print those again).
28
47
  class CardStore
29
48
  CAPACITY = 20
49
+ NOTICE_TYPES = %i[hook_notice guardrail_warning empty_answer_retry].freeze
50
+ FILE = "cards.json"
51
+ # The events after which the file is written again: the ones that
52
+ # change an entry or the turn count.
53
+ SAVED_ON = (%i[card hook_notice empty_answer_retry question_requested question_answered question_cancelled
54
+ turn_failed] + Events::TURN_KEPT).freeze
55
+
56
+ # What a session's saved file lists, as a store started from it lists
57
+ # (no turn running): the web's cards for a session no worker runs.
58
+ # @param session_dir [String]
59
+ # @return [Array<Hash>] [] without a readable file
60
+ def self.saved(session_dir)
61
+ new(path: File.join(session_dir, FILE)).list
62
+ end
30
63
 
31
- def initialize(capacity: CAPACITY)
64
+ # @param path [String, nil] the file the entries are saved in and
65
+ # started from; nil keeps them in memory only
66
+ def initialize(capacity: CAPACITY, path: nil)
32
67
  @capacity = capacity
68
+ @path = path
33
69
  @mutex = Mutex.new
34
70
  @entries = []
35
71
  @turns_done = 0
36
72
  @running = false
37
73
  @iteration = nil
38
74
  @calls = 0
75
+ seed
39
76
  end
40
77
 
41
78
  def call(event)
42
- @mutex.synchronize { fold(event) }
79
+ @mutex.synchronize do
80
+ fold(event)
81
+ save if SAVED_ON.include?(event[:type])
82
+ end
43
83
  rescue StandardError
44
84
  nil # never break the running turn
45
85
  end
46
86
 
47
87
  # @return [Array<Hash>] oldest first: the card's (or notice's) fields
48
- # plus turns_since: and current:, and during: true for a card that
88
+ # plus turns_since: and current:, earlier: true for one an earlier
89
+ # worker saved, and during: true for a card that
49
90
  # came while the running turn runs but isn't that turn's (it goes
50
91
  # after the running turn's prompt)
51
92
  def list
@@ -63,6 +104,42 @@ module Samagotchi
63
104
 
64
105
  private
65
106
 
107
+ def save
108
+ return unless @path
109
+
110
+ entries = @entries.reject { |entry| entry[:type] == :guardrail_warning }
111
+ # A session that never showed one gets no file (the turn count
112
+ # only places entries relative to each other).
113
+ return if entries.empty? && !File.exist?(@path)
114
+
115
+ FileUtils.mkdir_p(File.dirname(@path))
116
+ AtomicFile.write(@path, JSON.generate({ "version" => 1, "turns" => @turns_done, "entries" => entries }))
117
+ rescue StandardError
118
+ nil # a card that can't be saved still shows live
119
+ end
120
+
121
+ # The entries an earlier worker saved (a missing or broken file:
122
+ # none), their types as symbols again; a card that came during that
123
+ # worker's last turn stays where it was, as after a failed turn.
124
+ def seed
125
+ return unless @path && File.file?(@path)
126
+
127
+ data = JSON.parse(File.read(@path), symbolize_names: true)
128
+ return unless data.is_a?(Hash) && data[:turns].is_a?(Integer) && data[:entries].is_a?(Array)
129
+
130
+ @turns_done = data[:turns]
131
+ @entries = data[:entries].filter_map do |entry|
132
+ next unless entry.is_a?(Hash) && entry[:type].is_a?(String) && entry[:turns].is_a?(Integer)
133
+
134
+ entry = entry.merge(type: entry[:type].to_sym, earlier: true)
135
+ entry unless entry[:type] == :guardrail_warning || open_question?(entry)
136
+ end
137
+ settle_during(after_turn: false)
138
+ rescue StandardError
139
+ @turns_done = 0
140
+ @entries = []
141
+ end
142
+
66
143
  def fold(event)
67
144
  case event[:type]
68
145
  when :turn_started
@@ -144,7 +221,18 @@ module Samagotchi
144
221
 
145
222
  def push(entry)
146
223
  @entries << entry
147
- @entries.shift while @entries.size > @capacity
224
+ notice = notice?(entry)
225
+ kind = @entries.select { |e| notice?(e) == notice }
226
+ return if kind.size <= @capacity
227
+
228
+ evict = kind.find { |e| !open_question?(e) }
229
+ @entries.delete_at(@entries.index { |e| e.equal?(evict) }) if evict
230
+ end
231
+
232
+ def notice?(entry) = NOTICE_TYPES.include?(entry[:type])
233
+
234
+ def open_question?(entry)
235
+ entry[:type] == :question && !entry.key?(:answer) && !entry[:cancelled]
148
236
  end
149
237
  end
150
238
  end
@@ -95,7 +95,9 @@ module Samagotchi
95
95
  @port = port
96
96
  @ring = RingBuffer.new(capacity: ring_capacity)
97
97
  @accumulator = TurnAccumulator.new
98
- @cards = CardStore.new
98
+ # Saved in the session's folder: a later worker, and the web without
99
+ # one, show the cards where they were.
100
+ @cards = CardStore.new(path: File.join(session_dir, CardStore::FILE))
99
101
  @pending_card = PendingCard.new(session_dir)
100
102
  @heartbeat_interval = heartbeat_interval
101
103
  @epoch = SecureRandom.hex(4)
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: check-in
3
- version: 0.1.3
3
+ version: 0.2.0
4
4
  scope: system
5
5
  description: "Checks on a long turn: after N tool calls with no answer, a card asks the user (Nudge / Keep going / Stop), or the model is nudged to say what it has found"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:c5aa4c49989723ec2370184cee36f4c8515d9df818b2c45422a5efb6c4ba31a7
9
+ sha256: sha256:0a5ba60962206b3ec90bbaa0934a7284416aef495df8a70391d9ea47f7eb9ec1
10
10
  requires_chi: ">= 0.3.0"
@@ -15,12 +15,19 @@
15
15
  # (a card's actions run while the turn goes on), so the state is behind a
16
16
  # Mutex.
17
17
  #
18
+ # What /checkin changes (on/off, mode, the threshold) is the session's: it
19
+ # is saved in the bundle's data dir (sessions/<id>.json) and read back
20
+ # when the session comes back (a worker's restart), before its first turn
21
+ # or command. Another session starts from the settings.
22
+ #
18
23
  # Settings (config.yml, bundles: check-in:):
19
24
  # after: 50 tool calls in one turn before the first check-in
20
25
  # every: 50 then again every this many more
21
26
  # mode: ask ask | nudge | notify
22
27
  # message: "..." what a nudge says; {calls} is the count
23
28
  # ignore_tools: [task_wait, task_get, delegate_result]
29
+ require "fileutils"
30
+ require "json"
24
31
  require "securerandom"
25
32
 
26
33
  class Plugin
@@ -39,6 +46,8 @@ class Plugin
39
46
  @message = message.empty? ? DEFAULT_MESSAGE : message
40
47
  @ignore = settings.key?("ignore_tools") ? Array(settings["ignore_tools"]).map(&:to_s) : DEFAULT_IGNORE
41
48
  @enabled = true
49
+ @defaults = { "enabled" => @enabled, "mode" => @mode, "after" => @after, "every" => @every }
50
+ @session = :none # the session whose /checkin state is loaded
42
51
  @mutex = Mutex.new
43
52
  reset
44
53
  end
@@ -69,7 +78,10 @@ class Plugin
69
78
 
70
79
  def before_turn(ctx)
71
80
  close_card(ctx, "The turn ended after #{calls} tool calls.")
72
- @mutex.synchronize { reset }
81
+ @mutex.synchronize do
82
+ load_session(ctx)
83
+ reset
84
+ end
73
85
  end
74
86
 
75
87
  def after_tool_call(event, ctx)
@@ -77,6 +89,7 @@ class Plugin
77
89
  return if @ignore.include?(tool)
78
90
 
79
91
  due = @mutex.synchronize do
92
+ load_session(ctx)
80
93
  @count += 1
81
94
  @tools = (@tools + [tool]).last(LAST_TOOLS)
82
95
  next nil unless @enabled && @count >= @next_at
@@ -159,18 +172,25 @@ class Plugin
159
172
  # --- /checkin --------------------------------------------------------------
160
173
 
161
174
  def command(args, ctx)
175
+ @mutex.synchronize { load_session(ctx) }
162
176
  case args
163
177
  when "" then status
164
178
  when "on", "off"
165
- @mutex.synchronize { @enabled = args == "on" }
179
+ @mutex.synchronize do
180
+ @enabled = args == "on"
181
+ save_session(ctx)
182
+ end
166
183
  "check-in is #{args} for this session"
167
184
  when /\A\d+\z/
168
- threshold(Integer(args))
185
+ threshold(Integer(args), ctx)
169
186
  when /\Amode\s+(\S+)\z/
170
187
  mode = Regexp.last_match(1)
171
188
  return "check-in: unknown mode #{mode} (ask, nudge or notify)" unless MODES.include?(mode)
172
189
 
173
- @mutex.synchronize { @mode = mode }
190
+ @mutex.synchronize do
191
+ @mode = mode
192
+ save_session(ctx)
193
+ end
174
194
  "check-in mode is #{mode} for this session"
175
195
  when "nudge" then nudge(ctx)
176
196
  when "later" then later(ctx)
@@ -189,12 +209,13 @@ class Plugin
189
209
 
190
210
  # For this session: check in at +n+ calls and every +n+ after; a turn
191
211
  # already past it is checked on +n+ calls from now.
192
- def threshold(n)
212
+ def threshold(n, ctx)
193
213
  return "check-in: the threshold must be at least 1" unless n.positive?
194
214
 
195
215
  @mutex.synchronize do
196
216
  @after = @every = n
197
217
  @next_at = n > @count ? n : @count + n
218
+ save_session(ctx)
198
219
  end
199
220
  "check-in after #{n} tool calls, then every #{n}, for this session"
200
221
  end
@@ -223,6 +244,48 @@ class Plugin
223
244
  nil
224
245
  end
225
246
 
247
+ # --- the session's /checkin state (under @mutex) ---------------------------
248
+
249
+ # The current session's state, when the session isn't the one loaded:
250
+ # its saved file over the settings.
251
+ def load_session(ctx)
252
+ id = ctx.session_id
253
+ return if id == @session
254
+
255
+ @session = id
256
+ state = @defaults.merge(read_state(ctx, id))
257
+ @enabled = state["enabled"] == true
258
+ @mode = MODES.include?(state["mode"]) ? state["mode"] : @defaults["mode"]
259
+ @after = positive(state["after"]) || @defaults["after"]
260
+ @every = positive(state["every"]) || @defaults["every"]
261
+ @next_at = @after if @count.zero?
262
+ end
263
+
264
+ def read_state(ctx, id)
265
+ return {} unless id
266
+
267
+ data = JSON.parse(File.read(state_path(ctx, id)))
268
+ data.is_a?(Hash) ? data : {}
269
+ rescue SystemCallError, JSON::ParserError
270
+ {}
271
+ end
272
+
273
+ # Written aside and renamed: a reader never sees half a file.
274
+ def save_session(ctx)
275
+ id = ctx.session_id
276
+ return unless id
277
+
278
+ path = state_path(ctx, id)
279
+ FileUtils.mkdir_p(File.dirname(path))
280
+ temp = "#{path}.#{Process.pid}.tmp"
281
+ File.write(temp, JSON.generate({ "enabled" => @enabled, "mode" => @mode, "after" => @after, "every" => @every }))
282
+ File.rename(temp, path)
283
+ rescue SystemCallError => e
284
+ ctx.log.warn("check_in_state_not_saved", msg: e.message) if ctx.respond_to?(:log)
285
+ end
286
+
287
+ def state_path(ctx, id) = File.join(ctx.data_dir, "sessions", "#{id.to_s.gsub(/[^A-Za-z0-9_.-]+/, "_")}.json")
288
+
226
289
  # --- helpers ---------------------------------------------------------------
227
290
 
228
291
  def calls = @mutex.synchronize { @count }