samagotchi 0.115.0 → 0.117.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -1
  3. data/bin/chi +2 -0
  4. data/docs/cli.md +33 -2
  5. data/docs/configuration.md +13 -0
  6. data/docs/guardrails.md +188 -1
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/sessions.md +3 -3
  9. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -0
  10. data/lib/samagotchi/bridge.rb +114 -14
  11. data/lib/samagotchi/bridge_client.rb +20 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/config.rb +34 -3
  14. data/lib/samagotchi/engine.rb +6 -1
  15. data/lib/samagotchi/guardrail_wiring.rb +166 -2
  16. data/lib/samagotchi/guardrails/approval.rb +20 -0
  17. data/lib/samagotchi/guardrails/targets.rb +23 -2
  18. data/lib/samagotchi/guardrails/verdict.rb +23 -3
  19. data/lib/samagotchi/guardrails_command.rb +329 -0
  20. data/lib/samagotchi/hold_stop.rb +89 -0
  21. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -0
  22. data/lib/samagotchi/prompt_warmup.rb +8 -3
  23. data/lib/samagotchi/session_access/contract.rb +1 -1
  24. data/lib/samagotchi/session_access/web_bridge_client.rb +9 -4
  25. data/lib/samagotchi/session_commands.rb +79 -2
  26. data/lib/samagotchi/session_manager.rb +3 -2
  27. data/lib/samagotchi/session_metrics.rb +7 -1
  28. data/lib/samagotchi/system_one/answer.rb +78 -0
  29. data/lib/samagotchi/system_one/approval_question.rb +113 -0
  30. data/lib/samagotchi/system_one/client.rb +89 -0
  31. data/lib/samagotchi/system_one/errors.rb +14 -0
  32. data/lib/samagotchi/system_one/fake_backend.rb +45 -0
  33. data/lib/samagotchi/system_one/http_backend.rb +129 -0
  34. data/lib/samagotchi/system_one/log.rb +133 -0
  35. data/lib/samagotchi/system_one/question.rb +86 -0
  36. data/lib/samagotchi/system_one/record.rb +83 -0
  37. data/lib/samagotchi/system_one/self_protect.rb +90 -0
  38. data/lib/samagotchi/system_one/similar.rb +69 -0
  39. data/lib/samagotchi/system_one.rb +25 -0
  40. data/lib/samagotchi/task_watch.rb +23 -5
  41. data/lib/samagotchi/terminal_ui/attached_loop.rb +145 -12
  42. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -0
  43. data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
  44. data/lib/samagotchi/version.rb +1 -1
  45. data/lib/samagotchi/web/app.rb +3 -0
  46. data/lib/samagotchi/web/public/activity.js +10 -0
  47. data/lib/samagotchi/web/public/app.js +20 -1
  48. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  49. data/lib/samagotchi/web/public/composer_fold.js +8 -8
  50. data/lib/samagotchi/web/public/composer_grip.js +65 -22
  51. data/lib/samagotchi/web/public/composer_size.js +40 -5
  52. data/lib/samagotchi/web/public/data.js +24 -0
  53. data/lib/samagotchi/web/public/favicon.svg +1 -1
  54. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  55. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  56. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  57. data/lib/samagotchi/web/public/index.html +21 -0
  58. data/lib/samagotchi/web/public/session_actions.js +39 -6
  59. data/lib/samagotchi/web/public/stop_sheet.js +126 -0
  60. data/lib/samagotchi/web/public/system_one_wrong.js +32 -0
  61. data/lib/samagotchi/web/public/timing.js +9 -5
  62. data/lib/samagotchi/web/public/turn_events.js +1 -0
  63. data/lib/samagotchi/web/public/turn_html.js +13 -2
  64. data/lib/samagotchi/web/session_access_routes.rb +9 -6
  65. data/lib/samagotchi/web/session_control.rb +62 -3
  66. data/lib/samagotchi/web/session_show.rb +35 -5
  67. data/lib/samagotchi/worker.rb +57 -5
  68. data/lib/samagotchi/worker_idle_exit.rb +111 -9
  69. metadata +17 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1829b38f073eadf8af34d8b4955f4d69d0e6aa1983acdf5e26a99daef4b629a4
4
- data.tar.gz: 19ee9838f04a908813a8dee016cea758acaa3ac78fb0df3902b0941ff222500a
3
+ metadata.gz: 31e109ef4c87aedb02323fa93499a53ad9e08ccc5384be8aff037f3ac99459db
4
+ data.tar.gz: c52c487f3f06b2a3d44bc9bc6463086aa4c5fd882cff83e8f5afee7cb89062f9
5
5
  SHA512:
6
- metadata.gz: 4d4e59ca58dc0dcfc0ebe16a033fee3e32f6eda731181ec093f554b53393f9fae2d4ca3bcf33c8b0ea9d0f204abaf4be22c416b3788c43d0eeb8b19730cb29bf
7
- data.tar.gz: f26d79c70cb60d2febde92c114ade0cb01ef975c05b05602bf975779481147a13e0e8832bf46b8ec3730f6c12800d3ff4541669a925605b5b98372b106d4213b
6
+ metadata.gz: 44752dd2037da4b96c34028e5173dc4e7d5d26079ab048d153d97a63655289b78ec8114b98bf2b4db160d8596772137f147018e3a9c1bce64af8502893411686
7
+ data.tar.gz: 9e4e2eab0e6c1719e6599d3895596ffc23ef850e0a6540c54213470618578bca4ec2c0a39146edb46c5b15258a3edf5c3b3560a6a23b868af494cac62714e6b3
data/CHANGELOG.md CHANGED
@@ -8,6 +8,79 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.117.0] - 2026-10-26
12
+
13
+ ### Added
14
+
15
+ - `/exit` in an attached terminal **asks what to do with what still runs** when only things you can stop keep the
16
+ worker up (reminders, a watched background task, a delegate with a live worker): keep running in the background,
17
+ stop them and exit, or go back. Anything else that holds the worker (a running turn, queued prompts, another UI)
18
+ still refuses as before, and a delegate never holds the idle exit. See [cli.md](docs/cli.md#attached-terminal).
19
+ - The web's `stop session` asks the same first: a sheet (a bottom sheet on a phone) counts what still runs and offers
20
+ **Stop all**, **Stop session only** (the stop as it was: background tasks and delegates keep running) or
21
+ **Cancel**; with nothing to stop it is one tap as before. See [Sessions](docs/sessions.md).
22
+
23
+ ### Fixed
24
+
25
+ - An unquoted `on`/`off` in `config.yml` now means that word for a setting that takes it
26
+ (`guardrails.system_one.mode: on`, `session.task_wakes: off`) instead of silently falling back to the default
27
+ (YAML 1.1 reads them as booleans). See [Configuration](docs/configuration.md).
28
+ - chi web's composer grip keeps the message box inside the window it is shown in: a height saved in a bigger window
29
+ is held to half of a smaller one (a rotated phone, a resized window) and comes back whole when the room is back.
30
+
31
+ Update with `chi update` (the system bundle 0.117.0), then restart `chi web`.
32
+
33
+ ## [0.116.0] - 2026-10-25
34
+
35
+ ### Added
36
+
37
+ - System 1's judgment now sees more of the ask it is given: its state carries up to five of the user's **earlier
38
+ answered asks** that look like this one (`SystemOne::Similar` — same rule and repository first, then the same rule,
39
+ then the ask whose logged call shares the most words with this one, ties newest first), and a `memory_write` ask
40
+ names the entry and its scope instead of only a path, with that entry's index description for a removal — the
41
+ `memory-remove` ask was scoring flat because the judge could not tell which memory was going. The judgments log is
42
+ read once per ask, shared by the examples and the record gate. See
43
+ [Guardrails](docs/guardrails.md#system-1-experimental).
44
+ - `/guardrails wrong [N]` appends a `should_have_asked` label to this session's Nth newest unlabelled
45
+ System 1 auto-allow (default 1), so its rule asks again until its record is rebuilt.
46
+ - `/guardrails` shows this session's recent System 1 auto-allows and each rule's record summary when System 1
47
+ is enabled or has judgments.
48
+ - chi web's owner can flag an auto-allowed tool row as **should have asked**; the worker appends the label only
49
+ for a judgment belonging to that session, and participants cannot reach the route; a flagged row stays marked
50
+ after a reload.
51
+ - System 1's auto-allowed calls are marked on their tool row in the terminal (` · auto-allowed (System 1, p=0.97)`) and
52
+ the web (an `auto-allowed · System 1` label, its rule and p on hover).
53
+ - **System 1 (experimental)**: `Samagotchi::SystemOne` talks the TypeSafe judgment API (`POST <host>/v1/systemone`) for
54
+ the questions a guardrail ask would ask, with the new `guardrails.system_one.*` settings (`mode`, `host`, `model`,
55
+ `rules`, `threshold`, `min_record`, `timeout_ms`) and `script/system-one-probe HOST [MODEL]`, which prints each
56
+ question's probability and latency. In `audit` mode every approval a user is about to see is now judged
57
+ first and written, with the answer they gave, to `guardrails/judgments.jsonl` beside the approvals store. See
58
+ [Guardrails](docs/guardrails.md#system-1-experimental).
59
+ - `chi guardrails replay [--dry-run] [--threshold T] [--model M]` runs System 1 over the approvals this machine's
60
+ sessions already answered — read from their `cards.json` sidecars — and writes each judgment and the user's answer
61
+ to `guardrails/judgments.jsonl`, so a rule's record starts with the asks the user really decided on. It prints a
62
+ per-rule table (asks, allowed, denied, auto at the threshold, false allows, no judgment) and skips unanswered
63
+ asks, relay cards and question ids a previous replay logged; a parent agent's answer is not the user's, so only
64
+ its deny is replayed, and only where the parent could have allowed. Exits 2 when `guardrails.system_one.mode`
65
+ is off or no host is set. See [Guardrails](docs/guardrails.md#seeding-the-record-chi-guardrails-replay).
66
+ - **System 1 `on` mode answers for the user**: an ask is allowed once, without a question, when its rule is in
67
+ `guardrails.system_one.rules`, the judgment's probability is at `guardrails.system_one.threshold` (0.8 now, was
68
+ 0.95), and that rule's record is clean — `guardrails.system_one.min_record` (5) asks at the threshold the user
69
+ allowed, and no false allow among its last 20 labelled asks. Calls that write chi's own config.yml, hooks,
70
+ installed bundles or the guardrails store are never auto-allowed, nor `chi-answer-piped`, `chi-answer-http` or
71
+ `chi-broadcast`; memory files are fair game. Nothing is stored in `approvals.json`, no card is opened and no
72
+ notification is sent, so the auto-allowed tool row is its only trace. See
73
+ [Guardrails](docs/guardrails.md#answering-for-the-user-on-mode).
74
+
75
+ ### Changed
76
+
77
+ - chi web's composer grip hides the message box with a click as well as a drag: a click on the expanded grip folds it
78
+ 250 ms after the press, so a second click inside that window is a double-click and resets the height instead (a
79
+ drag, Enter or Space still act at once). A fold no longer forgets the height the grip set (`chi_composer_floor`):
80
+ the box opens again at the height it had, and only a double-click forgets it. See [Sessions](docs/sessions.md).
81
+ - chi's icon (the browser tab and the Home Screen app) is a deeper blue to purple, so the white χ stands out on any
82
+ wallpaper. On a phone, remove the Home Screen app and add it again to see it: iOS keeps the old icon.
83
+
11
84
  ## [0.115.0] - 2026-10-25
12
85
 
13
86
  ### Changed
@@ -4354,7 +4427,9 @@ and long-lived sessions.
4354
4427
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
4355
4428
  hotkey panel that send selected text or the clipboard to your sessions.
4356
4429
 
4357
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.115.0...HEAD
4430
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.117.0...HEAD
4431
+ [0.117.0]: https://github.com/dm1try/samagotchi/compare/v0.116.0...v0.117.0
4432
+ [0.116.0]: https://github.com/dm1try/samagotchi/compare/v0.115.0...v0.116.0
4358
4433
  [0.115.0]: https://github.com/dm1try/samagotchi/compare/v0.114.0...v0.115.0
4359
4434
  [0.114.0]: https://github.com/dm1try/samagotchi/compare/v0.113.0...v0.114.0
4360
4435
  [0.113.0]: https://github.com/dm1try/samagotchi/compare/v0.112.0...v0.113.0
data/bin/chi CHANGED
@@ -68,6 +68,8 @@ SUBCOMMANDS = {
68
68
  "memory" => ["samagotchi/memory_command", "MemoryCommand"],
69
69
  # install, upgrade, uninstall, status, diff, list, build (--scope, --force, …)
70
70
  "bundle" => ["samagotchi/bundle_command", "BundleCommand"],
71
+ # the System 1 record: replay the asks this machine's sessions already answered
72
+ "guardrails" => ["samagotchi/guardrails_command", "GuardrailsCommand"],
71
73
  # other machines' chi web, for chi --remote NAME (add, ls, rm)
72
74
  "remote" => ["samagotchi/remote_command", "RemoteCommand"]
73
75
  }.freeze
data/docs/cli.md CHANGED
@@ -36,6 +36,8 @@
36
36
  - `chi update [--dry-run] [--no-gem] [--no-bundles] [--no-desktop]` — update an installed chi: the gem, the system bundle, the shipped bundles you installed and the desktop helper, in one table (see [Updating](#updating))
37
37
  - `chi bundle install|upgrade|uninstall|status|diff|list|build|trash|new|test|check` — manage memory bundles (see [Bundle hooks](hooks.md#bundle-hooks-unified-workflow-bundle)); `list` shows the installed ones and the ones shipped with chi, which `install <name>` installs; `core` and `dev` are profiles that install a set of them (see [Bundle profiles](memory.md#bundle-profiles-core-and-dev), [Guardrails](guardrails.md), [Plugins](plugins.md#the-btw-bundle), [the mcp bundle](plugins.md#the-mcp-bundle) [the loop-guard bundle](plugins.md#the-loop-guard-bundle), [the check-in bundle](plugins.md#the-check-in-bundle) and [the skills bundle](plugins.md#the-skills-bundle)). `trash` lists and empties the bundle trash (moved files from uninstalls/upgrades). A usage error (an unknown subcommand or flag, a missing argument, a bad `build --scope`) exits 2, as for the other subcommands. Plain `chi` and `chi web` exit 1 on an unknown flag, a flag missing its value or a stray argument. `new`, `test` and `check` are for writing one: see [Writing a bundle](#writing-a-bundle-new-test-check)
38
38
 
39
+ - `chi guardrails replay [--dry-run] [--threshold T] [--model M]` — seed System 1's record from the approvals this machine's sessions already answered: each ask is judged by the configured model and written to `guardrails/judgments.jsonl` with the answer the user gave, then a per-rule table prints (asks, allowed, denied, would-be auto-allows, false allows, asks with no judgment). A parent agent's answer is not the user's: only its deny is replayed, and only where the parent could have allowed. `--dry-run` judges without writing, `--threshold` overrides `guardrails.system_one.threshold` for the table, `--model` the configured model for this run. Exit 2 when `guardrails.system_one.mode` is off or no `host` is set; a usage error exits 2 (see [System 1](guardrails.md#system-1-experimental))
40
+
39
41
  - `chi bundle try PATH -p TEXT [options]` — install a bundle in a temporary, filtered sandbox, run one real model turn,
40
42
  and print its trace (see [Trying a bundle](#trying-a-bundle)).
41
43
 
@@ -432,7 +434,34 @@ In an attached terminal:
432
434
  doesn't wait out the idle timeout. It stays up while something still needs
433
435
  it, and the detach line says what: a turn is running (Ctrl-C cancels it
434
436
  first), prompts are queued, a continue offer is pending, another UI is
435
- attached, or reminders are set. A web tab you just closed can count as
437
+ attached, or reminders are set. When every hold is something you can stop —
438
+ reminders, a background task it watches for a [finish wake](internals/background-tasks.md#finish-wake),
439
+ a delegate with a live worker — it asks instead of refusing, even when that
440
+ is all there is:
441
+
442
+ ```
443
+ This session still has:
444
+ • 2 reminders: api_health (every 20m), git push (every 5m)
445
+ • 1 background task: rails s (2m)
446
+ • 1 delegate: c1c1c1c1 "Fix the composer" (running a turn)
447
+ 1. Keep running in the background (detach)
448
+ 2. Stop them and exit
449
+ 3. Back to the session
450
+ ```
451
+
452
+ 1 is today's detach. 2 cancels the reminders, stops the tasks the same way
453
+ `task_stop` does and stops the delegates the same way `chi sessions stop ID`
454
+ does, then exits as `/exit` does; the line names what it stopped (a child a
455
+ terminal owns, or a task that can't be stopped, is left running and logged).
456
+ Each entry says what it is: a reminder its schedule (`every 20m`, `every 1h`,
457
+ with `, due now` when its next turn has already come), a task how long it has
458
+ run, a child its title and what it is doing. 3 keeps the prompt open, and the
459
+ next `/exit` asks again; Ctrl-D, `/detach` and a line the ask doesn't
460
+ understand are not answers to it (Ctrl-D detaches, a stray line is re-asked).
461
+ A delegate counts only for this ask: it never holds the idle exit, so 1 leaves
462
+ it running and the worker idles out as usual. `/exit --delete` and `/archive`
463
+ don't ask — with nothing else holding the worker, a running child does not
464
+ stop them, as before. A web tab you just closed can count as
436
465
  attached for about 30 s. When the worker exits, `chi --resume ID` or
437
466
  `chi --attach ID` starts a new one with the conversation. Before it exits
438
467
  (here and on the idle exit) the worker writes the session's recap if
@@ -1428,7 +1457,9 @@ In interactive assist mode, you can switch the request model without restarting:
1428
1457
  - `/model <name> --alias <alias>`: create alias for current effective model (alias value may be bare or `host:model`).
1429
1458
  - `/model`: show the effective model (and default when diverged: `runtime model: <effective> (default: <default>, profile=<name>, <source>)`, e.g. `profile=qwen36, server (chat_template)`). When the session's prompt carries model notes the line ends with `; notes: model_notes_deepseek (system, 612 chars)`; a switch's `runtime model set to …` names the new model's ([Memory](memory.md#model-notes)).
1430
1459
  - `/model clear` (or `default`/`none`/`off`): clear the session override, reverting to the configured default.
1431
- - `/guardrails`: the guardrail rules (by source), what failed to load, and your stored approvals, numbered; `/guardrails revoke N` removes approval N (see [Guardrails](guardrails.md)).
1460
+ - `/guardrails`: the guardrail rules (by source), what failed to load, your stored approvals, and System 1's
1461
+ recent auto-allows; `/guardrails revoke N` removes approval N and `/guardrails wrong [N]` flags an auto-allow
1462
+ this session should have been asked about (see [Guardrails](guardrails.md)).
1432
1463
  - `/models`: list model ids aggregated across all `hosts:` (an alias shows next to its id: a bare alias on every host listing the id, a `host:model` one only under its host; grouped `host (host:port):` with per-host `unreachable` warnings, e.g. an unset `api_key_env`; lists cached 60s, 10 minutes for a remote host; lazy — no startup prefill). Ids the host declares under `hosts.<name>.models` come first, as `rr/x (config)`, and don't count toward the cap; an unreachable host keeps them, under its warning, as `rr/x (config, host down)`. At most 20 ids per host, then `… and N more`; `/models <text>` lists every id containing `<text>` (any case), e.g. `/models qwen` on OpenRouter.
1433
1464
 
1434
1465
  Notes:
@@ -97,6 +97,12 @@ Behavior:
97
97
  - Top level is a YAML mapping of sections (`default:`, `server:`, `recap:`, …)
98
98
  and the maps described below. Keys are lower `snake_case`, one level per
99
99
  dot: `server.read_timeout` is `server: {read_timeout: 600}`.
100
+ - Each key takes its own type: a number, a boolean (`true`/`false`, and
101
+ `yes`/`on`/`1`, `no`/`off`/`0`), text, or one of the words the setting
102
+ allows (`auto`, `audit`, `on`, …). YAML 1.1 reads an unquoted `on`, `off`,
103
+ `yes` or `no` as a boolean, so for a setting whose values are `on` and
104
+ `off` an unquoted `off` is the same as `"off"` (`guardrails.system_one.mode: off`,
105
+ `session.task_wakes: off`).
100
106
  - A key chi doesn't read warns at start, with the closest known key:
101
107
  `config: unknown key 'default.modle' (did you mean 'default.model'?)`.
102
108
  Names you choose under the maps below (host names, model ids) don't warn.
@@ -1886,6 +1892,13 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
1886
1892
  | `guardrails.mode` | `auto` | | Which rules vote: `auto` leaves out the rules tagged `modes: [strict]` (in the guardrails bundle: `git rebase`, writes and git outside the session's repo); `strict` runs them all. An unknown value warns and is `auto`. config.yml only. See [Guardrails](guardrails.md#modes). |
1887
1893
  | `guardrails.small_models` | `auto` | | Which models get the `models: small` rules: `auto` (32B or less by the size in the name, an MoE's active size), a list of globs, or `[]`. config.yml only. See [Guardrails](guardrails.md#rules-for-some-models). |
1888
1894
  | `guardrails.parent_answers` | `soft` | | Which options a parent agent (`chi answer`) may pick on a session's question, by their effect: `hard` (a deny, a Stop, a plain answer), `standard` (+ Continue and other spending options), `soft` (+ "Allow once"); never a wider scope or an allow on chi's own config. An invalid value warns and is `hard`. A session's own `parent_answers` (`chi --parent-answers`, `/parent-answers`) may only tighten it. Replaces `guardrails.parent_approvals` and `turn.parent_continue`. config.yml only: no environment variable (a parent can still pick the config dir with `XDG_CONFIG_HOME`). A convention for an honest parent, not a security boundary. See [Guardrails](guardrails.md#answers-from-a-parent-agent). |
1895
+ | `guardrails.system_one.mode` | `off` | | System 1, the cheap judgment that answers an ask before the user sees it: `off` (the default), `audit` (judge every ask, log it, never decide), `on` (also allowed to allow a call once, behind the gates in [System 1](guardrails.md#system-1)). An unknown value warns and is `off`. Experimental. config.yml only. See [Guardrails](guardrails.md#system-1). |
1896
+ | `guardrails.system_one.host` | none | | The `hosts:` entry serving `/v1/systemone` (a local Splash, or the hosted Jev API); unset: no judgment. config.yml only. |
1897
+ | `guardrails.system_one.model` | none | | The judgment model; unset uses the host's first model, and a host that names none has no judgment. config.yml only. |
1898
+ | `guardrails.system_one.rules` | none | | The rule ids System 1 may answer for in `on` mode: `"git-push|git-rebase"` or a YAML list, parsed like `guardrails.small_models`. Nothing is auto-allowed while it is unset; the `chi-answer-piped`, `chi-answer-http` and `chi-broadcast` rules never are, however they are listed. config.yml only. |
1899
+ | `guardrails.system_one.threshold` | `0.8` | | The probability an auto-allow needs. config.yml only. |
1900
+ | `guardrails.system_one.min_record` | `5` | | How many clean records a rule needs before System 1 is trusted for it: labelled asks of that rule at the threshold the user allowed. config.yml only. |
1901
+ | `guardrails.system_one.timeout_ms` | `2000` | | Milliseconds one judgment may take; a slower one is no judgment and the ask goes to the user. config.yml only. |
1889
1902
  | `log.file` | state dir | yes | See "Debug Log File". |
1890
1903
  | `log.disable` | `false` | yes | No file logging. |
1891
1904
  | `log.level` | `info` | yes | `debug`, `info`, `warn`, `error`. |
data/docs/guardrails.md CHANGED
@@ -97,7 +97,12 @@ matches its exact folder.
97
97
  A stored approval only relaxes an ask; a deny rule is never approvable.
98
98
  A file that doesn't parse is moved aside to `approvals.json.corrupt-<UTC time>`
99
99
  with one warning, and chi starts with no stored approvals (more asks, nothing lost).
100
- `/guardrails` lists the rules and approvals; `/guardrails revoke N` removes one.
100
+ `/guardrails` lists the rules and approvals; `/guardrails revoke N` removes
101
+ approval N. When System 1 is enabled or has judgments, it also shows this
102
+ session's recent auto-allows and each rule's record summary. In System 1 `on`
103
+ mode, `/guardrails wrong [N]` flags the Nth newest auto-allowed call in this
104
+ session as one System 1 should have asked about (default 1). The flag is
105
+ appended to the judgments log; the rule asks again until its record is rebuilt.
101
106
  The file tools can't write the store.
102
107
 
103
108
  ### Answers from a parent agent
@@ -613,6 +618,188 @@ A rule in config.yml that uses a key this chi doesn't know yet (`modes:`,
613
618
  that doesn't parse denies every tool call (see Failing closed). Bundles say
614
619
  which chi they need (`requires_chi`), so their rules don't hit this.
615
620
 
621
+ ## System 1 (experimental)
622
+
623
+ System 1 is a cheap classifier that judges an approval — "would the user
624
+ allow this?" — before it reaches them, so the asks they always allow stop
625
+ reaching them. With `mode: audit` every ask a person is about to see is
626
+ judged and logged and **nothing about the ask changes**: the user is asked
627
+ exactly as they would have been, and their answer is what settles the call.
628
+ With `mode: on` an ask that passes every gate is allowed **once** without
629
+ reaching them — see [Answering for the user](#answering-for-the-user-on-mode).
630
+
631
+ `Samagotchi::SystemOne` (lib/samagotchi/system_one/) speaks the TypeSafe
632
+ "System One" judgment API: `POST <host>/v1/systemone` with
633
+ `{model, state, questions}` and answers of type `noul` (a probability),
634
+ `choice` or `score`. The host is a `hosts:` entry with `api: openai` — a
635
+ local Splash server, or the hosted Jev API — and its key is that host's own
636
+ `api_key_env:`.
637
+
638
+ ```yaml
639
+ guardrails:
640
+ system_one:
641
+ mode: off # off (default) | audit (judge, log, never decide) | on
642
+ host: splash # the hosts: entry serving /v1/systemone
643
+ model: Qwen3.8-Flash-Next-GSQ-RCO-GGUF:Q2_0 # else the host's first model
644
+ rules: [git-push, git-rebase] # the rule ids it may answer for
645
+ threshold: 0.8 # the probability an auto-allow needs
646
+ min_record: 5 # clean records a rule needs before it is trusted
647
+ timeout_ms: 2000 # a slower judgment is no judgment
648
+ ```
649
+
650
+ `SystemOne::Client.from_config(host_registry: ...)` reads these on every
651
+ call, so a config.yml change takes effect on the next ask. `mode: off` (the
652
+ default) or no `host` means there is no client at all. Anything else is no
653
+ judgment and one `Log.warn(:guardrails, "system_one_failed", …)`: an
654
+ unreachable host, a host that isn't `api: openai`, a status other than 200,
655
+ a body that isn't JSON, a question with no answer, a request longer than
656
+ `timeout_ms`. A nil means "ask the user", which is what the caller would
657
+ have done anyway.
658
+
659
+ ### What the audit writes
660
+
661
+ With a client, `GuardrailWiring#request_approval` asks System 1 one `noul`
662
+ question, `allow`, before the user is asked. Its instruction is a constant
663
+ in code (`SystemOne::ApprovalQuestion::INSTRUCTIONS`), reviewed like a
664
+ prompt; the state is the ask's own facts — the rule and its source, the
665
+ reason, the tool, the command (or the paths) cut to 2000 characters, the
666
+ edit's diff, the cwd, the repo and branch, and the session's title when it
667
+ has one. A memory ask also names what it is about: the entry and its scope,
668
+ and for a removal the description that entry's index line carries
669
+ (`Targets#memory`, read into the card by `Guardrails::Approval`): a path
670
+ alone tells the judge too little. The state then carries up to five of the
671
+ user's **earlier asks they answered** (`SystemOne::Similar`) — this rule in
672
+ this repository first, then this rule anywhere, then any answered ask, ranked
673
+ by how many words the logged call shares with this one, ties going to the
674
+ newest. Reading the log is best-effort, so a log that cannot be read simply
675
+ offers no examples. A participant's review turn is never judged: there is no
676
+ user there to ask. The judgment runs before the card appears, so an ask is
677
+ slower by up to `timeout_ms` (2000 ms by default).
678
+
679
+ Each judgment is a line in `judgments.jsonl`, beside `approvals.json` in
680
+ `$XDG_STATE_HOME/samagotchi/guardrails/` (`SystemOne::Log`): 0600, appended
681
+ under an exclusive `flock` (several workers write it), and rewritten —
682
+ keeping the newest half of its lines — once it passes 5 MB. A line is never
683
+ rewritten to add its label:
684
+
685
+ ```json
686
+ {"kind":"judgment","id":"…","at":"…","session_id":"…","rule":"git-push","source":"config","tool":"execute","call":"git push origin main","call_digest":"<sha256 of the whole call>","repo_name":"samagotchi","p":0.97,"threshold":0.8,"model":"Qwen3.8-Flash","latency_ms":180,"decision":"audit","origin":"live"}
687
+ {"kind":"label","id":"…","at":"…","answer":"allow_once"}
688
+ ```
689
+
690
+ `decision` is what the judgment led to: `audit` (audit mode), `asked` (on
691
+ mode, the user was asked), `auto` (on mode, System 1 answered for the user).
692
+ A judgment that never arrived is logged too, with `p` null, so the failures
693
+ count as well. The label line, keyed by the judgment's id, is what the user
694
+ did: `allow_once`, `allow_wide` (any scope wider than once), `deny`, or
695
+ `none` for an ask that was cancelled. **A parent agent's answer is not the
696
+ user's: its allow labels nothing. Its deny is a label only where the parent
697
+ could have allowed instead** — the ask is not `user_only` and the session's
698
+ parent level permits a permit (`Guardrails::ParentPolicy`); a parent that was
699
+ not allowed to allow says nothing about what the user would have done. An
700
+ auto-allow has no label at all, since nobody was asked.
701
+ `SystemOne::Log#entries` reads the file and returns the judgments joined with
702
+ their last label.
703
+
704
+ Everything about the log is best-effort: a write that fails is one
705
+ `Log.warn(:guardrails, "system_one_log_failed", …)` and the approval goes on
706
+ as if System 1 were not there.
707
+
708
+ `ruby script/system-one-probe HOST [MODEL]` asks three fixed approval-like
709
+ questions through the client and prints each probability and the latency of
710
+ the call — how a host is checked before any of this is turned on.
711
+
712
+ ### Answering for the user: `on` mode
713
+
714
+ In `on` mode an ask is allowed **once**, without reaching the user, when every
715
+ gate below passes, in this order:
716
+
717
+ 1. the mode is `on` (in `audit` the judgment decides nothing);
718
+ 2. the ask's rule is in `guardrails.system_one.rules` — `"git-push|git-rebase"`
719
+ or a YAML list, read like `guardrails.small_models`. Nothing is auto-allowed
720
+ by default: the user names the rules they want;
721
+ 3. the rule is not one of the three that let an agent answer chi's own
722
+ questions — `chi-answer-piped`, `chi-answer-http`, `chi-broadcast` — which
723
+ are never auto-allowed, even when listed
724
+ (`SystemOne::SelfProtect::NEVER_AUTO`);
725
+ 4. the call does not change chi itself (`SystemOne::SelfProtect.refuse?`): no
726
+ edit or write whose paths include config.yml in chi's config dir, anything
727
+ under the hooks dir, the installed bundles dir or the guardrails store (where
728
+ `approvals.json` and `judgments.jsonl` live), and no `execute` or
729
+ `task_create` command naming any of them — the `~/.config/samagotchi`
730
+ spelling counts too. The memories dir is deliberately not one of these:
731
+ reading or appending a memory file can be auto-allowed. A protected rule
732
+ (`Verdict::PROTECTED_RULES`) may be listed by name and System 1 may answer
733
+ it, but a call that really writes config.yml or the hooks is refused here;
734
+ 5. the judgment's `p` is at least `guardrails.system_one.threshold` (0.8);
735
+ 6. the rule's record is clean (`SystemOne::Record`, read from the log once per
736
+ ask): at least `guardrails.system_one.min_record` (5) of its asks judged at
737
+ or above the threshold that the user allowed, and none of its **last 20
738
+ labelled** asks judged at the threshold that the user denied or later
739
+ flagged as one System 1 should have asked about. Asks with no label, no
740
+ probability, or a `none` label count for neither side.
741
+
742
+ Then the judgment's `decision` is `auto`, the verdict settles as
743
+ `settle!(:allow, decided_by: "system one")` with scope `once`, and nothing is
744
+ written to `approvals.json` — the next call is judged again. **No question is
745
+ opened, so no card appears, no `:question_asked` hook fires, and no
746
+ notification is sent.** The verdict's activity carries
747
+ `note: "auto-allowed by System 1 (p=0.97)"`, `judgment_id` and `p`; the line
748
+ that shows it in the terminal and the web is P3b, and is not rendered yet, so
749
+ today an auto-allowed call simply looks like a call that never needed
750
+ approval. If the judgment line cannot be written to the log, the ask goes to
751
+ the user.
752
+
753
+ A delegate child answers from its own session's settings: its System 1 can
754
+ auto-allow a call even when the child's `parent_answers: hard` bars a parent
755
+ from allowing it — the record gate is about the rule, not about who answers.
756
+
757
+ To correct an auto-allow after the fact, run `/guardrails` to see this
758
+ session's recent unlabelled auto-allows, newest first, then
759
+ `/guardrails wrong [N]` to label one. In chi web, the owner can instead click
760
+ **should have asked** on that tool row; participants cannot use that route.
761
+ The label is appended, not written over the judgment. `should_have_asked`
762
+ makes the rule's record fail the false-allow check, so the rule asks again
763
+ until enough later clean asks rebuild its record.
764
+
765
+ ### Seeding the record: `chi guardrails replay`
766
+
767
+ A rule's record starts empty, so `chi guardrails replay` (this machine only)
768
+ judges the asks the sessions here already answered and writes them to the same
769
+ log, with the user's answer as the label:
770
+
771
+ ```sh
772
+ chi guardrails replay [--dry-run] [--threshold T] [--model M]
773
+ ```
774
+
775
+ It reads every session's sidecar, `$XDG_STATE_HOME/samagotchi/sessions/<id>/cards.json`
776
+ (the file `Bridge::CardStore` saves), and takes the entries of type `question`
777
+ whose `pending_question.approval` is there and that have an `answer` or were
778
+ `cancelled`. A label is the scope the user took — `allow_once` for "Allow once",
779
+ `allow_wide` for a wider one, `deny` for anything else, `none` for a cancelled
780
+ ask. Skipped: a question no one answered, a relay card, which carries no
781
+ approval of its own, and a question id a previous replay already logged — so
782
+ running it twice adds nothing. A parent agent's answer is not the user's: its
783
+ allow is skipped, and its deny is replayed only where the parent could have
784
+ allowed (the card is not `user_only` and the session's parent level permits a
785
+ permit). A cards file that doesn't parse is skipped with one warning line,
786
+ never a failed run.
787
+
788
+ Each ask is judged exactly as a live one is (`SystemOne::ApprovalQuestion`),
789
+ and written as a `judgment` line with `decision` and `origin` both `replay`,
790
+ its `question_id` and the card's `created_at` as `asked_at`, followed by its
791
+ label. Then a table, one row per rule sorted by how many asks it had, and a
792
+ total: the asks, how many the user allowed, denied, how many at the threshold
793
+ System 1 would have auto-allowed, how many of those the user denied (the false
794
+ allows), and how many got no judgment. `--threshold` overrides
795
+ `guardrails.system_one.threshold` for the table; `--model` overrides the model
796
+ for this run; `--dry-run` judges and prints without writing a line. Without a
797
+ client (`mode: off`, or no `host`) it exits 2 saying what to set.
798
+
799
+ Two limits on what the replay can see: `CardStore` keeps only the last 20
800
+ questions per session, and a `--no-shared` REPL session never writes a
801
+ `cards.json` at all, so its asks are not replayable.
802
+
616
803
  ## The guardrails bundle
617
804
 
618
805
  ```sh
@@ -56,7 +56,7 @@ A task the model leaves running (it ends its turn: "I'll check when it's finishe
56
56
  - **The wake turn**: the first finished task runs a continue turn from client `task:<id>` (`last_turn.origin: "task"`, labelled `task <id8> finished` in the TUI and the web; the web's bell rings at its end however short), whose first message is the note (`turn_start`, `turn_id`, written before the turn's checkpoint so a restore keeps it). It counts toward `session.max_wakes` with the other wakes (`WorkerWakes#task_open?`: no continue offer, not paused, past the 2 s start grace). Several tasks finishing together: one wake turn, the others plain notes before it.
57
57
  - **A plain note instead** (the same text without the last two lines, no turn start), written at once, with `queue`, the budget spent (the UIs hear `N finished background tasks noted; the model reads them with your next message` once per human input, the flag shared with the other wakes), wakes paused, a continue offer waiting, input queued, an exit asked for, or a **note-only** task: one started or waited on in a turn the user stopped (reasons `user`, `manual`, `ctrl_c`), since a wake telling the model to carry on would undo that Stop. A prompt's turn takes the finished ones as plain notes first.
58
58
  - **Failure**: a wake turn that fails and kept nothing, or is stopped and restored, turns its note back into the plain one (`WorkerInbound#unmark_wake`, as for a context wake) and pauses wakes until the user writes.
59
- - **Idle exit**: with `wake`, a watched task holds the worker's idle exit (`WorkerIdleExit`'s `:background_tasks`) until its wake is taken: while it runs, and once it finished until the next idle pass takes it (`TaskWatch#holding`, in memory, no `ps`), so one that ends between the pass's look and the exit's check still wakes the session. A still-running one holds `/exit` too (`TaskWatch#running`, read once per request by `WorkerIdleExit#request_hold`; the 409 lists the tasks, `{id, command}`, with a `hint` on stopping them, and the attached terminal names them and says to use the web's stop-task button or ask for `task_stop`), as reminders do. A note-only task holds neither (no wake would come for it), nor does one whose note, or the look for it (the conversation's task ids unreadable), failed 3 times (`TaskWatch::MAX_FAILURES`: it is still tried at each look, but can't keep the worker up for good). With `queue` nothing holds: nothing would wake for the end, so keeping the worker up isn't worth it. As the worker leaves (any exit but a stop, a signal or an `/exit --delete`), the tasks that finished since the last look are written as plain notes; a task that ends after the worker left gets no note (the model sees its end at its next look, as before). A forgotten dev server keeps the worker up until it is stopped; a crash of that server wakes the session.
59
+ - **Idle exit**: with `wake`, a watched task holds the worker's idle exit (`WorkerIdleExit`'s `:background_tasks`) until its wake is taken: while it runs, and once it finished until the next idle pass takes it (`TaskWatch#holding`, in memory, no `ps`), so one that ends between the pass's look and the exit's check still wakes the session. A still-running one holds `/exit` too (`TaskWatch#running`, read once per request by `WorkerIdleExit#request_hold`; the 409 lists the tasks, `{id, command, age_seconds}`, with a `hint` on stopping them, and the attached terminal names them and says to use the web's stop-task button or ask for `task_stop`), as reminders do. When the only holds are ones the user can stop, the 409 carries `holds` too and the terminal asks whether to stop them all (`HoldStop`, which stops these tasks the same way `task_stop` does). A note-only task holds neither (no wake would come for it), nor does one whose note, or the look for it (the conversation's task ids unreadable), failed 3 times (`TaskWatch::MAX_FAILURES`: it is still tried at each look, but can't keep the worker up for good). With `queue` nothing holds: nothing would wake for the end, so keeping the worker up isn't worth it. As the worker leaves (any exit but a stop, a signal or an `/exit --delete`), the tasks that finished since the last look are written as plain notes; a task that ends after the worker left gets no note (the model sees its end at its next look, as before). A forgotten dev server keeps the worker up until it is stopped; a crash of that server wakes the session.
60
60
  - **The tool descriptions**: a worker's `execute` and `task_create` say "if you end your turn before it finishes, chi tells you when it does" (left out under `off` and in the REPL), fixed when the worker builds its Engine.
61
61
 
62
62
  Work that needs a model, not a shell command, goes to a child chi session instead: `delegate` / `delegate_result` have the same shape (start, then wait) and return only the child's final reply; see [Sessions: Delegating](../sessions.md#delegating).
data/docs/sessions.md CHANGED
@@ -19,7 +19,7 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
19
19
 
20
20
  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.
21
21
 
22
- **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered, no background task it watches for a [finish wake](internals/background-tasks.md#finish-wake) still running or waiting for its wake turn (with `session.task_wakes: wake`; not one whose turn you stopped) 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`). As its Bridge stops, the worker marks the lock leaving (`OwnerLock#mark_leaving`: `leaving_at`, and `leave_wait`, its `recap.timeout`), since it keeps the lock while it writes the recap on leave: an `--attach` or `--resume` meanwhile (here, or a remote one through chi web's wake) prints `(waiting for the session to finish its recap…)` (`LaunchMode::RECAP_WAIT`) and waits for the release, up to that budget plus `OwnerLock::LEAVE_MARGIN` (5 s), before starting the new worker (`OwnerLock.await_release`). A lock held by a worker that isn't leaving (a live one, a stuck one) is left as before. A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up (for background tasks, which ones and how to stop them). The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, reminders, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
22
+ **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered, no background task it watches for a [finish wake](internals/background-tasks.md#finish-wake) still running or waiting for its wake turn (with `session.task_wakes: wake`; not one whose turn you stopped) 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`). As its Bridge stops, the worker marks the lock leaving (`OwnerLock#mark_leaving`: `leaving_at`, and `leave_wait`, its `recap.timeout`), since it keeps the lock while it writes the recap on leave: an `--attach` or `--resume` meanwhile (here, or a remote one through chi web's wake) prints `(waiting for the session to finish its recap…)` (`LaunchMode::RECAP_WAIT`) and waits for the release, up to that budget plus `OwnerLock::LEAVE_MARGIN` (5 s), before starting the new worker (`OwnerLock.await_release`). A lock held by a worker that isn't leaving (a live one, a stuck one) is left as before. A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up (for background tasks, which ones and how to stop them). When every hold is one the user can stop — reminders, a watched task still running, a delegate with a live worker — the 409 also carries `holds: [{kind:, items: [{id:, label:, detail:}]}]` (a reminder's detail is its schedule, `every 20m`/`every 1h`, `, due now` when it is already due), and an attached terminal asks what to do with them; its "stop them and exit" posts `{"stop": "all"}`, which clears the reminders, stops those tasks (`TaskRuntime.stop_task`, as `task_stop`) and stops those children (`SessionManager.stop_session`) before leaving, re-checking first that nothing the user can't stop appeared meanwhile (`HoldStop`). A delegate counts only for that ask: it never holds the idle exit, and an exit that deletes or archives the session does not ask about it. 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.
23
23
 
24
24
  **Empty sessions:** a session nothing happened in is deleted when it is left, so `chi` → `/exit` leaves no trace. Empty means: no messages, no turn tried (a failed turn leaves a `last_prompt` and keeps it), no memory used, no pending question, nothing in `input/`, `notes/` or `images/`, nothing in its dir beyond the worker's skeleton, mode `assist`, and the default model. A session on another model (`/model`, `--model`) counts as prepared and stays (`SessionManager.discardable?`). The worker checks as it idle-exits or leaves on a client's request and deletes it once its lock is free, checking again first. The attached `/exit` then says `Detached; the session is empty: it will be discarded if nothing arrives before its worker stops.` (a note or prompt landing first keeps it). The REPL (`--no-shared`) does the same at `/exit` or Ctrl-D. The retention sweep catches those killed first: empty sessions over an hour old with no live owner, whatever `DAYS`/`MAX`, and skeleton-only dirs with no `*.json`. `session.keep_empty: true` (env `SAMAGOTCHI_SESSION_KEEP_EMPTY`) turns all of it off.
25
25
 
@@ -33,7 +33,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
33
33
 
34
34
  **A worker killed mid-turn:** while a turn runs, its worker keeps it on disk (`TurnCheckpoint`): the session file has the turn so far (the prompt from the turn's start, then the finished tool steps and lines merged into it) and `interrupted_turn: {id, started_at, at, iteration, enqueued_id}` (`enqueued_id`: the input the turn runs, so any process can tell which turn is running; a continue turn carries its offer turn's), written as the turn starts, at the loop's first iteration boundary and then at most every 10 s (`updated_at` moves with it). The turn's end saves the real messages without the mark. A worker that died without ending its turn (SIGKILL, a crash of the process) leaves the mark, and loses what came after its last written boundary: at most the last 10 s of steps, plus the step in flight. Each checkpoint write also saves the turn's metrics so far to `analytics.json` (tokens, speeds, timings, its finished tool calls), as the record a killed turn leaves: canceled, `cancellation_reason: worker_stopped`; the turn's own end writes over it. The next worker to start for the session (a `chi send`, `--resume`, `--attach`, the web), or a `--no-shared` REPL resuming it, settles the turn before anything else (`TurnCheckpoint.settle`, log `interrupted_turn_settled`): a note `the previous turn was cancelled (worker-stopped) after Ns` goes at the tail, the status goes idle and the mark goes; `last_turn` stays the previous turn's (a new one saved before the turn a `chi send --wait` woke the worker for would end that wait at once); the dead turn's end is recorded under its `enqueued_id` instead, in `<id>/input/settled/<enqueued_id>` (a `last_turn` record: canceled, `cancel_reason: worker_stopped`), which only a wait for that very turn reads (`chi send --queue --wait`, a `chi send --wait -m` to an idle session or `--new`, `chi send --wait ID --turn`, a continue's wait): it ends `canceled` with `cancel_reason: worker_stopped`, detail "its worker exited mid-turn", instead of timing out. A wait with no turn id that saw the turn running ends the same way when the settle comes (`chi send --wait ID` with no message on a turn with no id, such as a session's first prompt or a reminder turn; a message merged into the turn as a steer), unless an input still waits or was claimed since: an unmerged message runs as its own turn next, and that turn's end is the answer. A reply the turn wrote before its worker died is still its answer. The session shows the turn's prompt and steps with that note; the dead turn is not run again, and the model reads on its next turn that it was cut.
35
35
 
36
- **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`. A running turn is canceled over the Bridge first, with reason `session_stopped` (the turn's line ends "(session stopped)", its note says `cancelled (session-stopped)`, and a command it was running says "stopped as the session was stopped"; a bare signal to the worker makes it "stopped by a signal to chi"; an older worker takes the reason as `manual`); the TERM stops whatever still runs as a Stop with reason `signal` and saves it (a spawned worker traps TERM, HUP and INT: `WorkerSignals`; a turn not ended within 3 s, 0.5 s after a stop or a second signal, is cut and the session saved as it was), and a stopped worker still saves its kept turn, since the marker, not the saved status, says stopped. Any TERM, HUP or INT does the same to a worker outside a stop: it leaves (log `worker stop reason=signal`) and the next send wakes a new one, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's `llm ctx` chip, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s (the page's Stop shows the stop at once), or for the body's `wait` up to the same 10 s (`SessionManager::STOP_WAIT`): `chi --remote NAME sessions stop` sends it, so it waits as long as here. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
36
+ **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`. A running turn is canceled over the Bridge first, with reason `session_stopped` (the turn's line ends "(session stopped)", its note says `cancelled (session-stopped)`, and a command it was running says "stopped as the session was stopped"; a bare signal to the worker makes it "stopped by a signal to chi"; an older worker takes the reason as `manual`); the TERM stops whatever still runs as a Stop with reason `signal` and saves it (a spawned worker traps TERM, HUP and INT: `WorkerSignals`; a turn not ended within 3 s, 0.5 s after a stop or a second signal, is cut and the session saved as it was), and a stopped worker still saves its kept turn, since the marker, not the saved status, says stopped. Any TERM, HUP or INT does the same to a worker outside a stop: it leaves (log `worker stop reason=signal`) and the next send wakes a new one, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's `llm ctx` chip, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s (the page's Stop shows the stop at once), or for the body's `wait` up to the same 10 s (`SessionManager::STOP_WAIT`): `chi --remote NAME sessions stop` sends it, so it waits as long as here. Before that stop, the page's `stop session` button asks what the worker still has that the user can stop: `GET /api/sessions/:id/holds` (owner-only) answers the worker's `holds` — the same list the exit ask's 409 carries — always an array, empty when there is no live worker, one from before the route, or one that did not answer. With something, a sheet (a bottom sheet on a phone, a popover at the button otherwise — `stop_sheet.js`) lists them, at most three of a kind named and `+N more`, and offers **Stop all** (`POST /api/sessions/:id/holds/stop` → `HoldStop#stop_all`, then the stop), **Stop session only** (the stop exactly as it was) or **Cancel**. Nothing to ask about is the plain confirm, one tap as before. Both buttons stay because a stop only TERMs the worker: its reminders die with it, while a background task and a delegate are their own processes and keep running. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
37
37
 
38
38
  **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 page 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?force=true` (the page sends `force`; without it a live worker's session is 409 `worker_running`, as the CLI without `--force`; 200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
39
39
 
@@ -94,7 +94,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
94
94
  - 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).
95
95
  - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `#N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `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), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `stage_view.js` (the stage view: the running turn pinned above the composer, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`), `markdown_place.js` (where rendered markdown and its refs go: a text as markdown HTML, its `path:line` refs, a final answer's rendering), `images.js` (image chips in the composer: paste, drop and the image button, the upload before a turn, the thumbnails and the lightbox), `composer_grip.js` (the composer's grip and fold: the drag, tap and keys, the fold per visit), `strip_grip.js` (the latest-sessions strip's grip, the composer grip's twin: hides and shows the strip), `storage.js` (the page's per-browser choices in localStorage, guarded: a choice that can't be read or written is not remembered), `unqueue.js` (the × on a message queued for after the turn: takes it back, its text into an empty composer or a toast), `toasts.js` (the page's toast stack: a timer each, sticky ones until acted on, the same text reusing its item), `toast_place.js` (where the toast sits: above the composer card, the stage and an open model panel), `dock_reserve.js` (what floats over the history: the history's bottom reserve (`--dock-h`) at the composer card's and the showing stage's height, a reader at the end kept there, and the toast's place measured for `toast_place.js`), `notice_links.js` (a hook notice line's http(s) URLs as links), `start_page.js` (the start page's model picker and its LLM context and thinking chips, a choice for the next new session only; a fresh chain link's picker and its `/model`), `session_actions.js` (Continue, Archive and its Undo, Delete and Stop, and the start page when the open session is gone), `send_turn.js` (the composer's send: a new session first on the start page, the route, the echo, the image upload, the `/turn` ack, a fast failure's restore and each error's place). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
96
96
  - 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).
97
- - The web session page's message box folds away for room to read. Drag the grip on its top edge (the pill that shows on hover; always on a phone) down, past the box by 32 px: the pill turns accent while releasing would hide it, and on release the box folds and the info bar stays as the page's bottom edge. The grip stays as a handle on the info bar's top edge, drawn always: pull it up (8 px opens it, and the box follows the pointer in the same gesture), click or tap it (opens it and focuses the box), or just type on the page (the key lands in the box) to bring it back; Enter or Space on the focused grip folds or opens too. A draft (text or images, which folding never clears) colours the handle with the accent, a session waiting on you with the attention colour; its tooltip says so. Stop turn moves into the info bar while it is folded. The fold is remembered per browser (`chi_composer_collapsed`), an archived session opens folded for the visit, and opening it there, like the page opening it itself (a restored line, a quote, continue →'s opener), is for that visit only, and a fold forgets the height the grip set (`chi_composer_floor`): the box reopens at one line, or at its text's height. The start page's grip only resizes.
97
+ - The web session page's message box folds away for room to read. Drag the grip on its top edge (the pill that shows on hover; always on a phone) down, past the box by 32 px: the pill turns accent while releasing would hide it, and on release the box folds and the info bar stays as the page's bottom edge. The grip stays as a handle on the info bar's top edge, drawn always: pull it up (8 px opens it, and the box follows the pointer in the same gesture), click or tap it (opens it and focuses the box), or just type on the page (the key lands in the box) to bring it back; Enter or Space on the focused grip folds or opens too. A draft (text or images, which folding never clears) colours the handle with the accent, a session waiting on you with the attention colour; its tooltip says so. Stop turn moves into the info bar while it is folded. The fold is remembered per browser (`chi_composer_collapsed`), an archived session opens folded for the visit, and opening it there, like the page opening it itself (a restored line, a quote, continue →'s opener), is for that visit only. The fold keeps the height the grip set (`chi_composer_floor`): the box opens again at the height it had, and only a double-click on the grip forgets it. That height never takes more than half of the window the box is drawn in: a floor saved in a bigger window is held to that half in a smaller one (a rotated phone, a resized window, another device on the same origin), and the saved floor itself stays, so the height comes back when the room does. A click on the expanded grip hides the box as well: it folds 250 ms after the press, so a second click inside that window is read as a double-click and resets the height instead (a drag, Enter or Space act at once, and a drag after a click takes the wait back). The start page's grip only resizes.
98
98
 
99
99
  **Test-session hygiene:**
100
100
 
@@ -192,6 +192,7 @@ module Samagotchi
192
192
  activity = { tool: part[:tool], status: part[:status], params: part[:params], **title }
193
193
  activity[:action] = part[:action] if part[:action]
194
194
  activity[:no_match] = true if part[:no_match]
195
+ activity[:guardrail] = part[:guardrail] if part[:guardrail]
195
196
  completed = { type: :tool_call_completed, **call, output: part[:output], output_truncated: !!part[:output_truncated],
196
197
  activity: activity }
197
198
  completed[:duration_ms] = part[:duration_ms] unless part[:duration_ms].nil?
@@ -333,6 +334,9 @@ module Samagotchi
333
334
  capped = output.length > @max_output_chars
334
335
  tool[:status] = (event.dig(:activity, :status) || "ok").to_s
335
336
  tool[:no_match] = true if event.dig(:activity, :no_match)
337
+ # Only System 1's auto-allow is drawn on the row (the web's label).
338
+ guardrail = event.dig(:activity, :guardrail)
339
+ tool[:guardrail] = guardrail if guardrail.is_a?(Hash) && guardrail[:decided_by] == "system one"
336
340
  tool[:output] = capped ? ToolOutputCut.preview(output, cap: @max_output_chars, tool: event[:tool]) : output.dup
337
341
  tool[:output_truncated] = capped || !!event[:output_truncated]
338
342
  tool[:images] = event[:images] if event[:images]