samagotchi 0.121.0 → 0.121.1

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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -1
  3. data/bin/chi +3 -2
  4. data/docs/architecture.md +36 -11
  5. data/docs/bundle-authoring.md +2 -1
  6. data/docs/cli.md +10 -4
  7. data/docs/configuration.md +7 -3
  8. data/docs/guardrails.md +48 -19
  9. data/docs/internals/anthropic.md +1 -1
  10. data/docs/internals/background-tasks.md +1 -1
  11. data/docs/internals/participants.md +3 -1
  12. data/docs/internals/remote-sessions.md +15 -11
  13. data/docs/internals/web-push.md +22 -11
  14. data/docs/plugins.md +6 -2
  15. data/docs/sessions.md +15 -0
  16. data/docs/testing.md +41 -1
  17. data/lib/samagotchi/answer_command.rb +1 -1
  18. data/lib/samagotchi/bridge.rb +8 -8
  19. data/lib/samagotchi/bridge_client/event_stream.rb +11 -27
  20. data/lib/samagotchi/bridge_client/stream_open.rb +79 -0
  21. data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
  22. data/lib/samagotchi/bridge_client.rb +52 -65
  23. data/lib/samagotchi/bridge_secret.rb +9 -7
  24. data/lib/samagotchi/bundle_command/check.rb +27 -2
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/claude-peer/scripts/pair +20 -6
  27. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
  29. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
  31. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  32. data/lib/samagotchi/child_move.rb +5 -7
  33. data/lib/samagotchi/children_status.rb +2 -1
  34. data/lib/samagotchi/guardrail_wiring.rb +19 -2
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
  36. data/lib/samagotchi/guardrails_command.rb +41 -6
  37. data/lib/samagotchi/image_store.rb +6 -3
  38. data/lib/samagotchi/interrupted_turn.rb +6 -3
  39. data/lib/samagotchi/live_versions.rb +6 -7
  40. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  41. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  42. data/lib/samagotchi/plugin/api.rb +8 -5
  43. data/lib/samagotchi/plugin/sessions.rb +13 -4
  44. data/lib/samagotchi/push_command.rb +1 -1
  45. data/lib/samagotchi/relay_verifier.rb +2 -3
  46. data/lib/samagotchi/relay_watcher.rb +2 -2
  47. data/lib/samagotchi/remote_attach_command.rb +1 -1
  48. data/lib/samagotchi/reply_wait/poll.rb +8 -2
  49. data/lib/samagotchi/reply_wait.rb +4 -3
  50. data/lib/samagotchi/review_command.rb +1 -1
  51. data/lib/samagotchi/run_options.rb +1 -0
  52. data/lib/samagotchi/session.rb +4 -0
  53. data/lib/samagotchi/session_access/local.rb +18 -8
  54. data/lib/samagotchi/session_access/remote.rb +5 -0
  55. data/lib/samagotchi/session_access/web_bridge_client.rb +31 -19
  56. data/lib/samagotchi/session_access.rb +21 -9
  57. data/lib/samagotchi/session_continue.rb +2 -3
  58. data/lib/samagotchi/session_manager.rb +118 -28
  59. data/lib/samagotchi/sessions_command.rb +10 -5
  60. data/lib/samagotchi/system_one/client.rb +6 -1
  61. data/lib/samagotchi/system_one/fake_backend.rb +0 -2
  62. data/lib/samagotchi/system_one/log.rb +21 -6
  63. data/lib/samagotchi/system_one/self_protect.rb +39 -43
  64. data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
  65. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  66. data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
  67. data/lib/samagotchi/toolkit.rb +68 -2
  68. data/lib/samagotchi/tools/delegate.rb +1 -1
  69. data/lib/samagotchi/tools/delegate_relay.rb +9 -7
  70. data/lib/samagotchi/turn_checkpoint.rb +15 -9
  71. data/lib/samagotchi/version.rb +1 -1
  72. data/lib/samagotchi/web/app.rb +123 -65
  73. data/lib/samagotchi/web/participant_access.rb +26 -7
  74. data/lib/samagotchi/web/participant_review.rb +2 -1
  75. data/lib/samagotchi/web/participant_view.rb +7 -3
  76. data/lib/samagotchi/web/participants_admin.rb +13 -0
  77. data/lib/samagotchi/web/public/app.js +1 -1
  78. data/lib/samagotchi/web/public/notifications.js +21 -1
  79. data/lib/samagotchi/web/public/participant.html +3 -2
  80. data/lib/samagotchi/web/public/participant.js +23 -9
  81. data/lib/samagotchi/web/public/participants_panel.js +46 -11
  82. data/lib/samagotchi/web/public/push.js +4 -3
  83. data/lib/samagotchi/web/public/push_devices.js +17 -3
  84. data/lib/samagotchi/web/public/review_history.js +14 -2
  85. data/lib/samagotchi/web/public/review_start.js +1 -3
  86. data/lib/samagotchi/web/public/session_actions.js +14 -2
  87. data/lib/samagotchi/web/public/storage.js +16 -0
  88. data/lib/samagotchi/web/push/client.rb +5 -0
  89. data/lib/samagotchi/web/push/store.rb +16 -3
  90. data/lib/samagotchi/web/push_routes.rb +3 -2
  91. data/lib/samagotchi/web/session_access_routes.rb +25 -17
  92. data/lib/samagotchi/web/session_create.rb +4 -4
  93. data/lib/samagotchi/web/session_hub.rb +6 -4
  94. data/lib/samagotchi/web/session_summary.rb +8 -16
  95. data/lib/samagotchi/worker.rb +1 -1
  96. data/lib/samagotchi/worker_link.rb +213 -0
  97. data/lib/samagotchi/worker_sidecar.rb +15 -37
  98. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 78051f4f253f400d0b791fe2694f0dc3d07a19e7d1268ef30557ff3a780d8d64
4
- data.tar.gz: c0bf5fbba8c530df5bf56a3947addddc0a9952a39f2077ddd5f59129683ba8f6
3
+ metadata.gz: b07fb49361e67b325eaaf3190d15d3f889a6df2e590585b45257a0a2b1409ac9
4
+ data.tar.gz: 37e6db4dc2235fcfe9127d38261056983f609ebd0dd1a214a903ec94c8afec35
5
5
  SHA512:
6
- metadata.gz: c438bac307263d1a56cd9a97b56a2c3ab7175d9be0944c4e327fb92a57c9fdd9ffb82bdac8ec7048ac780a4a9e5f3545a3186dfcb3fec7842e687e2d47099307
7
- data.tar.gz: 2738c2cc9f4f81f34c3c88f02568066dced57578a88ac6d1e301ca62508a3e343179a97a7d6ef9d6778f463a00648d2aa0f0b1aded820e8bce7442b2601a0d8a
6
+ metadata.gz: 8a8e5c43eee0002c4054c6e347f75a795fa09216a7f0ea3db30770043a5eaaf57fa34f9a1ee5a4ddf64117f1e8f3bb5ed4ccf8b34c55bb5c9a2e6398c1fec997
7
+ data.tar.gz: 23714d86e0872fdcba52fb019b30dbf84c4531a3c44a13f0cb30b72cc95bb59506c654cb53ae395b3d8ae5d83672c29fe8624958ef968dc5648b4189d9e8731f
data/CHANGELOG.md CHANGED
@@ -7,6 +7,86 @@ and commands may change between minor versions. How releases are made:
7
7
  [docs/releasing.md](docs/releasing.md).
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.121.1] - 2026-10-27
11
+
12
+ ### Fixed
13
+
14
+ - `chi sessions restart` can recover an unreachable, secret-refusing, newer-protocol or stuck-start worker without a
15
+ stop marker, but refuses when disk shows a running turn, pending question, queued input or running background task.
16
+ - chi web now opens a session stream before answering: a missing, leaving or unreachable worker gets 503, a Bridge
17
+ refusal gets 502 instead of an empty 200 or SSE frame, and the opened socket closes when the stream ends. Remote
18
+ stream retries distinguish 503 from a 502 `bridge_secret`, and `/stats` reports secret refusals consistently.
19
+ - A plugin's `ctx.sessions.fork` reports a child chi won't start as `Samagotchi::Plugin::Sessions::Error` with chi's
20
+ own reason (the session's model is gone from the config, or its toolkit is a `toolkit.kits` kit with an invalid
21
+ `groups:`) instead of handing the bundle the raw `Toolkit::Refused` or `ModelProfile::MissingModel`.
22
+ - A `toolkit.kits` kit whose `groups:` isn't a name or a list (a map such as `{chrome: true}`) no longer loads
23
+ silently with its base's groups: chi, `chi send --new`, chi web and `delegate` refuse a new session on it, naming
24
+ the kit and the reason, and a session already saved on it reaches no tool-index group until config.yml is fixed.
25
+ - A worker's liveness is one rule everywhere, the owner lock plus the sidecar's pid, with no port probe: after a
26
+ `kill -9` whose port another session's Bridge took, `chi send`, `--attach` and chi web no longer take the dead
27
+ worker for live (they wake a new one). A worker on a newer sidecar protocol is not driven by an older chi: a wake
28
+ answers 409 `worker_newer`.
29
+ - System 1 no longer auto-allows a command that changes chi's own config through a spelling its check missed
30
+ (`$XDG_CONFIG_HOME/samagotchi/config.yml`, `$HOME/.config/…`, `memories/../config.yml`, `cd ~/.config && …`): it
31
+ refuses by the same test that makes an ask the user's alone, so such a call goes to the user. Memory files still
32
+ can be auto-allowed.
33
+ - `chi guardrails replay` no longer judges an ask again that System 1 already judged live (audit or on mode), which
34
+ counted each such decision twice and let a rule pass `min_record` early: a live label now carries the card's
35
+ `question_id`, and a live line without one is matched by its session and call digest.
36
+ - Stopping a turn while System 1 judges one of its approvals no longer opens an approval card for the stopped turn
37
+ (nor logs the cancel as a failed judgment): the call is denied with "The turn was stopped".
38
+ - A participant no longer reads a one-time link code (`chc_…`, as in an invite link a reply quotes) or a Bridge's
39
+ `X-Chi-Bridge` secret: both are redacted like `chp_` tokens.
40
+ - An agent participant's `chi --remote … send --wait` goes on after chi web restarts: chi web reads whose turn it is
41
+ from the claimed input file, the last turn or the refused turn's record, not only its memory (it answered 403).
42
+ - chi web's participants routes (list, invite, revoke, cap) answer a grants store chi can't write (unreadable, or a
43
+ newer chi's) with 409 `grants_unwritable` and the store's own message, not a 500 with a backtrace on every refresh.
44
+ - A participant name or an annotate preset holding `\&`, `` \` `` or `\'` no longer breaks chi web's page: it was read
45
+ as a backreference when the page's attributes went in.
46
+ - One push subscription with a `p256dh` key off the P-256 curve no longer stops every push: chi web refuses such a
47
+ subscribe (400 `invalid_subscription`), and one already on disk fails that device alone, so the other devices still
48
+ get the push, `POST /api/push/test` answers that device as failed instead of a 500, and `chi web push test` prints
49
+ its failure instead of a backtrace.
50
+ - The notifications sheet's switches always show what chi web has: "this device" no longer stays on after iOS's
51
+ Don't Allow, nor "Long turns done" moved after its change failed.
52
+ - Turning push off on a flaky connection no longer drops this phone's subscription while chi web keeps the device:
53
+ chi web is told first, and a failure leaves the bell on with the error. The Home Screen app whose first `GET
54
+ /api/push` failed (chi web restarting, a phone just woken) tries again when it comes back to the front instead of
55
+ staying in tab mode until a reload.
56
+ - `chi --help` lists `chi guardrails replay` (seeding System 1's record), the one subcommand it left out.
57
+ - The participants panel's Cap form no longer sets a $0 cap when its field is cleared, nor sends a negative or
58
+ over-$1000 one: it refuses as the Invite's cap field does ("A cap is dollars, 0 to 1000.").
59
+ - A double tap on the web's Stop no longer asks twice (two confirms, or two stops): a second Stop of the same session
60
+ while the first one asks the worker what still runs, or stops it, does nothing.
61
+ - A participant's comment box keeps what they typed while their last comment was on its way (it was cleared when the
62
+ comment was accepted), and an error under the box (a refusal, "Images can't go in a review comment yet") goes at
63
+ their next edit or send instead of staying until a comment went through.
64
+ - An invite whose participants panel closed before chi web answered no longer loses its one-time link (the grant was
65
+ made, so the owner had to revoke and invite again): a sticky toast names it with Copy link.
66
+ - A participant's thread no longer jumps when a read fails and again when it recovers: "Can't reach the session —
67
+ retrying…" (and "This session is gone.") shows over the thread's top instead of as a new row in the header.
68
+ - A participant's wait on a turn whose worker died in it is no longer refused (403) after chi web restarts: the turn's
69
+ canceled (`worker_stopped`) record keeps whose turn it was.
70
+ - claude-peer (0.4.5): `pair` run as `bash pair` or by zsh no longer refuses every call with its usage (only `sh
71
+ pair` kept the id and the `--inbox-socket` path), and a carriage return or another control character in what it
72
+ pushes (the cwd) is escaped, so chi's JSON read no longer refuses the push.
73
+ - codex-peer (0.3.4): a carriage return or another control character in what `pair` pushes (the cwd) is escaped, as
74
+ claude-peer's is, so chi's JSON read no longer refuses the push.
75
+ - A toolkit's `groups:` match source groups in any case and with `{a,b}` braces, as the guardrails rules' `models:`
76
+ do (`groups: [Chrome]` missed `chrome`), and a config kit's `groups:` that isn't a name or a list (a map) is refused
77
+ with a line, the kit loading without it, instead of matching its stringified pairs.
78
+ - A plugin's `chi.toolkit(groups:)` that isn't a name, a list or `:all` (a map) is a load error ("toolkit NAME: groups
79
+ must be a list of group names or globs"), as a config kit's is refused, instead of matching its stringified pairs.
80
+ - `chi bundle check`'s `requires-chi-floor` sees a keyword added later: `chi.toolkit(prompt:)` (0.72.0),
81
+ `chi.toolkit(groups:)` (0.121.0), `ctx.ask_model(model:)` (0.80.0) and `ctx.sessions.send(delivery:)` (0.64.0).
82
+ - A remote `chi share` (or `share ls`/`cap`/`revoke`) whose chi web can't read its grants store prints the store's own
83
+ line, as a local one does, instead of "… answered 409 grants_unwritable: …".
84
+ - `chi web push test`'s line for a push that failed before the push service answered reads "failed (12 ms)", not
85
+ "failed (12 ms)".
86
+
87
+ Update with `chi update` (claude-peer 0.4.5, codex-peer 0.3.4, github-pr 0.5.1), then restart `chi web` and
88
+ `chi sessions restart` the running sessions.
89
+
10
90
  ## [0.121.0] - 2026-10-26
11
91
 
12
92
  ### Added
@@ -4551,7 +4631,8 @@ and long-lived sessions.
4551
4631
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
4552
4632
  hotkey panel that send selected text or the clipboard to your sessions.
4553
4633
 
4554
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.121.0...HEAD
4634
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.121.1...HEAD
4635
+ [0.121.1]: https://github.com/dm1try/samagotchi/compare/v0.121.0...v0.121.1
4555
4636
  [0.121.0]: https://github.com/dm1try/samagotchi/compare/v0.120.0...v0.121.0
4556
4637
  [0.120.0]: https://github.com/dm1try/samagotchi/compare/v0.119.0...v0.120.0
4557
4638
  [0.119.0]: https://github.com/dm1try/samagotchi/compare/v0.118.0...v0.119.0
data/bin/chi CHANGED
@@ -391,7 +391,7 @@ if launch == :attached
391
391
  toolkit_flags: options[:toolkit_flags] || [],
392
392
  settings: options[:settings],
393
393
  parent_answers: options[:parent_answers])
394
- rescue Samagotchi::TerminalUI::AttachLauncher::Error => e
394
+ rescue Samagotchi::TerminalUI::AttachLauncher::Error, Samagotchi::Toolkit::Refused => e
395
395
  warn "Error: #{e.message}"
396
396
  exit 1
397
397
  rescue Interrupt
@@ -427,7 +427,8 @@ begin
427
427
  # limit, or Ctrl-C cancelled it.
428
428
  exit 1 if %i[turn_failed empty_answer iteration_limit].include?(ended)
429
429
  exit Samagotchi::CLI::Exit::INTERRUPTED if ended == :interrupted
430
- rescue Samagotchi::TerminalUI::SessionBusy, Samagotchi::TerminalUI::SessionNotFound, Samagotchi::ModelProfile::MissingModel => e
430
+ rescue Samagotchi::TerminalUI::SessionBusy, Samagotchi::TerminalUI::SessionNotFound, Samagotchi::ModelProfile::MissingModel,
431
+ Samagotchi::Toolkit::Refused => e
431
432
  warn "Error: #{e.message}"
432
433
  exit 1
433
434
  rescue Samagotchi::LLM::ProviderError => e
data/docs/architecture.md CHANGED
@@ -393,14 +393,18 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
393
393
  worker.
394
394
  - **The secret.** Every request, on every route (the stream, the reads such as `state` and `stats`, the writes, and
395
395
  an unknown path), names the Bridge's secret in `X-Chi-Bridge`; one without it, or with another one, is
396
- `403 bridge_secret` with one message (`BridgeSecret::REFUSED`: restart chi web, re-attach terminals and restart
397
- older session workers), after the browser and proxy checks and before any route (`Bridge#secret_ok?`,
396
+ `403 bridge_secret` with one session-specific message (`BridgeSecret.refused`): "session ID's worker refused
397
+ this chi's request (it didn't name its secret): a chi web or terminal started before chi was updated can't
398
+ reach it; restart chi web, re-attach with `chi --attach ID`, or restart the worker with `chi sessions restart
399
+ ID`." `chi sessions restart` can also recover when the worker cannot be asked: TERM + resume if disk shows no
400
+ running turn, pending question or approval, queued input, or running background task. The check is after the
401
+ browser and proxy checks and before any route (`Bridge#secret_ok?`,
398
402
  `OpenSSL.secure_compare`). No route is left open: what the reads show is the session itself. Only a request that
399
403
  got past it counts as client activity, so a refused one (an older chi web's polls) never holds the idle exit. Each Bridge makes its own (`SecureRandom.hex(32)`, 64 hex
400
404
  characters) when it starts and writes it to `bridge.json`, which is 0600 from the moment it exists
401
405
  (`AtomicFile` creates the temporary file with that mode, then renames it), so only this machine's user can read
402
406
  it: a forward that adds no proxy header, or another local account, reaches the port but not a session. Clients get
403
- it only from the sidecar: `BridgeClient.discover`/`.wait_for`/`.for_sidecar` take the port and the secret from the
407
+ it only from the sidecar: `WorkerLink.client`/`.await` and `BridgeClient.for_sidecar` take the port and the secret from the
404
408
  same read (chi web makes its client from the sidecar for every request, the attached terminal again on a
405
409
  `worker_changed`), so a respawned worker's new one is used and nothing passes a port by hand. A worker from before
406
410
  the secret writes none and asks for none; a chi web or attached terminal from before it sends none, so a worker
@@ -408,7 +412,7 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
408
412
  (`BridgeClient::Response#secret_refused?`) and shows the one message: chi web's relays answer 502 `bridge_secret`,
409
413
  `chi answer` and a parent's approval relay say it instead of "refused", and an event stream ends with reason
410
414
  `bridge_secret` once rediscovery finds no newer worker (the attached terminal's closing line), never retrying in
411
- silence; chi web's stream proxy passes no refusal on as frames. The secret is never logged (a refusal logs
415
+ silence; chi web's stream proxy answers 502 `bridge_secret`, never passing the refusal on as frames. The secret is never logged (a refusal logs
412
416
  `secret_refused` with the path, at most once a minute), never in an event, a reply, `/api/info` or a session
413
417
  file, and `WorkerSidecar`, `BridgeClient` and `Bridge` leave it out of `#inspect`.
414
418
 
@@ -491,10 +495,29 @@ Every route goes through `Bridge#dispatch`: an id other than the bridge's own se
491
495
  `handler_failed`) rather than dropping the connection.
492
496
 
493
497
  The per-session port is OS-assigned (bound to `0`) and published to a `bridge.json` sidecar
494
- for client discovery, with the Bridge's secret (see above). `chi web`'s `GET /api/sessions/:id/stream` proxies this bridge
495
- (503 `not_live` when the worker is not running; full history of any session is served by
496
- `GET /api/sessions/:id/output`), as a byte pipe for the owner: `App#proxy_stream_body` gives `ProxyStreamBody` the
497
- request's frame filter (`App#stream_filter`: nil for the owner; for a participant, and the owner's participant view
498
+ for client discovery, with the Bridge's secret (see above).
499
+
500
+ **Worker link.** Every client asks `WorkerLink` (`worker_link.rb`) whether a session's worker can be reached, never
501
+ reading `bridge.json` or probing a port itself: `WorkerLink.resolve(session_dir, unlink:, owner:)` answers a typed
502
+ outcome. The rule is the owner lock plus the sidecar's `pid`; there is no port probe (a port can be reused by another
503
+ session's Bridge, a free lock is the kernel's proof the worker died). In order: no lock holder, or a chi REPL's: a
504
+ sidecar is `Stale(:lock_free)` (removed with `unlink: true`, only if it is still the file that was read), else `None`;
505
+ a holder with no kind yet: `Starting`; a leaving worker: `Leaving`; a worker whose sidecar names no port or another
506
+ pid than the holder's (a worker that has not written its own yet): `Starting`, `stuck` once it has held the lock
507
+ past `START_LIMIT` (30 s) with none; a sidecar whose `protocol` is newer than `WorkerLink::PROTOCOL`: `Skew` (a
508
+ newer worker; never driven, `wake` answers 409 `worker_newer`); else `Live` (its sidecar; `#client` makes the one
509
+ `BridgeClient`). A sidecar with no `pid` is an older worker's: the lock alone says it is live. `owner:` is the
510
+ holder as the caller already read it (nil: free), so one read serves a summary and its status. `WorkerLink.client` is
511
+ the Live's client or nil; `WorkerLink.await` polls for a new worker's Live (an `until:` check tells a restart's
512
+ successor from the old worker); chi web's `App#worker_link` / `#await_worker_link` wrap them. The Bridge writes
513
+ `pid` and `protocol` with its port and removes its sidecar on stop only when the file still carries its epoch.
514
+
515
+ `chi web`'s `GET /api/sessions/:id/stream` proxies this bridge
516
+ (503 `not_live` for no worker, a leaving or stuck worker, or an unreachable Bridge; 502 `worker_newer` for skew and
517
+ `bridge_error` / `bridge_secret` for a refused Bridge; 200 only after the Bridge answers; full history of any session
518
+ is served by `GET /api/sessions/:id/output`). It opens the Bridge before answering and gives the one opened socket to
519
+ `App#proxy_stream_body` / `ProxyStreamBody`, which closes it when the body or hijacked client ends. The owner's stream
520
+ is a byte pipe; the body gives it the request's frame filter (`App#stream_filter`: nil for the owner; for a participant, and the owner's participant view
498
521
  `?as=participant`, a `Web::ParticipantStreamFilter`, which cuts the Bridge's chunks into frames and writes each again
499
522
  from `Web::ParticipantView.event`'s allowlist, dropping every other event; only `from_seq` and `snapshot` reach the
500
523
  Bridge; [internals/participants.md](internals/participants.md#what-a-participant-sees)). Resume/ring-buffer state is **in-memory**, per worker: a cursor from
@@ -531,7 +554,7 @@ moved through `Session.summary_from_file`. Liveness is probed, since a killed ow
531
554
  file trace: the owner lock every tick for the sessions the projection believes owned, and every
532
555
  session every 10 s, so a `kill -9` shows within a second. The summary (`Web::SessionSummary`,
533
556
  shared with `/api/sessions` and the session view) carries `owner`, `project_root` and
534
- `bridge_up` (the sidecar is there *and* the lock is held: the page attaches its stream on it), and
557
+ `bridge_up` (`WorkerLink` answers Live: a worker holds the lock and its sidecar names its pid; the page attaches its stream on it), and
535
558
  from that sidecar `worker_version` (the chi the worker runs) and `worker_features`
536
559
  (`Bridge::FEATURES`, what a client may ask of it beyond the routes: `restart`, `task_stop`, `reparent`, `unqueue`,
537
560
  `delivery`, a worker that queues a message for after the turn; `SessionManager.deliver_turn` and
@@ -577,12 +600,12 @@ model: [internals/remote-sessions.md](internals/remote-sessions.md).
577
600
  | `GET /api/sessions/:id/worker` | owner | | 200 `{session_id, live, stopped, owner: worker \| tui \| null, worker: {epoch, started_at, version, features} \| null, default_model, parent_strategy}` (`Local#worker`; never starts one; `default_model` is this machine's) | 404 |
578
601
  | `POST /api/sessions/:id/exit` | owner | `client_id` (passed on only when it is an attached TUI's, `tui:…`; any other is dropped), `delete` | 200 the worker's `{status: exiting, session_id, discard}` (`BridgeClient#request_exit`; never wakes one) | 409 `held` with the worker's `{status, reason, tasks, hint}`; 503 `not_live`; 501 `not_supported`; 504 `worker_timeout` |
579
602
  | `POST /api/sessions/:id/recap` | owner | | 200 the worker's `{enabled, saved, request, min_user_turns, session_off}` (`BridgeClient#request_recap`) | 503, 501, 504 as every relay |
580
- | `GET /api/sessions/:id/stats` | owner | | 200 `{session_id, metrics}`: the worker's stats, its state's metrics for a worker from before the stats route | 503 `not_live`; 504 `worker_timeout`; 502 `bridge_error` |
603
+ | `GET /api/sessions/:id/stats` | owner | | 200 `{session_id, metrics}`: the worker's stats, its state's metrics for a worker from before the stats route | 503 `not_live`; 504 `worker_timeout`; 502 `bridge_secret` or `bridge_error` |
581
604
  | `GET /api/sessions/:id/children/counts` | owner | | 200 `{running, waiting, unreported}` (`ChildrenStatus.counts`, the delegates' files here) | 404 |
582
605
  | `GET /api/sessions/:id/turns/:turn_id/records` | owner | | 200 `{tool_records: […], turn_record: {…} \| null}` (`SessionMetrics.saved_tool_records` / `saved_turn_record`, as saved) | 400 `invalid_turn_id`; 404 |
583
606
  | `POST /api/sessions/:id/turn` (`no_interrupt`) | owner | `no_interrupt: true`: the turn runs with the raised iteration limit (`SessionManager.deliver_turn(no_interrupt:)`, to the Bridge or the input file) | as before | |
584
607
  | `POST /api/sessions/:id/command` (its 501) | owner | | | 501 `not_supported` for a builtin the worker lacks adds `command` (`"/title"`) and `message` |
585
- | `GET`/`POST`/`DELETE /api/sessions/:id/participants`, `GET`/`DELETE /api/participants` | owner | `chi share`'s (`name`, `kind`, `ttl`; `?id=p_…`) | the grants, never a token but POST's own once ([internals/participants.md](internals/participants.md)) | 400 `invalid_participant`; 404 |
608
+ | `GET`/`POST`/`DELETE /api/sessions/:id/participants`, `GET`/`DELETE /api/participants` | owner | `chi share`'s (`name`, `kind`, `ttl`; `?id=p_…`) | the grants, never a token but POST's own once ([internals/participants.md](internals/participants.md)) | 400 `invalid_participant`; 404; 409 `grants_unwritable` (the store doesn't read: its words, which a remote `chi share` prints as a local one does) |
586
609
  | `GET /api/sessions/:id/apply[?turn=T]` | owner | `turn`: a turn id or a prefix (none: the latest review turn with a reply) | 200 `{text, turn_id, author, author_name}` (`ReviewApply`): the owner's Apply of a participant's review turn, for the composer, never sent ([internals/participants.md](internals/participants.md)) | 404 `not_found`; 400 `not_review_turn`; 409 `no_reply` |
587
610
  | `POST /api/sessions` (`review`) | owner | `review: {subjects: [project files], from: a session id}`; never with `prompt`, `preview`, `idle` or `continues` (a `from` takes the source's folder and setup) | 201 as a create's: an idle review session (`ReviewStart.start`, or `ReviewStart.fork` with `from`) | 400 `invalid_review` (`ReviewStart::Refused`'s words: a subject, a gone folder); with `from` also 404 `not_found`, 400 `ambiguous_id` |
588
611
 
@@ -723,8 +746,10 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
723
746
  | Shipped bundles | `bundles/` (`mcp`, `github-pr`, `loop-guard`, `skills`, `source-links`, …) | Bundles that ship with chi. |
724
747
  | Hooks | `hooks/registry.rb` (`Hooks::Registry`), `hooks/loader.rb`, `hook_routes.rb` (`HookRoutes`), `event_routes.rb` (`EventRoutes`) | Hook registration and dispatch; what a hook or plugin does beyond reading its event (notice, question, stop, steer) routed to the UIs (cards and notices through one set of routes) ([hooks.md](hooks.md)). |
725
748
  | Guardrails | `guardrails.rb`, `guardrails/shell_lex.rb`, `guardrails/data_readers.rb`, `guardrails/rules.rb`, `guardrails/protected_paths.rb`, `guardrails/parent_policy.rb` + `guardrails/parent_approvals.rb`, `guardrail_wiring.rb` | The gate before every tool call: shell lexing (and the commands that only read a heredoc as data), YAML rules, the core checks on chi's own files, approvals, what a parent agent may answer (`guardrails.parent_answers`) ([guardrails.md](guardrails.md)). |
749
+ | System 1 | `system_one.rb`, `system_one/` (`SystemOne::Client`, `ApprovalQuestion`, `Log`, `Record`, `SelfProtect`, `Similar`, the backends), `guardrail_wiring.rb` (`GuardrailWiring#request_approval`), `guardrails_command.rb` (`chi guardrails replay`) | The approval judge: a model's `p` for each ask, logged to `judgments.jsonl` with the user's answer; in `on` mode an ask on a listed rule with a clean record is allowed once without the user. Labelled by `/guardrails wrong` (`SessionCommands`) and the web's **should have asked** (the Bridge's `POST guardrails/wrong`) ([guardrails.md](guardrails.md#system-1-experimental)). |
726
750
  | Delegation | `tools/delegate.rb`, `tools/delegate_result.rb`, `tools/delegate_wait.rb`, `child_reports.rb`, `tools/delegate_relay.rb`, `relay_desk.rb`, `relay_verifier.rb`, `relay_watcher.rb` | Child sessions a session starts and waits on, and their approval relay ([sessions.md](sessions.md#delegating)). |
727
751
  | send / broadcast | `send_command.rb` (`SendCommand`), `broadcast_command.rb` (`BroadcastCommand`), `broadcast/` | `chi send` and `chi broadcast` ([broadcast.md](broadcast.md), [sub-agent.md](sub-agent.md)). |
752
+ | Worker link | `worker_link.rb` (`WorkerLink`: `Live`, `Starting`, `Leaving`, `Skew`, `Stale`, `None`), `worker_sidecar.rb` (`WorkerSidecar`) | Whether a session's worker can be reached and the client for it: every Bridge client asks `WorkerLink.resolve` / `.client` / `.await` (*Worker link* above); `WorkerSidecar` is the `bridge.json` it reads. |
728
753
  | Session access | `session_access.rb` (`SessionAccess`: `NewSession`, `SetupWords`, `WaitStart`, `SessionView`, `Created`, `ContextEntry`, `ContextPush`, the errors `RemoteFailed`, `RemoteTooOld`, `Refused`, `BadRequest`), `session_access/local.rb` (`SessionAccess::Local`), `session_access/remote.rb` (`SessionAccess::Remote`), `session_access/http_transport.rb`, `session_access/web_bridge_client.rb` (`SessionAccess::WebBridgeClient`, a remote attach's worker through chi web), `session_access/dispatch.rb` (`SessionAccess::Dispatch`), `session_access/contract.rb` (`SessionAccess::Contract`), `remotes.rb` (`Remotes`), `remote_command.rb` (`chi remote`), `web/session_access_routes.rb` (`Web::SessionAccessRoutes`) | The seam between the session commands and where the sessions live: `Local` is this machine's (the `SessionManager`, `ReplyWait`, `NoteDelivery`, `ContextSources` and Bridge calls the commands made); `Remote` is another machine's, the same methods as requests to its chi web's routes of the same name (Bearer token, `HTTPTransport`), results as Local's value objects and refusals as the classes Local's callees raise, built from the fields chi web sends; waits and prefixes run on the server, a wait asked again per 30 s slice. `Dispatch` is bin/chi's front for `--remote NAME` / `CHI_REMOTE` (taken off before the subcommand lookup): it refuses what the contract table lists `local_only` (exit 2, one message) and runs the command with a `Remote` on the `Remotes` entry (`$XDG_CONFIG_HOME/samagotchi/remotes.yml`, 0600). chi web's routes answer from `Local` (*chi web's routes for the session commands* above). Checks that read this machine's config or files (vision, parent answers, the cwd's project, `SAMAGOTCHI_PARENT_SESSION`) stay in the commands and are skipped under a `Remote`, where chi web's create, turn and wait routes run the vision, parent-answers and busy checks with the server's config and the worker checks a parent's answer. See [internals/remote-sessions.md](internals/remote-sessions.md). |
729
754
  | Idle work | `idle_scheduler.rb` (`IdleScheduler`), `idle_recap.rb` (`IdleRecap`), `idle_reminders.rb` | One poller for an idle session's recap and reminders. |
730
755
  | Attached context | `context_sources.rb` (`ContextSources`), `context_poller.rb`, `context_absorber.rb`, `peer_sources.rb` (`PeerSources`: the sources peer bundles declare) | External sources a session keeps fresh, and the peer loop's wakes ([context.md](context.md)). |
@@ -130,7 +130,8 @@ class, a hand-built `ctx`), that is a gap in the kit: say so to the user rather
130
130
  after the last edit.
131
131
  - **requires_chi.** Use the chi the user runs (`chi --version`). `check` warns when the plugin calls something
132
132
  newer than the floor (`ctx.http` needs 0.80.0, `ctx.cost` 0.83.0, `ctx.usage` 0.100.0, `ctx.sessions.children` 0.34.0). A keyword added
133
- later it can't see: `ctx.ask_model(model:)` needs 0.80.0, `ctx.sessions.send(delivery:)` 0.64.0.
133
+ later counts too (`ctx.ask_model(model:)` 0.80.0, `ctx.sessions.send(delivery:)` 0.64.0, `chi.toolkit(prompt:)`
134
+ 0.72.0, `chi.toolkit(groups:)` 0.121.0), when the call is written on `ctx` / `chi` itself (not through a variable).
134
135
  - **trust_level.** Leave it out. `reviewed` / `trusted` are for someone who reviewed the bundle, not its author.
135
136
  - **Restart.** A session loads bundles when it starts: after an install or a settings change, the user starts a
136
137
  new session (or `chi sessions restart ID`).
data/docs/cli.md CHANGED
@@ -518,10 +518,16 @@ chi installed, without stopping it: an attached terminal and the web's tabs
518
518
  move to the new worker. It is refused, with the reason, while something would
519
519
  be lost: a turn running or queued, a question or approval waiting, reminders,
520
520
  a `/btw` still running, an approval relayed from a delegate, or background
521
- tasks the session started. A session with no running worker needs none (its
522
- next prompt starts one on the newest chi), and a worker from before restarts
523
- needs `chi sessions stop ID`. The Web UI's `POST /api/sessions/:id/restart`
524
- does the same (409 with `reason` and `detail` when it can't). An attached
521
+ tasks the session started. If the worker cannot be asked (a refused Bridge,
522
+ protocol mismatch or stuck start), chi sends it TERM and resumes only when
523
+ disk shows no running turn, pending question or approval, queued prompt, or
524
+ running background task; otherwise it says to run `chi sessions stop ID`, then
525
+ send any prompt. That fallback may lose reminders, a running `/btw`, and other
526
+ in-memory state. A session with no running worker needs no restart (its next
527
+ prompt starts one on the newest chi), and an older worker without restart
528
+ support needs `chi sessions stop ID`. The Web UI's
529
+ `POST /api/sessions/:id/restart` does the same (409 with `reason` and `detail`
530
+ when it can't). An attached
525
531
  terminal says at the join, in one `chi>` line, when the worker or the terminal
526
532
  runs an older chi than the newest installed (the worker moves with `chi
527
533
  sessions restart ID`, the terminal with `/detach` and `chi --attach ID`), and
@@ -1418,8 +1418,12 @@ toolkit:
1418
1418
  (any other key, a typo or a `tools:` list, is ignored with a warning); it can't reuse a built-in's name
1419
1419
  (`review`'s least of all: it is the participants' boundary). A bundle declares its own with `chi.toolkit`
1420
1420
  ([plugins](plugins.md#chitoolkitname-base-add--remove--prompt-nil-groups-nil)).
1421
- - **Tool-index groups.** `groups:` selects source groups by name or glob (`chrome`, `db*`); for the MCP bundle
1422
- each group is a server. Selectors are sorted and deduplicated and match the groups declared when plugins load.
1421
+ - **Tool-index groups.** `groups:` selects source groups by name or glob (`chrome`, `db*`; any case, `{a,b}` braces,
1422
+ as the guardrails rules' `models:`); for the MCP bundle each group is a server. Anything but a name or a list (a
1423
+ map such as `{chrome: true}`) refuses every new session on that kit or on a kit built on it, on every surface (chi,
1424
+ `chi send --new`, chi web, `delegate`, a continue or a fork): `toolkit.kits.NAME: groups must be a list of group
1425
+ names or globs; fix config.yml to start a session on toolkit KIT`. A session already saved on it keeps running,
1426
+ its kit reaching no group (with a `toolkit>` warning) until config.yml is fixed. Selectors are sorted and deduplicated and match the groups declared when plugins load.
1423
1427
  An omitted key inherits the base's selectors; `full` and `lean` (and `child`, which is on `lean`) reach all
1424
1428
  groups, `review` (the participants' kit) reaches none, `groups: ["*"]` matches every group, and `groups: []`
1425
1429
  reaches none. A kit whose base doesn't resolve (an unknown name, a cycle) falls back to `full`, which reaches
@@ -1947,7 +1951,7 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
1947
1951
  | `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). |
1948
1952
  | `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). |
1949
1953
  | `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). |
1950
- | `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). |
1954
+ | `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-experimental)). An unknown value warns and is `off`. Experimental. config.yml only. See [Guardrails](guardrails.md#system-1-experimental). |
1951
1955
  | `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. |
1952
1956
  | `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. |
1953
1957
  | `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. |
data/docs/guardrails.md CHANGED
@@ -243,6 +243,16 @@ with its `reason`; `chi answer` exits 1 with the line), checked against the
243
243
  question pending at that moment. Answers from the web and an attached
244
244
  terminal keep every option.
245
245
 
246
+ If `chi answer` reaches a worker that refuses its Bridge secret (`403
247
+ bridge_secret`), it prints a session-specific recovery message rather than
248
+ treating that as a refused answer: `"session ID's worker refused this chi's
249
+ request (it didn't name its secret): a chi web or terminal started before chi
250
+ was updated can't reach it; restart chi web, re-attach with chi --attach ID,
251
+ or restart the worker with chi sessions restart ID"`. `chi sessions restart
252
+ ID` can also recover when the worker cannot be asked, but only if disk shows
253
+ no running turn, pending question or approval, queued prompt, or running
254
+ background task; when one exists, stop it explicitly and then send a prompt.
255
+
246
256
  A parent may also type its answer into chi itself: a piped `chi --attach ID`
247
257
  or `printf '2\n' | chi --no-shared -p …`. So chi treats an answer as a
248
258
  parent's, held to the same setting, when its input isn't a terminal or an
@@ -662,8 +672,8 @@ With a client, `GuardrailWiring#request_approval` asks System 1 one `noul`
662
672
  question, `allow`, before the user is asked. Its instruction is a constant
663
673
  in code (`SystemOne::ApprovalQuestion::INSTRUCTIONS`), reviewed like a
664
674
  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
675
+ reason, the tool, the command (or the paths) cut to 2000 characters
676
+ (`ApprovalQuestion::TEXT_CHARS`), the edit's diff, the cwd, the repo and branch, and the session's title when it
667
677
  has one. A memory ask also names what it is about: the entry and its scope,
668
678
  and for a removal the description that entry's index line carries
669
679
  (`Targets#memory`, read into the card by `Guardrails::Approval`): a path
@@ -680,11 +690,13 @@ Each judgment is a line in `judgments.jsonl`, beside `approvals.json` in
680
690
  `$XDG_STATE_HOME/samagotchi/guardrails/` (`SystemOne::Log`): 0600, appended
681
691
  under an exclusive `flock` (several workers write it), and rewritten —
682
692
  keeping the newest half of its lines — once it passes 5 MB. A line is never
683
- rewritten to add its label:
693
+ rewritten to add its label. Its `call` is shorter than the state's: cut to 300
694
+ characters (`ApprovalQuestion::CALL_CHARS`), with `call_digest` the SHA256 of
695
+ the whole call:
684
696
 
685
697
  ```json
686
698
  {"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"}
699
+ {"kind":"label","id":"…","at":"…","answer":"allow_once","question_id":"…"}
688
700
  ```
689
701
 
690
702
  `decision` is what the judgment led to: `audit` (audit mode), `asked` (on
@@ -692,7 +704,9 @@ mode, the user was asked), `auto` (on mode, System 1 answered for the user).
692
704
  A judgment that never arrived is logged too, with `p` null, so the failures
693
705
  count as well. The label line, keyed by the judgment's id, is what the user
694
706
  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
707
+ `none` for an ask that was cancelled. A live label carries the card's
708
+ `question_id` when the user answered (the judgment is written before the card
709
+ exists), so a replay knows the ask was judged. **A parent agent's answer is not the
696
710
  user's: its allow labels nothing. Its deny is a label only where the parent
697
711
  could have allowed instead** — the ask is not `user_only` and the session's
698
712
  parent level permits a permit (`Guardrails::ParentPolicy`); a parent that was
@@ -704,6 +718,10 @@ their last label.
704
718
  Everything about the log is best-effort: a write that fails is one
705
719
  `Log.warn(:guardrails, "system_one_log_failed", …)` and the approval goes on
706
720
  as if System 1 were not there.
721
+ A turn stopped while the judge is asked is not a failed judgment: the cancel
722
+ (`LLM::RequestCancelled`) goes through `SystemOne::Client#ask`, no line is
723
+ written, and the call is denied without a card; a turn that stopped as the
724
+ judgment came back opens no card either.
707
725
 
708
726
  `ruby script/system-one-probe HOST [MODEL]` asks three fixed approval-like
709
727
  questions through the client and prints each probability and the latency of
@@ -722,13 +740,18 @@ gate below passes, in this order:
722
740
  questions — `chi-answer-piped`, `chi-answer-http`, `chi-broadcast` — which
723
741
  are never auto-allowed, even when listed
724
742
  (`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
743
+ 4. the call does not change chi itself (`SystemOne::SelfProtect.refuse?`), by
744
+ the same test that makes an ask one only the user may answer
745
+ (`ParentApprovals.chi_files?`): no edit or write whose paths are in chi's
746
+ config dir (config.yml), the hooks dir, the installed bundles dir, the
747
+ guardrails store (where `approvals.json` and `judgments.jsonl` live) or the
748
+ attached context dir, and no `execute` or `task_create` command naming any
749
+ of them, however it spells them (`ParentApprovals::CHI_TEXT`:
750
+ `$XDG_CONFIG_HOME/samagotchi/config.yml`, `~/.config/samagotchi/…`, a
751
+ `cd ~/.config` then `samagotchi/config.yml`). The memories dir is
752
+ deliberately not one of these: reading or appending a memory file can be
753
+ auto-allowed, unless the command has a `..` segment (`memories/../config.yml`
754
+ climbs out of it). A protected rule
732
755
  (`Verdict::PROTECTED_RULES`) may be listed by name and System 1 may answer
733
756
  it, but a call that really writes config.yml or the hooks is refused here;
734
757
  5. the judgment's `p` is at least `guardrails.system_one.threshold` (0.8);
@@ -744,11 +767,14 @@ Then the judgment's `decision` is `auto`, the verdict settles as
744
767
  written to `approvals.json` — the next call is judged again. **No question is
745
768
  opened, so no card appears, no `:question_asked` hook fires, and no
746
769
  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.
770
+ `note: "auto-allowed by System 1 (p=0.97)"`, `judgment_id` and `p`, and the
771
+ call's tool row shows it: the terminal's tool line ends in
772
+ `· auto-allowed (System 1, p=0.97)`
773
+ (`TerminalUI::Formatting#format_tool_guardrail_suffix`, live and when
774
+ attaching to a saved turn), and the web's activity row carries
775
+ **auto-allowed · System 1** with `rule …, p=…, allowed once` on hover and the
776
+ owner's **should have asked** button (below). The stage trail shows no mark.
777
+ If the judgment line cannot be written to the log, the ask goes to the user.
752
778
 
753
779
  A delegate child answers from its own session's settings: its System 1 can
754
780
  auto-allow a call even when the child's `parent_answers: hard` bars a parent
@@ -778,8 +804,11 @@ whose `pending_question.approval` is there and that have an `answer` or were
778
804
  `cancelled`. A label is the scope the user took — `allow_once` for "Allow once",
779
805
  `allow_wide` for a wider one, `deny` for anything else, `none` for a cancelled
780
806
  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
807
+ approval of its own, a question id a previous replay already logged — so
808
+ running it twice adds nothing — and an ask already judged live (audit or on
809
+ mode): by the `question_id` its label carries, or, for a live judgment with
810
+ none (a cancelled ask, an older line), by its session and `call_digest`, one
811
+ card per such line. A parent agent's answer is not the user's: its
783
812
  allow is skipped, and its deny is replayed only where the parent could have
784
813
  allowed (the card is not `user_only` and the session's parent level permits a
785
814
  permit). A cards file that doesn't parse is skipped with one warning line,
@@ -130,7 +130,7 @@ gets a 400 `messages.N.content.M: Invalid signature in thinking block. The block
130
130
  conversation.`, and its last sentence says what changed ("The `system` prompt differs …"). chi's history is
131
131
  append-only within a process, so what changes the prefix is:
132
132
 
133
- 1. **A system-prompt rebuild** (`SystemPrompt#build` is cached per process and key; `PromptBuilder#reset!` drops it):
133
+ 1. **A system-prompt rebuild** (`SystemPrompt#build` is cached per process and key; `SystemPrompt#reset!` drops it):
134
134
  a new process (`--resume`, a respawn after `session.idle_exit_minutes`, a crash) after a memory index line, an
135
135
  AGENTS.md, an appended file, a model note or a bundle memory changed; `/model` (the tail's `Model:` line, its
136
136
  overlays and notes); `/thinking` (another key); a toolkit or profile change; a plugin's `tools_changed!`; the
@@ -31,7 +31,7 @@ Behavior:
31
31
  - `task_get` returns metadata and `output_path`; use `read` for output contents.
32
32
  - `task_wait` accepts `timeout`, `tail_lines` (maximum 100), and `done_pattern` (a regular expression string).
33
33
  - `task_create` accepts `env` as a JSON object string for deterministic overrides such as `PATH`; use an absolute interpreter path when that is simpler. Ruby/Bundler isolation variables remain protected.
34
- - `execute` and `task_create` export chi's own markers into the command, set over the model's `env` (which can't set them: `Error: env key is reserved`): `SAMAGOTCHI_PARENT_SESSION` (the session's id, `chi` without one; a chi started there answers as a parent agent), `SAMAGOTCHI_SESSION_MODEL` (the resolved ref the session runs on, read at each call so it follows `/model`; unset when unknown) `SAMAGOTCHI_SESSION_THINKING` (the session's own thinking level; unset when it has none) and `SAMAGOTCHI_SESSION_REASONING_HISTORY` (its own reasoning_history level, the same way). `chi self` reads the last three for its model, thinking level and reasoning history. The four are `TaskRuntime::MARKER_ENV_KEYS` (and `SessionManager::SESSION_MARKER_ENV`, which a spawned worker unsets). A session's worker never inherits them from whoever started it (a `chi send` run by another session's `execute`): its other commands (a user's `!cmd`, an MCP server, a context command) have none.
34
+ - `execute` and `task_create` export chi's own markers into the command, set over the model's `env` (which can't set them: `Error: env key is reserved`): `SAMAGOTCHI_PARENT_SESSION` (the session's id, `chi` without one; a chi started there answers as a parent agent), `SAMAGOTCHI_SESSION_MODEL` (the resolved ref the session runs on, read at each call so it follows `/model`; unset when unknown) `SAMAGOTCHI_SESSION_THINKING` (the session's own thinking level; unset when it has none) and `SAMAGOTCHI_SESSION_REASONING_HISTORY` (its own reasoning_history level, the same way). `chi self` reads the last three for its model, thinking level and reasoning history. The four are `ChildEnv::SESSION_MARKERS` (`TaskRuntime::MARKER_ENV_KEYS`), which a spawned worker unsets (`ChildEnv.unset`, merged into its spawn env). A session's worker never inherits them from whoever started it (a `chi send` run by another session's `execute`): its other commands (a user's `!cmd`, an MCP server, a context command) have none.
35
35
  - They also pass on a set `XDG_STATE_HOME` / `XDG_CONFIG_HOME` made absolute (`Builtins.xdg_absolute`, via `Paths`), so a chi the command runs in another `cwd` (`chi send`) reads this chi's state and config. These aren't reserved: an XDG dir the model sets in `task_create`'s `env` stays its own.
36
36
 
37
37
  ## Finish wake
@@ -187,7 +187,9 @@ answers 429 `too_many_turns` past it; with no worker, their waiting input files
187
187
  written, under the session dir's `participants.lock` (`SessionManager.deliver_turn`), so two at once can't both
188
188
  pass. The ack is 202 with
189
189
  `delivery: "queue"`; chi web keeps enqueued id → grant (the last 1000), which `dequeue` and `wait` check, falling
190
- back to a waiting file's author after a restart.
190
+ back after a restart to the author the session's files keep (`ParticipantAccess#turn_author`): the waiting or claimed
191
+ input file's, then `last_turn`'s or the settled record's (a refused turn's, `TurnCheckpoint.record_refused`; one whose
192
+ worker died, `TurnCheckpoint.settle`, from its checkpoint mark's `author`).
191
193
 
192
194
  **The worker.** A participant's input file waits for its own turn whatever its delivery says
193
195
  (`SessionInbox.waits_for_turn_end?`), so a drain never merges it into another author's turn; the Bridge queues their
@@ -289,11 +289,11 @@ bin/chi ── RunOptions.parse ── a remote, run flags ──> RemoteAttachC
289
289
  ### The plumbing
290
290
 
291
291
  **chi web's routes** (tabled in [architecture.md](../architecture.md)): `POST …/wake` (`Local#wake`: the live worker
292
- as is, else `SessionManager.resume_session` and `BridgeClient.wait_for`, at most `WAKE_MAX_WAIT` 10 s, the attach's
292
+ as is, else `SessionManager.resume_session` and `WorkerLink.await`, at most `WAKE_MAX_WAIT` 10 s, the attach's
293
293
  `BRIDGE_WAIT` here, after waiting for a worker still leaving to let go of its lock, `OwnerLock.await_release`: its
294
294
  recap on leave's timeout plus 5 s; a leftover scratch session is refused in `LaunchMode::SCRATCH_LEFTOVER`'s words, a late Bridge
295
- in `LaunchMode::BRIDGE_LATE`'s) and `GET …/worker` (`Local#worker`: live, the stop marker, the owner, `leaving` and `leave_seconds` when its worker
296
- is leaving, `WorkerState#leaving?`; it never starts one; it also names that machine's `default_model`), both answering the worker as `{epoch, started_at, version,
295
+ in `LaunchMode::BRIDGE_LATE`'s; a worker on a newer sidecar protocol is refused, 409 `worker_newer`, never resumed over) and `GET …/worker` (`Local#worker`: live, the stop marker, the owner, `leaving` and `leave_seconds` when its worker
296
+ is leaving, `WorkerState#leaving?`, `stuck` for a worker that never published its Bridge, `skew` for a newer protocol; it never starts one; it also names that machine's `default_model`), both answering the worker as `{epoch, started_at, version,
297
297
  features}`; `POST …/exit` (the worker's exit, `client_id` passed on only when it is a `tui:` one), `POST …/recap`,
298
298
  `GET …/stats` (the stats, else an older worker's state metrics), `GET …/children/counts` (`ChildrenStatus.counts`)
299
299
  and `GET …/turns/:turn_id/records` (`SessionMetrics`' saved tool records and turn record), every refusal with
@@ -309,7 +309,7 @@ builtin the worker lacks names it (`command`). `/api/info`'s feature `remote_att
309
309
  **`SessionAccess::WebBridgeClient`** (`session_access/web_bridge_client.rb`, built by `Remote#attach_client(id,
310
310
  worker:)` from a wake's or worker read's `worker`): `post_turn` (`no_interrupt`, `history: false`), `post_command`,
311
311
  `answer`, `dismiss_question`, `dequeue`, `cancel`, `request_exit`, `request_recap`, `stats`, `children_counts`,
312
- `turn_records`, `follow`, `connect_stream`, `session_id`, `worker_key`, `port` (nil). One-shots go through
312
+ `turn_records`, `follow`, `open_stream`, `session_id`, `worker_key`, `port` (nil). One-shots go through
313
313
  `Remote#route_request` (the server check, the auth and missing-route mapping), a new connection each, with a 45 s
314
314
  read timeout (chi web's 30 s Bridge read plus its wait for a waking Bridge; 75 s for answer and dismiss, which chi web
315
315
  sends twice on a 408; 5 s, `JOIN_READ_TIMEOUT`, for the join's reads: the children counts and a turn's records, L9). Every reply goes through **one status mapper**,
@@ -318,6 +318,7 @@ sends twice on a 408; 5 s, `JOIN_READ_TIMEOUT`, for the join's reads: the childr
318
318
  | chi web's answer | What the loop gets |
319
319
  |---|---|
320
320
  | 503 `not_live`, 404 `not_found` | raises `Unreachable` (< `IOError`): "no live worker for session ID on NAME" (`NO_WORKER`) |
321
+ | 502 `bridge_secret` (stream) | `Refused(:bridge_secret)`; `EventStream` ends with `stream_closed bridge_secret` |
321
322
  | 504 (any) | `Response(408, {error: deadline_passed})`: the loop's late line |
322
323
  | 501 with `command` (`/command`) | `Response(400, {error: unknown_command, command})`: the loop names the command |
323
324
  | any other 501 | `Response(404, {error: not_found})`: the stale-worker line |
@@ -326,7 +327,7 @@ sends twice on a 408; 5 s, `JOIN_READ_TIMEOUT`, for the join's reads: the childr
326
327
  Every `RemoteFailed` (a transport failure, `RemoteTooOld`, a 401 or a 403 `forbidden`) becomes `Unreachable` with its
327
328
  message, so a loop that rescues `IOError` around Bridge calls never crashes on one mid-session.
328
329
 
329
- **The stream.** `connect_stream` writes `GET /api/sessions/:id/stream…` through `BridgeClient::StreamRequest` (the
330
+ **The stream.** `open_stream` writes `GET /api/sessions/:id/stream…` through `BridgeClient::StreamRequest` (the
330
331
  GET a local stream writes, with `Authorization: Bearer` and `Last-Event-ID`; `Host` is the entry's host and port).
331
332
  An IPv6 entry (`http://[::1]:4567`) connects on the bare address (`URI#hostname`, as `HTTPTransport` does) and keeps
332
333
  the brackets in `Host`.
@@ -335,7 +336,9 @@ SYN isn't waited on for the OS's minutes on every reconnect.
335
336
  chi web proxies the Bridge's bytes unchanged (under WEBrick a hijacked body is raw, `Connection: close`). No chunked
336
337
  decoding in v1: a `Transfer-Encoding: chunked` reply (a proxy in between) and an https entry are logged
337
338
  (`stream_refused`, `chunked_unsupported` / `tls_unsupported`) and read as unreachable; decoding and TLS plug into
338
- `StreamRequest`. `follow` starts `EventStream` with three options a local stream never sets (`BridgeClient#follow`
339
+ `StreamRequest`. `WebBridgeClient#open_stream` returns typed outcomes: chi web's 502 `bridge_secret` is a refusal
340
+ (`stream_closed bridge_secret`), 503 is unreachable and retried with rediscovery, and 404 is an unknown-session
341
+ refusal. `follow` starts `EventStream` with three options a local stream never sets (`BridgeClient#follow`
339
342
  passes none):
340
343
  - `idle_timeout:` 40 s (`STREAM_IDLE_TIMEOUT`: two of the Bridge's 15 s pings plus margin): no byte for that long is a
341
344
  half-open connection (Wi-Fi gone, a sleeping laptop), reconnected with the cursor.
@@ -343,8 +346,9 @@ passes none):
343
346
  `worker_changed {"port" => nil, "epoch" => new}` comes before it (the `reset` the new Bridge sends), and the
344
347
  client's `worker_key` is updated in place. A restart reaches the stream this way because `Bridge#stop` removes its
345
348
  sidecar before it ends the open streams: the reconnect finds no sidecar, chi web waits for the next worker's
346
- (`await_bridge_port`) and proxies the cursor to it. A sidecar still naming the dead port would get an empty 200
347
- (the proxy's connects refused), and the stream would move by rediscovery (`worker_changed`, then a `snapshot`).
349
+ (`await_worker_link`) and proxies the cursor to it. A sidecar still naming a dead port now gets 503 `not_live`;
350
+ a secret refusal gets 502 `bridge_secret`, and no refusal is sent as SSE frames. The stream moves by rediscovery
351
+ (`worker_changed`, then a `snapshot`).
348
352
  - `announce_retries:` `stream_retrying` once a reconnect fails and rediscovery neither moved the stream nor ended it
349
353
  (a stop prints its stopped line alone), `stream_resumed` once a stream answers again (a replay may bring no frame).
350
354
 
@@ -421,9 +425,9 @@ the closing line reads `stopped` as `unreachable`, the words it always had.
421
425
  TUI (`tui:PID` otherwise). `report_unanswered` takes the link's `strategy:` and `via:`.
422
426
  - The client the loop and `BridgeClient::EventStream` use: `follow`, `post_turn`, `post_command`, `answer`,
423
427
  `dismiss_question`, `dequeue`, `cancel`, `request_exit`, `request_recap`, `session_id`, and for the stream
424
- `connect_stream(query:, last_event_id:, timeout:)` and `worker_key` (a new worker is a client with another key;
425
- a Bridge's is its port). `BridgeClient::StreamRequest` writes the stream's GET (host, port, path, extra headers)
426
- and reads its headers, for a stream through chi web too.
428
+ `open_stream(query:, last_event_id:, header_timeout:, attempts:)` and `worker_key` (a new worker is a client with
429
+ another key; a Bridge's is its port). `EventStream` reads the opened socket; `BridgeClient::StreamRequest`
430
+ writes the stream's GET (host, port, path, extra headers) and reads its headers, for a stream through chi web too.
427
431
  - `RunOptions.parse(argv)` is bin/chi's one run-flag parser (`RunOptions.switches` lists its flags), so a remote
428
432
  branch can take the parsed options before bin/chi's local checks; `LaunchMode.attached_conflict` and
429
433
  `LaunchMode::SCRATCH_LEFTOVER` / `BRIDGE_LATE` are the words both would print.