samagotchi 0.18.1 → 0.20.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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +167 -1
  3. data/README.md +4 -3
  4. data/docs/architecture.md +21 -2
  5. data/docs/cli.md +48 -11
  6. data/docs/configuration.md +12 -5
  7. data/docs/guardrails.md +92 -14
  8. data/docs/memory.md +4 -0
  9. data/docs/plugins.md +5 -0
  10. data/docs/sessions.md +7 -5
  11. data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
  12. data/lib/samagotchi/bootstrap_command.rb +26 -5
  13. data/lib/samagotchi/bridge.rb +25 -9
  14. data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
  15. data/lib/samagotchi/bridge_client.rb +9 -5
  16. data/lib/samagotchi/bundle_command.rb +24 -5
  17. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  21. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  27. data/lib/samagotchi/commands/registry.rb +4 -3
  28. data/lib/samagotchi/config.rb +3 -0
  29. data/lib/samagotchi/context_usage.rb +3 -3
  30. data/lib/samagotchi/engine.rb +25 -5
  31. data/lib/samagotchi/guardrail_wiring.rb +10 -3
  32. data/lib/samagotchi/guardrails/approval.rb +14 -7
  33. data/lib/samagotchi/guardrails/approvals.rb +69 -15
  34. data/lib/samagotchi/guardrails/context.rb +16 -9
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
  36. data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
  37. data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
  38. data/lib/samagotchi/guardrails/rules.rb +69 -5
  39. data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
  40. data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
  41. data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
  42. data/lib/samagotchi/guardrails/targets.rb +65 -4
  43. data/lib/samagotchi/guardrails.rb +2 -0
  44. data/lib/samagotchi/hooks/registry.rb +6 -2
  45. data/lib/samagotchi/host_registry.rb +22 -4
  46. data/lib/samagotchi/idle_recap.rb +7 -2
  47. data/lib/samagotchi/idle_scheduler.rb +1 -1
  48. data/lib/samagotchi/installed_versions.rb +91 -0
  49. data/lib/samagotchi/live_versions.rb +5 -3
  50. data/lib/samagotchi/llm/usage.rb +1 -1
  51. data/lib/samagotchi/memory_bundle/builder.rb +25 -6
  52. data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
  53. data/lib/samagotchi/memory_bundle/installer.rb +79 -13
  54. data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
  55. data/lib/samagotchi/memory_bundle/status.rb +17 -5
  56. data/lib/samagotchi/memory_bundle/uninstaller.rb +7 -7
  57. data/lib/samagotchi/memory_paths.rb +6 -0
  58. data/lib/samagotchi/model_overlay.rb +39 -0
  59. data/lib/samagotchi/model_profile.rb +4 -0
  60. data/lib/samagotchi/relay_desk.rb +9 -0
  61. data/lib/samagotchi/self_report.rb +38 -2
  62. data/lib/samagotchi/session.rb +35 -6
  63. data/lib/samagotchi/session_commands.rb +13 -2
  64. data/lib/samagotchi/session_manager.rb +166 -11
  65. data/lib/samagotchi/session_metrics.rb +260 -32
  66. data/lib/samagotchi/sessions_command.rb +43 -4
  67. data/lib/samagotchi/system_prompt.rb +10 -1
  68. data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -3
  69. data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
  70. data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
  71. data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
  72. data/lib/samagotchi/token_usage.rb +57 -41
  73. data/lib/samagotchi/tool_runner.rb +21 -2
  74. data/lib/samagotchi/tools/builtins.rb +21 -5
  75. data/lib/samagotchi/tools/execute.rb +9 -8
  76. data/lib/samagotchi/turn_note.rb +3 -2
  77. data/lib/samagotchi/update_command.rb +33 -12
  78. data/lib/samagotchi/version.rb +1 -1
  79. data/lib/samagotchi/web/app.rb +83 -11
  80. data/lib/samagotchi/web/public/app.js +135 -43
  81. data/lib/samagotchi/web/public/card.js +34 -2
  82. data/lib/samagotchi/web/public/ctx.js +59 -0
  83. data/lib/samagotchi/web/public/data.js +6 -0
  84. data/lib/samagotchi/web/public/format.js +15 -2
  85. data/lib/samagotchi/web/public/index.html +37 -2
  86. data/lib/samagotchi/web/public/model_pick.js +6 -2
  87. data/lib/samagotchi/web/public/model_picker.js +2 -1
  88. data/lib/samagotchi/web/public/question_card.js +14 -1
  89. data/lib/samagotchi/web/public/question_cards.js +17 -11
  90. data/lib/samagotchi/web/public/scope.js +15 -6
  91. data/lib/samagotchi/web/public/session_state.js +4 -0
  92. data/lib/samagotchi/web/public/timing.js +6 -2
  93. data/lib/samagotchi/web/public/turn_events.js +108 -4
  94. data/lib/samagotchi/web/public/update_notice.js +87 -14
  95. data/lib/samagotchi/web/server.rb +28 -1
  96. data/lib/samagotchi/web/session_hub.rb +30 -3
  97. data/lib/samagotchi/web/session_summary.rb +11 -2
  98. data/lib/samagotchi/worker.rb +25 -8
  99. data/lib/samagotchi/worker_idle_exit.rb +31 -2
  100. data/lib/samagotchi/worker_sidecar.rb +7 -5
  101. metadata +7 -1
data/docs/sessions.md CHANGED
@@ -13,17 +13,19 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
13
13
 
14
14
  A session is deleted if **expired by age OR overflow by count** (unless `keep_status` or the live-owner guard: a session a worker or `chi` still has open is never pruned). A session's `status` is its turn state (`idle`/`running`), not whether a worker is alive. Orphan dirs without a `*.json` are never deleted, except skeleton-only ones (see *Empty sessions*). Deletion removes both `*.json` and sidecar dir atomically. Set both `DAYS=0` and `MAX=0` to retain forever.
15
15
 
16
- **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered and no continue offer (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`.
16
+ **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up. The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, reminders, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
17
17
 
18
18
  **Empty sessions:** a session nothing happened in is deleted when it is left, so `chi` → `/exit` leaves no trace. Empty means: no messages, no turn tried (a failed turn leaves a `last_prompt` and keeps it), no memory used, no pending question, nothing in `input/`, `notes/` or `images/`, nothing in its dir beyond the worker's skeleton, mode `assist`, and the default model. A session on another model (`/model`, `--model`) counts as prepared and stays (`SessionManager.discardable?`). The worker checks as it idle-exits or leaves on a client's request and deletes it once its lock is free, checking again first. The attached `/exit` then says `Detached; the session was empty, so it is discarded.` The REPL (`--no-shared`) does the same at `/exit` or Ctrl-D. The retention sweep catches those killed first: empty sessions over an hour old with no live owner, whatever `DAYS`/`MAX`, and skeleton-only dirs with no `*.json`. `session.keep_empty: true` (env `SAMAGOTCHI_SESSION_KEEP_EMPTY`) turns all of it off.
19
19
 
20
20
  **Attached by default:** plain `chi` and `chi --resume ID` run their session in a background worker and attach to it (`session.shared`, default `true`; env `SAMAGOTCHI_SESSION_SHARED`). The worker runs in the session's `working_directory`, so `!commands` and tools see the directory the session was started in. `session.shared: false` keeps the in-process REPL, `--no-shared` does for one run; a session the REPL has open can't be attached ("close it there first"). See [CLI: Sharing a session](cli.md#sharing-a-session).
21
21
 
22
+ **Which chi a worker runs:** a new or woken worker runs the newest chi installed for this Ruby (`gem 'samagotchi', '>= 0.a'`, prereleases count), whoever starts it: a `chi web` or terminal still on an older chi starts it on the newest one, so a stale process doesn't spread old code. A worker already running keeps its version until it is restarted. Should the newest install fail to load, the worker runs the starter's own version and logs `boot_fallback` (its stdio is `/dev/null`). Under `bundle exec` the worker drops Bundler's env first, so it activates the newest gem too. Two consequences: a wrapper pin (`chi _0.17.0_ web`) pins only that process, not the workers it starts; and a source checkout (`bin/chi` in a worktree, `bundle exec`) starts its workers on its own `lib/`, but a session it made that is later woken by an installed chi (`chi web`, `chi send`) runs the installed code. The names an older starter hands a newer worker (the boot line, its env keys, `input/` files, the session JSON fields and the Bridge routes sessions use on each other) are pinned in `spec/worker_contract_spec.rb`.
23
+
22
24
  **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s.
23
25
 
24
26
  **Deleting a session:** `chi sessions delete ID...`, `/exit --delete` in a terminal and the Web UI's delete all go through `SessionManager.delete_session`: it resolves a unique prefix, removes `<id>.json` and the whole `<id>/` dir, and returns what it removed. A session a plain REPL has open (`owner.lock` kind `tui`) is always refused. One a worker runs is refused unless the caller asks to stop it: then it stops the worker as `chi sessions stop` does and deletes once `owner.lock` is free (`--force` on the CLI, 10 s; the web always, 2 s; `/exit --delete` after the worker agreed to exit, 10 s). A worker that outlives the wait leaves the session in place. The web's route is `DELETE /api/sessions/:id` (200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
25
27
 
26
- <a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered (the step-limit question too); a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn refuses it), and an empty session is discarded instead; `chi scratch` refuses it. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and "include archived" by the all-sessions search shows them, dimmed with an `archived` badge. The info bar reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
28
+ <a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered (the step-limit question too); a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn refuses it), and an empty session is discarded instead; `chi scratch` refuses it. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and "include archived" by the all-sessions search shows them, dimmed with an `archived` badge. The info bar marks a session whose live worker runs an older chi than the newest installed (`worker_version` in its summary): `chi 0.18.1 · Restart`, which asks to confirm and calls `POST /api/sessions/:id/restart` (a refusal's reason shows in a toast); a worker from before restarts shows `chi sessions stop ID` instead. The info bar reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
27
29
 
28
30
  **Lazy sweep:** automatic prune runs only while `chi web` runs, at most once per `session.sweep_interval_hours` (default 24): the web's session hub gives it a chance on its periodic full scan, and `GET /api/sessions` does too. No cron, and a terminal-only chi never sweeps. Manual prune is always available.
29
31
 
@@ -63,7 +65,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
63
65
  **Ordering:**
64
66
 
65
67
  - `Session.list` / `SessionManager.list_sessions` / `GET /api/sessions?sort=&order=&limit=&offset=` default to `updated_at desc` (newest activity first). Also supports `created_at`, `asc`. `X-Total-Count` header when paginated.
66
- - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again); "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). The info bar copies `chi --attach <id>` for a terminal. The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first; typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id}], warning?}`; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
68
+ - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again); "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). The info bar copies `chi --attach <id>` for a terminal. It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only. The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first; typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, sampling?}], warning?}`; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
67
69
  - Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
68
70
  - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `format.js` (pure formatters such as `previewOf`), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
69
71
  - Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
@@ -118,8 +120,8 @@ system message marked `kind: turn_note` (the UIs don't show it; they showed the
118
120
  before any answer: network error after 2 attempts (host main: Errno::ECONNREFUSED). The message went back to
119
121
  the user, who may send it again.]` in the REPL and attached mode, where the prompt is given back;
120
122
  `… The user's last message was not answered.]` after `-p`, where the prompt stays in the session;
121
- - cancelled (Ctrl-C, the web's stop): `[SYSTEM: the previous turn was cancelled (ctrl-c) after 12s; the answer
122
- above ends where it was cut off.]`, or `…; no answer had been shown.]` when only thinking had streamed;
123
+ - cancelled (Ctrl-C, the web's stop): `[SYSTEM: the previous turn was cancelled (ctrl-c) after 12s; the reply
124
+ 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;
123
125
  - ended with nothing visible: `[SYSTEM: the previous turn ended with no visible answer (thinking only, or
124
126
  nothing). The user's last message is still unanswered.]`.
125
127
 
@@ -95,20 +95,31 @@ module Samagotchi
95
95
  # +base+, with -2, -3… when the name is taken or is the prefix of a
96
96
  # model id (chi would read "qwen3:8b" as host qwen3, model 8b).
97
97
  def host_name(base, requested: nil, model_ids: [])
98
+ host_name_with_reason(base, requested: requested, model_ids: model_ids).first
99
+ end
100
+
101
+ # [name, reason]: reason is :taken when the name was suffixed because
102
+ # config.yml already has it, :model_prefix when a model id's prefix
103
+ # would read as a host, nil when the name is free as given.
104
+ def host_name_with_reason(base, requested: nil, model_ids: [])
98
105
  taken = current_hosts.keys
99
106
  if requested
100
107
  name = requested.to_s.strip
101
108
  raise Error, "--name must match #{ConfigFile::HOST_NAME_RE.source}" unless name.match?(ConfigFile::HOST_NAME_RE)
102
109
  raise Error, "config.yml already has a host named '#{name}'; pick another --name" if taken.include?(name.downcase)
103
110
 
104
- return name
111
+ return [name, nil]
105
112
  end
106
113
 
107
114
  prefixes = [*model_ids, configured_default_model].compact.map { |id| id.to_s.split(":", 2).first.downcase }
108
115
  name = base
109
116
  n = 1
110
- name = "#{base}-#{n += 1}" while taken.include?(name) || prefixes.include?(name)
111
- name
117
+ reason = nil
118
+ while taken.include?(name) || prefixes.include?(name)
119
+ reason ||= taken.include?(name) ? :taken : :model_prefix
120
+ name = "#{base}-#{n += 1}"
121
+ end
122
+ [name, reason]
112
123
  end
113
124
 
114
125
  # Write the entry. @param fields [Hash] the hosts entry (string keys,
@@ -3,6 +3,7 @@
3
3
  require "rbconfig"
4
4
  require_relative "config"
5
5
  require_relative "model_profile"
6
+ require_relative "host_registry"
6
7
  require_relative "bootstrap/probe"
7
8
  require_relative "bootstrap/config_writer"
8
9
  require_relative "bootstrap/bundles"
@@ -235,9 +236,11 @@ module Samagotchi
235
236
 
236
237
  def test(result, model, key_env)
237
238
  done = Queue.new
238
- waiting = Thread.new do
239
- @stdout.puts("test: waiting for an answer (loading the model?)…") if done.pop(timeout: LOADING_AFTER).nil?
240
- end
239
+ waiting = if loading_hint?(result)
240
+ Thread.new do
241
+ @stdout.puts("test: waiting for an answer (loading the model?)…") if done.pop(timeout: LOADING_AFTER).nil?
242
+ end
243
+ end
241
244
  seconds = @probe.test_turn(result.candidate, model, key_env: key_env)
242
245
  @stdout.puts(format("test: answered in %.1f s", seconds))
243
246
  true
@@ -249,17 +252,25 @@ module Samagotchi
249
252
  waiting&.join
250
253
  end
251
254
 
255
+ # The "loading the model?" hint fits a local server (a llama.cpp loading
256
+ # weights); a remote provider's slow answer is the network, not a load.
257
+ def loading_hint?(result)
258
+ !HostRegistry.remote_address?(result.candidate.scheme, result.candidate.host)
259
+ end
260
+
252
261
  # 0 written, 1 written but not as planned, nil with @exit on a refusal.
253
262
  def write(result, model, key_env, options)
254
263
  writer = Bootstrap::ConfigWriter.new(path: @config_path, env: @env)
255
264
  fields = host_fields(result.candidate, result.native?, key_env)
256
265
  ids = result.models.map(&:id)
257
- name = begin
258
- writer.host_name(Bootstrap::ConfigWriter.derived_name(result.candidate.host), requested: options[:name], model_ids: ids)
266
+ base = Bootstrap::ConfigWriter.derived_name(result.candidate.host)
267
+ name, reason = begin
268
+ writer.host_name_with_reason(base, requested: options[:name], model_ids: ids)
259
269
  rescue Bootstrap::ConfigWriter::Error => e
260
270
  @exit = usage_error(e.message)
261
271
  return nil
262
272
  end
273
+ explain_name(base, name, reason)
263
274
  outcome = writer.write(name: name, fields: fields, model: model, dry_run: options[:dry_run])
264
275
  code = report(outcome, model)
265
276
  return code unless INSTALLS_BUNDLES.include?(outcome.kind)
@@ -269,6 +280,16 @@ module Samagotchi
269
280
  code
270
281
  end
271
282
 
283
+ # One line when the name wasn't free as derived: why chi picked another.
284
+ def explain_name(base, name, reason)
285
+ case reason
286
+ when :taken
287
+ @stdout.puts("a host named #{base} already exists; saved as #{name}, use --name to choose")
288
+ when :model_prefix
289
+ @stdout.puts("a model id starts with #{base} (chi would read it as a host); saved as #{name}, use --name to choose")
290
+ end
291
+ end
292
+
272
293
  # The system bundle, core, and dev when a terminal says yes: one line
273
294
  # each. 1 when something failed.
274
295
  def install_bundles(dry_run: false)
@@ -212,8 +212,10 @@ module Samagotchi
212
212
  # The pending question at the top level too: one asked between turns
213
213
  # (the step-limit question) belongs to no turn, so the accumulator
214
214
  # doesn't keep it.
215
+ # chi_version: the chi this worker runs (an attaching TUI compares it
216
+ # with its own).
215
217
  # @return [Hash] {messages:, current_turn:, queued:, recap:, saved_recap:, continue_offer:, pending_question:,
216
- # guardrail_warning:, plugin_warning:, init_tasks:, cards:, commands:, event_seq:, event_id:}
218
+ # guardrail_warning:, plugin_warning:, init_tasks:, cards:, commands:, chi_version:, event_seq:, event_id:}
217
219
  def snapshot
218
220
  @engine.synchronize_events do
219
221
  seq = @engine.event_count
@@ -230,6 +232,7 @@ module Samagotchi
230
232
  init_tasks: @engine.init_tasks,
231
233
  cards: @cards.list,
232
234
  commands: command_registry.listing,
235
+ chi_version: Samagotchi::VERSION,
233
236
  event_seq: seq,
234
237
  event_id: event_id(seq)
235
238
  }
@@ -452,6 +455,12 @@ module Samagotchi
452
455
  !host.empty? && !LOOPBACK_NAMES.include?(host.downcase.sub(/:\d*\z/, ""))
453
456
  end
454
457
 
458
+ # What a client may ask of this worker beyond the routes, named in its
459
+ # sidecar (WorkerSidecar#features): a client checks a name here rather
460
+ # than a version.
461
+ # restart: POST /exit takes "restart": true (WorkerIdleExit#hold_for_restart).
462
+ FEATURES = ["restart"].freeze
463
+
455
464
  # The routes, by method and what follows /session/:id/: a handler takes
456
465
  # the session id and the request body and returns [headers, status, body].
457
466
  # GET stream is served apart (#serve_sse owns the connection).
@@ -683,11 +692,13 @@ module Samagotchi
683
692
  @engine.announce(type: :command_queued, **command, **anytime)
684
693
  end
685
694
 
686
- # A client asks the worker to exit now (`/exit` in the attached TUI). The
687
- # worker decides with the event log held, so no POST /turn lands between
688
- # its check and its answer; it leaves from its loop after this reply.
689
- # 200 {status: "exiting", discard?: the session is empty and goes}, or 409 {status: "held", reason:} naming what
690
- # keeps it up. Returns [headers, status, body].
695
+ # A client asks the worker to exit now (`/exit` in the attached TUI), or
696
+ # with "restart": true to hand the session to a new worker on the newest
697
+ # chi installed (the "restart" feature). The worker decides with the
698
+ # event log held, so no POST /turn lands between its check and its
699
+ # answer; it leaves from its loop after this reply.
700
+ # 200 {status: "exiting", discard?: the session is empty and goes} ({status: "restarting"} for a restart), or 409
701
+ # {status: "held", reason:} naming what keeps it up. Returns [headers, status, body].
691
702
  def handle_exit_request(session_id, body)
692
703
  return [{}, 501, { error: "exit_unavailable" }] unless @on_exit_request
693
704
 
@@ -696,8 +707,12 @@ module Samagotchi
696
707
 
697
708
  client_id = fetched(parsed, "client_id")
698
709
  delete = fetched(parsed, "delete") == true
699
- reason = @engine.synchronize_events { @on_exit_request.call(client_id, delete: delete) }
710
+ restart = fetched(parsed, "restart") == true
711
+ options = restart ? { delete: delete, restart: true } : { delete: delete }
712
+ reason = @engine.synchronize_events { @on_exit_request.call(client_id, **options) }
700
713
  if reason.nil?
714
+ return [{}, 200, { status: "restarting", session_id: @session_id }] if restart
715
+
701
716
  body = { status: "exiting", session_id: @session_id }
702
717
  body[:discard] = @exit_discards.call == true if @exit_discards
703
718
  return [{}, 200, body]
@@ -1029,9 +1044,10 @@ module Samagotchi
1029
1044
  end
1030
1045
 
1031
1046
  def write_sidecar
1032
- # version: the chi this worker runs (chi update reports older ones).
1047
+ # version: the chi this worker runs (chi update and the web's badge
1048
+ # report older ones); features: what a client may ask of it.
1033
1049
  WorkerSidecar.new(port: @port, bind: @bind, session_id: @session_id, started_at: Time.now.iso8601(3),
1034
- version: Samagotchi::VERSION, input_format: @input_format).write(session_dir)
1050
+ version: Samagotchi::VERSION, input_format: @input_format, features: FEATURES).write(session_dir)
1035
1051
  rescue StandardError => e
1036
1052
  Log.warn(:bridge, "sidecar_write_failed", echo: "Bridge: failed to write #{WorkerSidecar::FILE}: #{e.class}: #{e.message}", error: e.class.name)
1037
1053
  end
@@ -17,8 +17,17 @@ module Samagotchi
17
17
  # not serve the session (404), it yields one synthetic
18
18
  # `{"type" => "stream_closed", "reason" => ...}` and stops. #close stops
19
19
  # it without that event.
20
+ #
21
+ # With +rediscover+, a failed connection first looks for the session's
22
+ # live worker (its sidecar): a new one (a restart) is followed from its
23
+ # snapshot, announced by a synthetic `{"type" => "worker_changed",
24
+ # "port" => ...}` first, since its event ids start over. (A new worker
25
+ # on the old one's port is the plain reconnect: its Bridge can't replay
26
+ # the old ids and sends a reset.)
20
27
  class EventStream
21
28
  DEFAULT_RECONNECT_DELAYS = [0.1, 0.25, 0.5, 1.0, 2.0].freeze
29
+ # Long enough for a restarted worker to boot (its Engine, the plugins).
30
+ REDISCOVER_DELAYS = [0.1, 0.25, 0.5, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0].freeze
22
31
  HEADER_TIMEOUT = 5.0
23
32
 
24
33
  # @return [String, nil] the id of the last frame yielded (the reconnect cursor)
@@ -30,9 +39,13 @@ module Samagotchi
30
39
  # tell this client's stream from others' (Bridge#open_streams_except)
31
40
  # @param reconnect_delays [Array<Float>] sleeps between failed attempts;
32
41
  # one more failure than there are delays gives up
42
+ # @param rediscover [#call, nil] → a BridgeClient for the session's live
43
+ # worker now, nil when there is none yet, or :gone to give up now
33
44
  # @yieldparam event [Hash] string-keyed event
34
- def initialize(client, snapshot: true, client_id: nil, reconnect_delays: DEFAULT_RECONNECT_DELAYS, &on_event)
45
+ def initialize(client, snapshot: true, client_id: nil, reconnect_delays: DEFAULT_RECONNECT_DELAYS, rediscover: nil,
46
+ &on_event)
35
47
  @client = client
48
+ @rediscover = rediscover
36
49
  params = []
37
50
  params << ["snapshot", "1"] if snapshot
38
51
  params << ["client_id", client_id] if client_id
@@ -86,6 +99,13 @@ module Samagotchi
86
99
  next
87
100
  end
88
101
 
102
+ case rediscover_worker
103
+ when :moved
104
+ failures = 0
105
+ next
106
+ when :gone then return finish("unreachable")
107
+ end
108
+
89
109
  failures += 1
90
110
  return finish("unreachable") if failures > @reconnect_delays.size
91
111
 
@@ -148,6 +168,22 @@ module Samagotchi
148
168
  nil
149
169
  end
150
170
 
171
+ # @return [Symbol, nil] :moved to a new worker's Bridge, :gone, or nil
172
+ def rediscover_worker
173
+ return nil unless @rediscover
174
+
175
+ found = @rediscover.call
176
+ return :gone if found == :gone
177
+ return nil if found.nil? || found.port == @client.port
178
+
179
+ @client = found
180
+ @last_event_id = nil
181
+ @on_event.call({ "type" => "worker_changed", "port" => found.port }) unless closed?
182
+ :moved
183
+ rescue StandardError
184
+ nil
185
+ end
186
+
151
187
  def finish(reason)
152
188
  @on_event.call({ "type" => "stream_closed", "reason" => reason }) unless closed?
153
189
  end
@@ -156,14 +156,16 @@ module Samagotchi
156
156
  post("command", payload, read_body: true)
157
157
  end
158
158
 
159
- # POST /session/:id/exit: ask the worker to exit now. 200 = it will
160
- # (status "exiting"), 409 = something keeps it up (reason), 404 with
159
+ # POST /session/:id/exit: ask the worker to exit now (+restart+: hand
160
+ # the session to a new worker, a worker with the "restart" feature). 200
161
+ # = it will (status "exiting"/"restarting"), 409 = something keeps it up (reason), 404 with
161
162
  # error "not_found" = a worker older than the route.
162
163
  # @param client_id [String] the asking UI, whose own streams don't hold
163
164
  # @return [Response]
164
- def request_exit(client_id:, delete: false)
165
+ def request_exit(client_id:, delete: false, restart: false)
165
166
  body = { client_id: client_id }
166
167
  body[:delete] = true if delete
168
+ body[:restart] = true if restart
167
169
  post("exit", body, read_body: true)
168
170
  end
169
171
 
@@ -290,11 +292,13 @@ module Samagotchi
290
292
  # Follow the session's events on a reader thread (see EventStream).
291
293
  # @param snapshot [Boolean] join with a snapshot frame
292
294
  # @param client_id [String, nil] names the stream (see EventStream)
295
+ # @param rediscover [#call, nil] follows a new worker (see EventStream)
293
296
  # @yieldparam event [Hash] string-keyed event
294
297
  # @return [EventStream] started
295
- def follow(snapshot: true, client_id: nil, reconnect_delays: EventStream::DEFAULT_RECONNECT_DELAYS, &on_event)
298
+ def follow(snapshot: true, client_id: nil, reconnect_delays: EventStream::DEFAULT_RECONNECT_DELAYS, rediscover: nil,
299
+ &on_event)
296
300
  EventStream.new(self, snapshot: snapshot, client_id: client_id, reconnect_delays: reconnect_delays,
297
- &on_event).start
301
+ rediscover: rediscover, &on_event).start
298
302
  end
299
303
 
300
304
  # Open GET /session/:id/stream and read past the response headers.
@@ -263,6 +263,10 @@ module Samagotchi
263
263
  @stdout.puts "Provenance updated after interactive resolution."
264
264
  @stdout.puts "Upgrade resolved interactively."
265
265
  0
266
+ ensure
267
+ # An owned source (git/zip/tar) was kept for the incoming files above;
268
+ # the agent step is done with them now.
269
+ installer.cleanup_source!
266
270
  end
267
271
 
268
272
  def uninstall(rest)
@@ -353,15 +357,22 @@ module Samagotchi
353
357
  @stdout.puts "Bundle '#{name}' not installed."
354
358
  return 0
355
359
  end
356
- @stdout.puts "Bundle: #{name} v#{st[:provenance][:version]} scope=#{st[:scope]} installed=#{st[:provenance][:installed_at]}"
357
- @stdout.puts "Target: #{st[:target_dir]}"
360
+ @stdout.puts "Bundle: #{name} v#{st[:provenance][:version]} scope=#{status_scope(st)} installed=#{st[:provenance][:installed_at]}"
361
+ if st[:scope_error]
362
+ @stdout.puts "Target: (unknown scope: this chi can't resolve it; upgrade chi or reinstall the bundle)"
363
+ else
364
+ @stdout.puts "Target: #{st[:target_dir]}"
365
+ end
358
366
  st[:files].each do |k, info|
367
+ next @stdout.puts(" #{k}: unchecked") if info[:unchecked]
368
+
359
369
  mods = []
360
370
  mods << "conflict (kept your edits over v#{st[:provenance][:version]}: chi bundle diff #{name} #{k})" if info[:conflict]
361
371
  mods << "modified" if info[:modified]
362
372
  mods << "missing" if info[:missing]
363
- mods << "no-index" unless info[:index_present]
373
+ mods << "no-index" unless info[:index_present] || info[:overlay]
364
374
  label = mods.empty? ? "ok" : mods.join(",")
375
+ label += " (model overlay)" if info[:overlay]
365
376
  @stdout.puts " #{k}: #{label}"
366
377
  end
367
378
  # Hooks
@@ -408,15 +419,19 @@ module Samagotchi
408
419
  plugin = st[:plugin]
409
420
  mods += 1 if plugin && (plugin[:state] != "ok" || plugin[:requires_failure])
410
421
  mods += 1 if st[:hooks_requires_failure]
422
+ mods += 1 if st[:scope_error]
411
423
  plugin_info = plugin ? " plugin=#{plugin[:file]}" : ""
412
424
  includes = st[:provenance][:includes]
413
425
  includes_info = includes ? " includes=#{includes.join(",")}" : ""
414
- @stdout.puts " #{bname} v#{st[:provenance][:version]} scope=#{st[:scope]} files=#{st[:files].size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
426
+ @stdout.puts " #{bname} v#{st[:provenance][:version]} scope=#{status_scope(st)} files=#{st[:files].size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
415
427
  end
416
428
  end
417
429
  return 0
418
430
  end
419
431
 
432
+ # "team (unknown)" for a scope this chi can't resolve (Status#bundle_status).
433
+ def status_scope(st) = st[:scope_error] ? "#{st[:scope]} (unknown)" : st[:scope]
434
+
420
435
  def diff(rest)
421
436
  args = rest.reject { |a| a.start_with?("--") }
422
437
  bname = args[0]
@@ -430,7 +445,11 @@ module Samagotchi
430
445
  @stderr.puts "Bundle '#{bname}' not installed"; return 1
431
446
  end
432
447
  scope = data[:scope]&.to_s || "system"
433
- target_dir = Samagotchi::MemoryBundle::Installer.new(source: ".", name: bname, scope: scope).send(:resolve_target_dir, scope)
448
+ begin
449
+ target_dir = Samagotchi::MemoryPaths.scope_dir!(scope)
450
+ rescue ArgumentError => e
451
+ @stderr.puts "Bundle '#{bname}': #{e.message}"; return 1
452
+ end
434
453
  files = data[:files] || {}
435
454
  hooks = data[:hooks] || {}
436
455
  plugin_path = prov.plugin_path(data)
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: check-in
3
- version: 0.2.0
3
+ version: 0.2.1
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:0a5ba60962206b3ec90bbaa0934a7284416aef495df8a70391d9ea47f7eb9ec1
9
+ sha256: sha256:afbd0ecf5a41eefe79786e089aa918728ebb0fed0e3b75f84d754a47ec6ade18
10
10
  requires_chi: ">= 0.3.0"
@@ -103,7 +103,11 @@ class Plugin
103
103
  def check_in(ctx, count, mode)
104
104
  case mode
105
105
  when "nudge"
106
- ctx.notify("nudged the model after #{count} tool calls") if ctx.steer(message(count))
106
+ text = message(count)
107
+ if ctx.steer(text)
108
+ @mutex.synchronize { @nudge = { text: text, count: count } }
109
+ ctx.notify("nudged the model after #{count} tool calls")
110
+ end
107
111
  when "notify"
108
112
  ctx.notify("#{count} tool calls in this turn, no answer yet")
109
113
  else
@@ -117,15 +121,20 @@ class Plugin
117
121
  nudge_not_sent(event, ctx)
118
122
  end
119
123
 
120
- # The card said "Nudged": if the turn ended before the nudge joined it
121
- # (the model answered first, or the turn was stopped), the card says so.
124
+ # A nudge that was accepted but dropped before it joined the turn (the
125
+ # model answered first, or the turn was stopped): the card says so, or,
126
+ # for a mode nudge (no card), a notice does.
122
127
  def nudge_not_sent(event, ctx)
123
128
  id, nudge = @mutex.synchronize { [@card_id, @nudge] }
124
- return unless id && nudge
129
+ return unless nudge
125
130
  return if steered?(event[:messages], nudge[:text])
126
131
 
127
132
  first = event[:status].to_s == "canceled" ? "The turn ended first" : "The answer came first"
128
- ctx.card(id: id, title: "check-in", body: "#{first}; nudge not sent.")
133
+ if id
134
+ ctx.card(id: id, title: "check-in", body: "#{first}; nudge not sent.")
135
+ else
136
+ ctx.notify("#{first}; nudge not sent.", level: :warn)
137
+ end
129
138
  end
130
139
 
131
140
  # Whether this turn's messages (after its last prompt) have the steer.
@@ -5,8 +5,12 @@
5
5
  #
6
6
  # Fields: id, tool (a name, a list, or shell = execute + task_create),
7
7
  # command (Ruby regex), path (outside_repo or a glob), git (outside_repo:
8
- # a shell call runs commit/add/reset/… in another checkout), verdict (ask|deny),
9
- # reason, scopes (for an ask: once, session, repo, rule; default all).
8
+ # a shell call runs commit/add/reset/… in another checkout), touches
9
+ # (chi_dirs: a shell command names a path in chi's own dirs), skip_read_only
10
+ # (true: a command that only reads, ls/cat/rg/sed -n/git log…, doesn't
11
+ # match), modes (strict: only with guardrails.mode: strict; untagged rules
12
+ # ask in every mode), verdict (ask|deny), reason, scopes (for an ask: once,
13
+ # session, repo, rule; default all).
10
14
  # [^;&|\n]* keeps a match inside one command of a chain; the git rules match
11
15
  # the subcommand after optional global options (git -C dir push).
12
16
  rules:
@@ -31,9 +35,11 @@ rules:
31
35
  command: '\bgit(?:\s+(?:-C\s+\S+|-c\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+branch\b[^;&|\n]*\s(-[a-zA-Z]*D|--delete\s+--force|--force\s+--delete)\b'
32
36
  verdict: ask
33
37
  reason: git branch -D deletes a branch even if it is not merged
38
+ # Strict mode only: a rebase rewrites local history, which the reflog keeps.
34
39
  - id: git-rebase
35
40
  tool: shell
36
41
  command: '\bgit(?:\s+(?:-C\s+\S+|-c\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+rebase\b'
42
+ modes: [strict]
37
43
  verdict: ask
38
44
  reason: git rebase rewrites history
39
45
  - id: git-history-rewrite
@@ -44,10 +50,13 @@ rules:
44
50
 
45
51
  # ── deleting ───────────────────────────────────────────────────────────
46
52
  # rm with both -r and -f on an absolute path, ~, $HOME or a path with ..
47
- # (a text heuristic; rm -rf build/ in the repo is not asked).
53
+ # (a text heuristic; rm -rf build/ in the repo is not asked), unless
54
+ # every rm -rf target is inside a tmp dir (rm: outside_tmp; /tmp itself,
55
+ # /tmp/* and a link out of it still ask).
48
56
  - id: rm-rf-wide
49
57
  tool: shell
50
58
  command: '\brm\b(?=[^;&|\n]*\s(-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)(\s|$))(?=[^;&|\n]*\s(-[a-zA-Z]*f[a-zA-Z]*|--force)(\s|$))[^;&|\n]*\s(/|~|\$HOME|\S*\.\.)'
59
+ rm: outside_tmp
51
60
  verdict: ask
52
61
  reason: rm -rf on a path outside the working tree
53
62
 
@@ -65,10 +74,13 @@ rules:
65
74
 
66
75
  # ── work outside this session's repo, and git hooks ───────────────────
67
76
  # outside_repo: not in the session's repo (its folder outside a repo);
68
- # tmp dirs and memory files don't count.
77
+ # tmp dirs and memory files don't count. Strict mode only, as is
78
+ # git-outside-repo; chi's own config and hooks still ask (core protected
79
+ # paths, shell-touches-chi).
69
80
  - id: write-outside-repo
70
81
  tool: [write, edit]
71
82
  path: outside_repo
83
+ modes: [strict]
72
84
  verdict: ask
73
85
  reason: writes outside this session's repo
74
86
  # cd/git -C into another checkout, then commit, add, reset, checkout,
@@ -80,6 +92,7 @@ rules:
80
92
  - id: git-outside-repo
81
93
  tool: shell
82
94
  git: outside_repo
95
+ modes: [strict]
83
96
  verdict: ask
84
97
  scopes: [once, session, rule]
85
98
  reason: runs git that changes another checkout, outside this session's repo
@@ -90,9 +103,15 @@ rules:
90
103
  reason: git hooks run code on every git command
91
104
 
92
105
  # ── chi's own config, hooks, rules and approvals, from the shell ───────
106
+ # The real dirs (config.yml's dir, hooks, the approval store, installed
107
+ # bundles, this repo's git hooks), not a look-alike under /tmp or chi's
108
+ # own lib/samagotchi/hooks source; a word that can't be resolved ($VAR,
109
+ # $(…), a glob) is matched as text. A command that only reads them (ls,
110
+ # cat, rg, sed -n) isn't asked about.
93
111
  - id: shell-touches-chi
94
112
  tool: shell
95
- command: '(\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|memories/\.bundles|\.git/hooks)'
113
+ touches: chi_dirs
114
+ skip_read_only: true
96
115
  verdict: ask
97
116
  reason: the command names chi's config, hooks, guardrails or git hooks
98
117
 
@@ -1,10 +1,11 @@
1
1
  ---
2
2
  name: guardrails
3
- version: 0.4.0
3
+ version: 0.5.0
4
4
  scope: system
5
- description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, writes and git in another checkout outside the session's repo, git hooks, answering chi around chi answer), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
5
+ description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config from the shell, answering chi around chi answer; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
6
6
  trust_level: reviewed
7
- # models: in small-models.yml (guardrails.small_models) needs 0.8.1; git: in rules.yml needs 0.17.0
8
- requires_chi: ">= 0.18.0"
7
+ # models: in small-models.yml (guardrails.small_models) needs 0.8.1; git: in rules.yml needs 0.17.0;
8
+ # skip_read_only:, touches:, rm: and modes: need 0.20.0
9
+ requires_chi: ">= 0.20.0"
9
10
  files:
10
11
  guardrails.md: sha256:04207fd1738aeeeef3b7a8053fc57597565bddd7862f01acaba784419cbd909b
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.4.0
3
+ version: 0.4.1
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:009dbb3b754958a6d9edb1e3b828f486aa0ddd09ff5b548f0c576abbfea18807
10
+ sha256: sha256:7c87e14dbdb20d32c37ef2a83f8d56448807ad26564a4034db4f6afbcf8a0323
11
11
  requires_chi: ">= 0.8.0"