samagotchi 0.102.0 → 0.104.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -1
  3. data/bin/chi +60 -191
  4. data/docs/architecture.md +67 -1
  5. data/docs/cli.md +190 -9
  6. data/docs/configuration.md +7 -1
  7. data/docs/guardrails.md +4 -2
  8. data/docs/internals/context-telemetry.md +6 -2
  9. data/docs/internals/engine.md +5 -2
  10. data/docs/internals/llm-context-forget.md +11 -1
  11. data/docs/internals/participants.md +137 -0
  12. data/docs/internals/remote-sessions.md +429 -0
  13. data/docs/internals/working-set.md +95 -11
  14. data/docs/plugins.md +33 -3
  15. data/docs/sessions.md +16 -7
  16. data/docs/sub-agent.md +16 -0
  17. data/docs/testing.md +68 -4
  18. data/lib/samagotchi/answer_command.rb +27 -25
  19. data/lib/samagotchi/bridge.rb +31 -7
  20. data/lib/samagotchi/bridge_client/event_stream.rb +86 -12
  21. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  22. data/lib/samagotchi/bridge_client.rb +26 -39
  23. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  25. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  27. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  28. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  30. data/lib/samagotchi/chi_invocation.rb +40 -0
  31. data/lib/samagotchi/cli/command.rb +3 -2
  32. data/lib/samagotchi/cli/flags.rb +5 -0
  33. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  34. data/lib/samagotchi/context_command.rb +80 -73
  35. data/lib/samagotchi/context_status.rb +15 -2
  36. data/lib/samagotchi/engine.rb +28 -14
  37. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  38. data/lib/samagotchi/idle_recap.rb +8 -2
  39. data/lib/samagotchi/idle_scheduler.rb +34 -9
  40. data/lib/samagotchi/kernel_loop.rb +21 -2
  41. data/lib/samagotchi/launch_mode.rb +26 -0
  42. data/lib/samagotchi/llm/chat_loop.rb +8 -1
  43. data/lib/samagotchi/llm_context_forget.rb +13 -6
  44. data/lib/samagotchi/note_command.rb +8 -7
  45. data/lib/samagotchi/owner_lock.rb +65 -5
  46. data/lib/samagotchi/parent_report.rb +55 -43
  47. data/lib/samagotchi/participants.rb +122 -0
  48. data/lib/samagotchi/remote_attach_command.rb +119 -0
  49. data/lib/samagotchi/remote_command.rb +170 -0
  50. data/lib/samagotchi/remotes.rb +130 -0
  51. data/lib/samagotchi/run_options.rb +245 -0
  52. data/lib/samagotchi/send_command/busy.rb +58 -0
  53. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  54. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  55. data/lib/samagotchi/send_command.rb +173 -108
  56. data/lib/samagotchi/session.rb +38 -9
  57. data/lib/samagotchi/session_access/contract.rb +167 -0
  58. data/lib/samagotchi/session_access/dispatch.rb +152 -0
  59. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  60. data/lib/samagotchi/session_access/local.rb +400 -0
  61. data/lib/samagotchi/session_access/remote.rb +734 -0
  62. data/lib/samagotchi/session_access/web_bridge_client.rb +236 -0
  63. data/lib/samagotchi/session_access.rb +223 -0
  64. data/lib/samagotchi/session_archive_command.rb +9 -5
  65. data/lib/samagotchi/session_commands.rb +20 -15
  66. data/lib/samagotchi/session_continue.rb +1 -1
  67. data/lib/samagotchi/session_delete_command.rb +8 -4
  68. data/lib/samagotchi/session_inbox.rb +18 -9
  69. data/lib/samagotchi/session_manager.rb +27 -6
  70. data/lib/samagotchi/session_setup.rb +11 -6
  71. data/lib/samagotchi/sessions_command.rb +36 -14
  72. data/lib/samagotchi/share_command.rb +174 -0
  73. data/lib/samagotchi/terminal_ui/attach_launcher.rb +66 -17
  74. data/lib/samagotchi/terminal_ui/attach_link.rb +114 -0
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +155 -87
  76. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  77. data/lib/samagotchi/terminal_ui/local_link.rb +135 -0
  78. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  79. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  80. data/lib/samagotchi/terminal_ui/version_lines.rb +28 -3
  81. data/lib/samagotchi/terminal_ui.rb +6 -3
  82. data/lib/samagotchi/testing/waiting.rb +92 -9
  83. data/lib/samagotchi/title_command.rb +9 -11
  84. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +530 -126
  87. data/lib/samagotchi/web/grants.rb +302 -0
  88. data/lib/samagotchi/web/participant_access.rb +286 -0
  89. data/lib/samagotchi/web/principal.rb +29 -0
  90. data/lib/samagotchi/web/public/data.js +3 -2
  91. data/lib/samagotchi/web/public/index.html +20 -4
  92. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  93. data/lib/samagotchi/web/public/stage_view.js +3 -2
  94. data/lib/samagotchi/web/public/stream_handlers.js +3 -1
  95. data/lib/samagotchi/web/public/working_set.js +159 -28
  96. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  97. data/lib/samagotchi/web/public/working_set_view.js +91 -33
  98. data/lib/samagotchi/web/server.rb +3 -1
  99. data/lib/samagotchi/web/session_access_routes.rb +459 -0
  100. data/lib/samagotchi/worker.rb +26 -5
  101. data/lib/samagotchi/worker_sidecar.rb +12 -5
  102. data/lib/samagotchi/working_set.rb +88 -14
  103. data/lib/samagotchi/working_set_command.rb +133 -30
  104. data/lib/samagotchi/working_set_pin.rb +34 -0
  105. data/lib/samagotchi/working_set_steer.rb +191 -27
  106. metadata +28 -1
data/docs/cli.md CHANGED
@@ -21,6 +21,10 @@
21
21
  - `chi context <add|push|ls|show|refresh|rm|mute|unmute> …` — attach live external text to sessions (a command chi runs every so often, or text pushed in); the model gets a short note when it changes and reads it with `context_read`; `chi context add <URL>` attaches a GitHub PR with the github-pr bundle (see [Attached context](context.md))
22
22
  - `chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...` — send a message to sessions as if typed there: a turn starts (or a running one takes it at its next step; `--cut` asks it to cut long thinking for it; `--queue` runs it as a turn of its own after the running one, and `--queue --wait` waits for that turn's own answer); piped stdin goes above `-m` as quoted context, and `--image` attaches images (see [Sessions: Sending a message](sessions.md#sending-a-message)); `--new` starts a session with it instead (`--new --continues ID|last:ID` the next link of that session's chain, see [Session chains](sessions.md#session-chains)), and `--wait` prints the answer (`--wait ID` with no message waits for the next reply without sending — to an idle session whose last turn already ended, that ended turn's reply or end comes back; `--wait --turn T ID` with no message waits for exactly turn T, whether running, queued or last ended; `--format json` includes its `turn_id` when known, for waiting again after exit 3 or 4; exit 3 a question waits, its options on stderr; exit 4 `--timeout` passed with the turn still running; see [Starting a session](sessions.md#starting-a-session))
23
23
  - `chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--format json]` (or `--dismiss`) — answer the question a session waits on (the one `chi send --wait` exited 3 with), then wait and print what comes next as `chi send --wait` does: the reply (0), the next question (3), still running after `--timeout` (4). `--option` is 1-based or the label, repeated on a multi-select question; `--text` is free text, or a Deny's reason; `--dismiss` leaves it unanswered and the model finishes its reply. A question no longer open (the web answered first) isn't answered again: it waits for that turn's reply. An option the question doesn't offer exits 2; a worker that is gone exits 1 (`send the task again: chi send --wait -m "…" ID`). What a parent may pick follows `guardrails.parent_answers`, or the session's own `parent_answers` when stricter (`soft`, the default: a deny, a plain answer, Continue and "Allow once"; `standard`: no allow; `hard`: no Continue either), never a wider scope or an allow on chi's own config; a refused pick exits 1 with what to answer instead (a convention, not a security boundary; see [Guardrails](guardrails.md#answers-from-a-parent-agent))
24
+ - `chi remote add NAME URL [--token-file F | --token-stdin]`, `chi remote ls`, `chi remote rm NAME` — the other machines whose chi web the session commands reach with `--remote NAME` (see [Remote sessions](#remote-sessions))
25
+ - `chi share ID --name NAME [--agent] [--expires 3d]`, `chi share ls [ID]`, `chi share revoke ID [P] | --all` — invite a participant to review a session through chi web on the LAN (a link of their own, 7 days), list them, revoke them (see [Participants](#participants))
26
+ - `chi --remote NAME send|answer|note|context|sessions|share …` (or `CHI_REMOTE=NAME`) — those commands on that machine's sessions, through its chi web (see [Remote sessions](#remote-sessions))
27
+ - `chi --remote NAME --attach ID` (`--resume ID`, `--dir DIR [-p TEXT]`) — the attached TUI on a session of that machine (see [Remote sessions](#remote-sessions))
24
28
  - `chi memory ls [--scope project|system] [--format text|json]` — the memories of both scopes (this folder's project first), one row each: scope, name, size, date, `from <bundle>`, the index description and its model overlays; `chi memory rm NAME [--scope project|system]` removes one as `memory_write` with `remove: true` does: its file and model overlays go to the bundle trash (the line names the dir; `chi bundle trash` lists and empties it) and its index line is dropped. NAME in both scopes without `--scope` exits 2 naming both; a missing NAME or a bundle's memory (`chi bundle uninstall` removes it) exits 1 (see [Memory: Listing and removing from the shell](memory.md#listing-and-removing-from-the-shell))
25
29
  - `chi desktop install|upgrade|uninstall|status` — the macOS "Send to chi" helper: a Service and a ⌃⌥⌘N hotkey that send text or images to a session (live, stopped or new) as a message (⏎, a turn runs) or a context note (⌘⏎) (see [Desktop helper](desktop.md))
26
30
  - `chi models [--format text|json] [--timeout S] [TEXT]` — list the models every configured host offers, as the names `--model` takes (see [Listing the models](#listing-the-models))
@@ -278,6 +282,8 @@ controls exit behavior (`--non-interactive`); `--resume` composes with both.
278
282
  | `--shared` | Run the session (new, or `--resume`'s) in a background worker and attach to it: the default, and the way to get it when `session.shared` is off. See [Sharing a session](#sharing-a-session). |
279
283
  | `--no-shared` | Run the plain in-process REPL for this run. |
280
284
  | `--attach SESSION_ID` | Attach to a session's worker, waking one if it has exited. |
285
+ | `--remote NAME` | Before a session command (`chi --remote studio send …`) or the run flags (`chi --remote studio --attach ID`, `--resume ID`, `--dir DIR -p TEXT`): run it against that remote's sessions, through its chi web; beats `CHI_REMOTE`. Commands and flags that run on this machine only exit 2. See [Remote sessions](#remote-sessions). |
286
+ | `--dir DIR` | With `--remote`: a new session's folder on that machine, its absolute path (required for a new session there). Without `--remote` it exits 2: cd there instead. |
281
287
  | `--model NAME` | Use this model for the run (overrides the configured default and a resumed session's model). |
282
288
  | `--thinking LEVEL` | This session's own thinking level: `off`, `low`, `medium` or `high`, saved in the session and first in the order (`default` unsets it). On `--resume` or `--attach` it is set at once, through a `/thinking` on an attached worker. With `chi web` it is instead the default for the sessions its workers run, below a session's own (as env `SAMAGOTCHI_THINKING_LEVEL` is). See "Thinking" in configuration.md. |
283
289
  | `--toolkit NAME`, `--tool NAME`, `--no-tool NAME` | A new session's toolkit, the tools its prompts declare: `full` (every built-in), `lean`, `child`, one of config's `toolkit.kits` or a bundle's (without `--toolkit`: config's `toolkit.default`), with tools declared on top of it (`--tool`) or left out (`--no-tool`); both repeatable, a comma list too. Saved in the session as given; a respawned worker, a fork and a continue keep it. Ignored with `--attach`/`--resume` (one line, as `--memory`); `/toolkit` changes it. Not on `chi web` (config's `toolkit.default` covers its sessions). See [Toolkits](configuration.md#toolkits). |
@@ -611,16 +617,39 @@ KEY…` takes an item out of what the model is sent: every read of it still sent
611
617
  goes as a stub, `(dropped by the user) — read it again if the task needs it`,
612
618
  whatever the LLM context strategy (none too), and stays dropped across a
613
619
  strategy switch. KEY is a path (absolute or the session folder's), `memory:NAME`
614
- or a URL. `/working-set undrop KEY…` sends it whole again. A change applies
620
+ or a URL; quote one with spaces (`"my notes/a b.md"`, as the listing shows it). `/working-set undrop KEY…` sends it whole again. A change applies
615
621
  from the next turn's first request, which also carries one line telling the
616
622
  model what you dropped or brought back (so it doesn't read it again by reflex);
617
623
  the reply says what it frees and the tail the server reads again (a drop changes
618
624
  history, so the prompt cache breaks there once). An undrop before that request
619
625
  undoes the drop for free. Refused, with why: an item only edited or written
620
626
  (nothing of it to drop), one not in the set, an older session's output chi
621
- can't name. The model can't undo a drop (`forget_outputs` refuses it).
622
- `/working-set` alone answers mid-turn too; `drop`/`undrop` sent while a turn
623
- runs wait for the turn's end.
627
+ can't name. The model can't undo a drop (`forget_outputs` refuses it). One
628
+ `memory_read` of several names is one output: dropping, undropping or pinning
629
+ one name acts on that read whole, so on the others too, and the reply names
630
+ them (`; the same memory_read holds memory tone: dropped with it`).
631
+ `/working-set pin KEY…` keeps an item from the model's `forget_outputs` (the
632
+ [forget layer](configuration.md#llm-context-the-forget-layer)): it refuses every
633
+ read of it, those to come too, until `/working-set unpin KEY…`. A pin sends
634
+ nothing different (no cache break) and is saved with the session, so a respawn,
635
+ a continue and a fork keep it, across a strategy switch too; under a strategy
636
+ without forget it waits until one has it (the reply says so). The listing marks
637
+ it `pinned`, and under forget the next turn's line tells the model. A pinned
638
+ item can't be dropped until you unpin it; a pin takes back a drop not yet sent
639
+ (free), and asks you to undrop one already sent first.
640
+ `/working-set add KEY…` brings an item in: the next turn's line asks the
641
+ model to load it before anything else (`read` for a file, `memory_read` for
642
+ `memory:NAME`, `web_fetch` for an http(s) URL), so it costs the model one tool
643
+ call, and what it reads is an ordinary read (droppable, forgettable, in the
644
+ set). Nothing is sent until then, and `/working-set unadd KEY…` takes an add
645
+ back for free. On a stale or forgotten item it is a refresh (the model reads
646
+ it again). Refused, with why: a file that isn't there (or a directory), a
647
+ memory neither scope holds, a URL that isn't http(s), an item the model is
648
+ sent whole now, a dropped one (undrop it instead), one already added. The
649
+ listing's last line names the pending adds (`added lib/tax.rb`). The model is
650
+ asked, not forced: a model may skip the read.
651
+ `/working-set` alone answers mid-turn too; `drop`/`undrop`/`pin`/`unpin`/`add`/`unadd`
652
+ sent while a turn runs wait for the turn's end.
624
653
 
625
654
  `/thinking` shows the thinking level the next turn runs at and where it came
626
655
  from (`session`, `--thinking` / `SAMAGOTCHI_THINKING_LEVEL`, `models: <key>`,
@@ -724,12 +753,14 @@ REPL alike:
724
753
  with images works the same way: it waits for the turn's end, and the lines
725
754
  sent after it still go in at the next step.
726
755
  At the open prompt (no turn running) `/cut TEXT` and `/queue TEXT` run TEXT
727
- as a normal prompt.
756
+ as a normal prompt, and the terminals say so: `(nothing is running: sent now)`.
728
757
  - `/unqueue` (attached terminal) takes back the newest line this terminal
729
758
  queued that hasn't started: `removed: <its first 60 characters>`, and its text
730
759
  goes back into the input to change and send again (not over typed text: then
731
760
  it is in the input history, ↑; not with input from a pipe; its images don't
732
- come back), then the one before it on the next `/unqueue`; `(nothing queued: …)` when none waits,
761
+ come back), then the one before it on the next `/unqueue`. A command typed
762
+ after that text without clearing it (`fix it/unqueue`, `fix it/queue more`)
763
+ runs as the command, and the text stays in the input. `(nothing queued: …)` when none waits,
733
764
  `(not removed: it already started)` when its turn won the race, `(not removed:
734
765
  it is already gone)` when another client removed it. Other terminals that
735
766
  showed it say `(a queued message was removed)`; the web drops its bubble. A
@@ -1009,13 +1040,26 @@ turn view ignores it.
1009
1040
  scope, `prompt` for a preloaded memory) that goes to the call. A file opens
1010
1041
  as its row's title does, a URL in a new tab. A row (on hover or keyboard
1011
1042
  focus) and a folded group offer `drop`, which runs `/working-set drop` (see
1012
- `/working-set` above); a dropped row offers `undrop`. Between turns it
1043
+ `/working-set` above); a dropped row offers `undrop`, and so does one whose
1044
+ drop holds on some reads only (`part dropped` in `/working-set`'s listing:
1045
+ an older read stubbed by the stale layer, or a newer read sent). Between turns it
1013
1046
  applies at once; while a turn runs it waits for the turn's end: the row is
1014
1047
  dashed with `drops next turn` and an `undo`, and the list's foot counts the
1015
- pending changes (`undo all`), in every tab and after a reload. On a phone a
1048
+ pending changes (`undo all`), in every tab and after a reload; the stage's
1049
+ rail dashes the item's line too (its tooltip says `drops next turn`). On a phone a
1016
1050
  tap on a row opens its actions under it. `drop` is greyed, with why on
1017
1051
  hover, for an item only edited, a memory only preloaded, or one already
1018
- forgotten.
1052
+ forgotten. Under a strategy with forget a row also offers `pin`
1053
+ (`/working-set pin`, pending as `applies next turn` mid-turn); a pinned row
1054
+ shows `📌` and `unpin` (under any strategy), and its `drop` is greyed until
1055
+ you unpin it.
1056
+ The list's foot (a phone's last row) takes a path, `memory:NAME` or a URL
1057
+ and runs `/working-set add`: the item shows as a dashed `+ name` row with
1058
+ `reads next turn` and an `undo` until the next turn starts (the model is then
1059
+ asked to read it, and it joins the set as the model reads it). A stale or
1060
+ forgotten row offers `refresh`, the same add of it. While a turn runs the
1061
+ stage's rail shows an add queued for its end as a dashed `+ name · next turn`
1062
+ line.
1019
1063
 
1020
1064
  Only `read`, `edit`, `write`, `memory_read`, `memory_write` and `web_fetch`
1021
1065
  calls that succeeded count; files an `execute` touched don't.
@@ -1031,6 +1075,8 @@ the same live, after a reload and in every tab:
1031
1075
  - **dropped** (struck): you dropped it (`/working-set drop`, the list's
1032
1076
  `drop`); its reads are sent as stubs under every strategy until you undrop
1033
1077
  it.
1078
+ - **pinned** (`📌`): you pinned it (`/working-set pin`, the list's `pin`);
1079
+ `forget_outputs` refuses it.
1034
1080
  - **forgotten** (struck): nothing of the item's content is still sent to the
1035
1081
  model under the session's current LLM context strategy (`forget_outputs`
1036
1082
  forgot every read of it; see
@@ -1113,6 +1159,141 @@ access token", paste the token there (the part of the link after
1113
1159
  - On http the browser has no notifications: the bell is hidden on the phone,
1114
1160
  and the tab title still counts what needs you.
1115
1161
 
1162
+ ### Remote sessions
1163
+
1164
+ The session commands also reach another machine's sessions, through its chi
1165
+ web on the LAN: the sessions, workers, memories and code stay there. On the
1166
+ other machine (the Studio), run `chi web --web-host lan`; on this one (the
1167
+ laptop), add it once with its token (the part of its LAN link after
1168
+ `token=`, or its `$XDG_STATE_HOME/samagotchi/web-token` file):
1169
+
1170
+ ```sh
1171
+ chi remote add studio http://192.168.1.29:4567 --token-file studio-token
1172
+ # studio http://192.168.1.29:4567 chi 0.103.0, LAN 192.168.1.29
1173
+ chi --remote studio send --new --dir /Users/me/projects/app --wait -m "fix the flaky spec"
1174
+ chi --remote studio answer ab12 --question q1 --option 1
1175
+ CHI_REMOTE=studio chi context push claude-outbox ab12 < answer.json
1176
+ ```
1177
+
1178
+ - `chi remote add NAME URL` checks the address and token first (`GET
1179
+ /api/info`) and says what it found: the chi it runs and its LAN address, or
1180
+ why not: nothing answers (start chi web there with `web.host: lan`), the
1181
+ token was refused, or a hostname (chi web answers its LAN IP only: use the
1182
+ IP, which changes with the Wi-Fi), or a chi too old for remote sessions.
1183
+ Nothing is saved then. The remotes live in
1184
+ `$XDG_CONFIG_HOME/samagotchi/remotes.yml` (0600), their tokens inside: a
1185
+ token runs commands as you there. `chi remote ls` (`--format json`) never
1186
+ prints one; `chi remote add` of an existing name replaces it.
1187
+ - `--remote NAME` goes before the command (`chi --remote studio note …`),
1188
+ `CHI_REMOTE=NAME` in the environment does the same for every command a
1189
+ script runs; the flag beats the variable, and there is no default remote.
1190
+ An unknown name exits 2.
1191
+ - What runs there: `send` (every form: `--new`, `--wait`, `--queue`,
1192
+ `--cut`, `--image`, a session command), `answer`, `note`, a session's
1193
+ `context add|push|ls|show|rm` and `sessions
1194
+ list|stop|restart|archive|unarchive|delete`. The rest runs on this machine
1195
+ only and exits 2 with `chi <command> runs on this machine only: run it on
1196
+ <remote>`: `context refresh|mute|unmute` and `--project`, `sessions
1197
+ stats|prune|clean`, `broadcast` and the machine's own commands (`bundle`,
1198
+ `memory`, `update`, …). `chi remote`, `--version` and `--help` always run
1199
+ here.
1200
+ - `send --new` needs `--dir`, that machine's absolute path (no folder of this
1201
+ one is guessed); `--toolkit`, `--set` and `--parent-answers` are checked
1202
+ against that machine's config and bundles. `sessions list` lists every
1203
+ project's sessions there (`--scope=project --cwd DIR` one project's) in the
1204
+ `--live` text form. `context` takes the session ids (no default session),
1205
+ and a URL resolves through that machine's bundles.
1206
+ - That machine's config.yml decides what it checks for `send` and `answer`:
1207
+ a parent agent's `/continue yes` and options (`guardrails.parent_answers`),
1208
+ a loosening `/parent-answers`, images to a model that can't see them. Each
1209
+ target refuses its own (`ab12 refused: …`), where this machine refuses
1210
+ before sending to any.
1211
+ - Waits run on the server, 30 s at a time, until the answer or your own
1212
+ `--timeout`; exit codes are the same. The commands chi prints for what to
1213
+ do next work as pasted: they carry `--remote NAME` (`chi --remote studio
1214
+ answer ab12 --question q1 --option N`, `chi --remote studio send --wait …`),
1215
+ under `CHI_REMOTE` too, an attach as well (`chi --remote studio --attach
1216
+ ab12 shows it`, `sessions stop`'s `chi --remote studio --resume ab12`).
1217
+ - A remote that can't be reached names itself and why (`chi send: remote
1218
+ studio (http://192.168.1.29:4567): connection refused`, exit 1; with
1219
+ `--format json` an `error` object).
1220
+ - Plain http, the same as the phone's: use it on your home network.
1221
+
1222
+ A session there in this terminal (the attached TUI, through that machine's
1223
+ chi web; its worker stays there):
1224
+
1225
+ ```sh
1226
+ chi --remote studio --attach ab12 # attach (wakes its worker if it exited)
1227
+ chi --remote studio --resume ab12 -p "go on" # resume there, run one turn, stay attached
1228
+ chi --remote studio --dir /Users/me/app -p "hi" # a new session there (--dir required), one turn, stay
1229
+ ```
1230
+
1231
+ - The same TUI and exit codes (0 detached, 3 a question left waiting with
1232
+ input from a pipe, 1 otherwise), every printed command with `--remote
1233
+ studio`. Prompts, session commands, `/model`, `!cmd` (a shell command **on
1234
+ that machine**, under its guardrails), `/queue`, `/cut`, `/unqueue`,
1235
+ `/detach`, `/exit`, `/exit --delete`, `/archive`, `/stats` and `/recap` work
1236
+ as here, on that machine's session (`/exit --delete` deletes it there).
1237
+ The status row's children segment counts its delegates there, and a join
1238
+ shows the steps' durations and a failed turn's line from its saved records
1239
+ there. A chi web too old for one of them says so in one line ("remote
1240
+ studio runs chi X, too old for this command through --remote: update chi
1241
+ there").
1242
+ - A new session needs `--dir`, that machine's absolute path; `--model`,
1243
+ `--thinking`, `--llm-context*`, `--toolkit`, `--tool`, `--no-tool`, `--set`
1244
+ and `--parent-answers` are checked there (exit 2 for a bad one). With
1245
+ `--attach`/`--resume` they work as here (`--model`, `--thinking`,
1246
+ `--llm-context*` change the session; the others are named as ignored).
1247
+ `--non-interactive` (use `chi --remote studio send --new --dir D -m TEXT
1248
+ --wait`), `--verbose`, `--no-shared`, `--memory`, `--mute` and
1249
+ `--append-system-prompt` run on this machine only (exit 2).
1250
+ - `@path` images are this machine's files (read and downscaled here, then
1251
+ uploaded); Tab completes `/` commands and `@path`, not memory names. The
1252
+ prompt history (↑) and `no_interrupt` / `no_default_input` are this
1253
+ terminal's.
1254
+ - When chi web can't be reached the TUI says "(lost the connection to
1255
+ studio; reconnecting…)" and tries for about a minute, then "(reconnected)";
1256
+ what it missed is replayed or resynced. A prompt typed meanwhile is usually
1257
+ accepted there and runs when a worker reads it. A worker that went away
1258
+ (not stopped) is woken again. The end is worded by why: "The session was
1259
+ stopped on studio. Resume it with: chi --remote studio --resume ab12", the
1260
+ token refused, chi web unreachable, the worker gone, the session deleted.
1261
+ - The version line names both machines: the worker older than what studio
1262
+ has installed (`chi --remote studio sessions restart ab12`), this terminal
1263
+ older than studio (`chi update` here).
1264
+
1265
+ How it works inside (`SessionAccess`, chi web's routes, where each check runs):
1266
+ [internals/remote-sessions.md](internals/remote-sessions.md).
1267
+
1268
+ ### Participants
1269
+
1270
+ A session's owner can invite **participants**, people or agents, to review it through chi web on the LAN
1271
+ (`web.host: lan`), each with a link of their own that reaches that one session:
1272
+
1273
+ ```sh
1274
+ chi share 3f9a1c2e --name Alex
1275
+ # p_7c01d2aa Alex (human, reviewer) · session 3f9a1c2e · expires 2026-10-30 14:02
1276
+ # http://192.168.1.29:4567/?token=chp_…#s=3f9a1c2e-…
1277
+ # (shown once: chi keeps only its digest; chi share revoke 3f9a1c2e p_7c01d2aa ends it)
1278
+ chi share ls # every session's participants (--format json; never a token)
1279
+ chi share revoke 3f9a1c2e # every link to that session; add a participant id for one
1280
+ chi share revoke --all # every link to every session
1281
+ ```
1282
+
1283
+ - The link is printed once (with a QR code at a terminal): chi keeps only a digest of its token. Send it to the
1284
+ participant; opening it sets their browser's cookie, as the owner's LAN link does. `--agent` marks an agent
1285
+ participant, which uses the token as `Authorization: Bearer` (e.g. `chi --remote` with that token).
1286
+ - It works for 7 days (`--expires Nh` or `Nd`, 1 hour to 30 days), until it is revoked, or until the session is
1287
+ archived or deleted. A revoked or expired link shows "This review link has expired or was revoked."
1288
+ - For now a participant sees a page with the session's title and status; comments and the review itself come in
1289
+ a later chi. They can't reach anything else: every other route answers them 403, another session 404.
1290
+ - `chi --remote studio share …` invites on studio: the link names studio's LAN address. Locally, the link names
1291
+ this machine's chi web; without one on the LAN, chi says the link opens on this machine only.
1292
+ - The owner's own token and the `chi web --new-token` rotation are separate: rotating it ends no participant's link.
1293
+
1294
+ How it works inside (grants, the gate, the routes, the audit trail):
1295
+ [internals/participants.md](internals/participants.md).
1296
+
1116
1297
  ## Runtime Model Switch (Assist Mode)
1117
1298
 
1118
1299
  In interactive assist mode, you can switch the request model without restarting:
@@ -1485,7 +1485,13 @@ models:
1485
1485
  forget keeps lines of it; a keep outside the output's lines, of a big file's head/tail preview, or holding all of
1486
1486
  it; an output too small for a stub to free anything; one already stubbed or forgotten; an unknown id, or one whose
1487
1487
  output chi can't pair with its call for sure (it shows no id). The system prompt, user messages, steers and context
1488
- notes have no ids.
1488
+ notes have no ids. An output you dropped, or one of an item you pinned, is refused too (`dropped by the user`,
1489
+ `pinned by the user: it stays in your context`).
1490
+ - **Pins.** `/working-set pin KEY…` (or the web list's `pin`, shown under forget only) keeps an item from the model's
1491
+ forget: every read of it, those to come too, until `/working-set unpin KEY…` (see [cli.md](cli.md#the-working-set)).
1492
+ It sends nothing different, so the prompt cache holds. The pins are the session's own (`working_set_pins` in the
1493
+ session file): a respawned worker, a continue and a fork keep them, across a strategy switch too; a delegate child
1494
+ starts without. Stale stubs still apply to a pinned file's superseded reads (its newer read is sent).
1489
1495
  - **When stubs reach the prompt** follows `llm_context.apply`, as `stale`'s do: the result says
1490
1496
  `applied, the next request sends the stubs (frees …)` or `staged until the turn ends`.
1491
1497
  - **Restore.** `forget_outputs(restore: ["t45"])` brings a forgotten output back with the next request, whatever
data/docs/guardrails.md CHANGED
@@ -582,7 +582,8 @@ hooks, approvals, bundles or attached context sources, or a `.git/hooks`
582
582
  (`shell-touches-chi`, `touches: chi_dirs`), unless they only read
583
583
  (`skip_read_only`); `chi context add … --cmd` (`chi-context-cmd`, once at a
584
584
  time: a command chi will run every few minutes outside the guardrails; see
585
- [Attached context](context.md#safety)); `chi broadcast` (`chi-broadcast`,
585
+ [Attached context](context.md#safety); the rule also sees `chi --remote NAME context add`, and a remote's `context push`
586
+ reaches its session with the trust of its chi web token, as your own `chi context push` does there); `chi broadcast` (`chi-broadcast`,
586
587
  once at a time and only by the user: it is the user's command, which also
587
588
  refuses to run inside a session; see [Broadcast](broadcast.md)); removing a
588
589
  memory with `memory_write remove: true` (`memory-remove`, once at a time:
@@ -594,7 +595,8 @@ only by the user: these change a model's system prompt for later sessions (see
594
595
  [Model notes](memory.md#model-notes); a shell command reaches them only under
595
596
  `shell-touches-chi`'s text heuristic); and answering chi's
596
597
  questions around `chi answer`: `chi --attach` or `chi -p` with stdin from a
597
- pipe, a here-string or a file, not `/dev/null` (`chi-answer-piped`), and `curl`/`wget` to a
598
+ pipe, a here-string or a file, not `/dev/null` (`chi-answer-piped`, which also sees
599
+ `chi --remote NAME --attach|--resume|-p` and `CHI_REMOTE=NAME chi …`), and `curl`/`wget` to a
598
600
  session's `/answer` route (`chi-answer-http`), both once or for the session, and only by the
599
601
  user (a parent agent allowing one would answer as the user).
600
602
  `chi answer` itself isn't asked about. It denies writes into `.git/hooks`.
@@ -12,8 +12,12 @@ resumed session's line already names):
12
12
 
13
13
  Both loops do this, before each request (`ContextStatus`, one per turn). The
14
14
  chat loop's estimate counts the conversation's text and tool calls, not the
15
- tool schemas it sends, until the host's first count (its `usage`). The event
16
- carries a `status` string using this prefix:
15
+ tool schemas it sends, until the host's first count (its `usage`). So that a
16
+ turn's first request doesn't dip below the last turn's count (the schemas are
17
+ thousands of tokens), the loop keeps the last counted request's chars per token
18
+ (`ContextStatus#counted_chars_per_token`, on `KernelLoop` / `ChatLoop`) and the
19
+ next turn's estimate before its first count uses it instead of
20
+ `context.chars_per_token`. The event carries a `status` string using this prefix:
17
21
 
18
22
  `CONTEXT_STATUS ...`
19
23
 
@@ -509,7 +509,7 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
509
509
  | `:question_requested` / `:question_answered` / `:question_cancelled` | `QuestionDesk`: a question is asked (an approval, `ask_user_question`, a hook's or plugin's `ask_user`, the step-limit Continue), answered, or withdrawn | `{pending_question}` / `{id, answer}` / `{id, reason}` |
510
510
  | `:plugin_init_wait` | a turn waits for plugin init tasks that provide tools (`PluginTasks`) | `{tasks: [{bundle, id, label}]}` |
511
511
  | `:used_memories_updated` | a memory was read, and after the loop | `{used_memory_names, read_names?}` |
512
- | `:working_set_updated` | after `:generation_started` (the running turn's conversation, the turn's strategy) and once in `end_turn` before the end event (the kept messages), only when the set differs from the last one emitted for the session and is not empty, except one empty event when the last one had items (a rollback emptied the set): a tool-free turn emits none (`Engine#emit_working_set`); between turns `Engine#announce_working_set` announces it under the next turn's strategy (after a `/working-set` op, held behind the command's `command_ran`) | `{items: [{kind, key, state (live\|stale\|forgotten\|dropped), tokens, ops, count, first_id}], at}` (`WorkingSet`, internals/working-set.md) |
512
+ | `:working_set_updated` | after `:generation_started` (the running turn's conversation, the turn's strategy) and once in `end_turn` before the end event (the kept messages), only when the set differs from the last one emitted for the session and is not empty, except one empty event when the last one had items (a rollback emptied the set): a tool-free turn emits none (`Engine#emit_working_set`); between turns `Engine#announce_working_set` announces it under the next turn's strategy (after a `/working-set` op, held behind the command's `command_ran`) | `{items: [{kind, key, state (live\|stale\|forgotten\|dropped), tokens, ops, count, first_id, pinned, has_drop}], at, adds?: [{kind, key, label}]}` (`WorkingSet`, internals/working-set.md; `adds` while the user's adds are pending) |
513
513
  | `:hook_notice` | a hook's or plugin's notify during a turn | `{hook, text, level}` (announced with `between_turns: true` outside a turn) |
514
514
  | `:guardrail_warning` | a guardrails line during a turn | `{message}` |
515
515
  | `:turn_completed` | normal end | Engine event `{result, turn_summary, display_pending}`; the Bridge's SSE frame omits `result` (answer remains in `turn_summary.output`) |
@@ -518,7 +518,10 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
518
518
  | `:answer_display` | after the after_turn hooks (observers only, not the sink) | `{display, refs?}` (display a string, or nil; refs the count of the answer's refs, only when a hook attached some) |
519
519
 
520
520
  The boundary events (`:turn_started`, `:turn_completed`, `:turn_canceled`, `:turn_failed`) also carry `origin`
521
- when the turn was queued with one.
521
+ when the turn was queued with one: `{client_id:, enqueued_id:}`, plus `author:` when someone other than the session's
522
+ owner wrote it (its input file's `author`, stamped by chi web from the request's `Web::Principal`). An owner's turn
523
+ has no `author` anywhere: not in its input file, its origin or the session. `GuardrailWiring#begin_turn` gets the
524
+ origin, and `#turn_author` reads its author for the gate's checks.
522
525
 
523
526
  ### Hook points (not events)
524
527
 
@@ -49,12 +49,22 @@ lets the model forget its own tool outputs. The pieces:
49
49
  `LLMContextApply.measure`/`.weigh` and `#restored_line` build their views from the same class, so a drop counts in
50
50
  them under none too. The model can't take a drop off: forget_outputs refuses to forget or restore a dropped
51
51
  output, in `WorkingSetSteer::Refused`'s words ("dropped by the user").
52
+ - **The user's pins**: an item the user pinned in the working set (`Session#working_set_pins`,
53
+ [working-set.md](working-set.md#steering-pin-and-unpin)) is guarded against the model's forget only: forget_outputs
54
+ refuses an output of it, in `WorkingSetSteer::Refused`'s words ("pinned by the user: it stays in your context").
55
+ `KernelLoop#forget_outputs` passes `LLMContextForget.call` a `pinned:` callable (`WorkingSet.pinned_ids` of the
56
+ session's pins, by the projection's pairing, the Engine's `working_set_pins`), asked once per forget; a pin covers
57
+ the item's later reads too. A restore of a pinned item's forget (one from before the pin) goes through. Stale stubs
58
+ still apply (a superseded read's newer read is sent, so the pin holds in substance), and a pin changes nothing that
59
+ is sent: no cache break.
52
60
  - **Stubs** (`LLMContextView`): `(forgotten) <note>` on the first output of a call's forgets in a row, `(forgotten
53
61
  with tN: see its note)` on the rest (a run of the same note, author and stamp, no other output between them),
54
62
  ` [restore: tN]` on a non-read with a stored id, then each kept range under `lines A-B kept:`. The entry keeps its
55
63
  role, tool_call_id and images bookkeeping as for stale, and a find_tools result's `loaded:`, so a Messages host
56
64
  still gets the load's references (anthropic.md, "Deferred tools"); only the sibling text is the stub.
57
- - **Offers**: `ContextStatus` (internals/context-telemetry.md).
65
+ - **Offers**: `ContextStatus` (internals/context-telemetry.md). Its `TIDY`/`COMPACT` lines name no output, so a pin
66
+ has nothing to take out of them; the refusal is how the model learns of one if it tries (and the turn-start
67
+ `working_set` note says it was pinned).
58
68
  - **Persistence**: the edits ride on the entries (plan D3), so `--resume`, rollbacks and forks keep the stubs and the
59
69
  restores; `/stats` counts each applied batch's re-prefill.
60
70
  - **Measuring**: the bench's `forget_outputs` strategy (internals/llm-context-bench.md).
@@ -0,0 +1,137 @@
1
+ # Participants: identity and routes
2
+
3
+ A session's owner can invite **participants** to review it: people, or agents, who reach that one session through
4
+ chi web with a link of their own. This page is the identity layer (the shared-sessions plan's P2): grants, the gate,
5
+ the routes a participant reaches, and what is written where. A participant can't send a turn yet (P3), and their page
6
+ shows only the session's title and status until the full participant UI lands (P4). The code and docs say
7
+ "participants" and "review", never "shared": `session.shared` already means "attached by default".
8
+
9
+ Until `web.public_url` lands, participants are a **LAN** feature (`web.host: lan`): a loopback peer is always the
10
+ owner, and a reverse proxy on the same machine would make every visitor loopback.
11
+
12
+ ## Grants
13
+
14
+ A grant (`Web::Grants`, `lib/samagotchi/web/grants.rb`) binds **one random token** to **one session**, **one
15
+ participant** and **one role**, until it expires or is revoked.
16
+
17
+ - **Token.** `chp_` + 43 URL-safe characters (32 random bytes, as the owner's `Web::Token`). The prefix lets the gate
18
+ look up only participant tokens, and a leaked one is recognisable. It is shown **once**, in the link chi share or
19
+ `POST …/participants` prints; chi keeps only its **SHA-256 digest**. Lookup digests the presented token and
20
+ compares it with every grant's digest (`Rack::Utils.secure_compare`).
21
+ - **Participant.** The grant's `id`, `p_<8 hex>`, names the participant: a turn they send will be authored
22
+ `"p:<id>"` (`Grant#author`, `Principal#author`; P3). `kind` is `human` or `agent` (D11: an agent uses the same
23
+ token, as a Bearer header; there is no agent UI). `role` is `reviewer`, the only one in v1. `name` is cleaned to
24
+ one line of at most 64 characters with no brackets (`Grants.clean_name`), so it can't forge P3's
25
+ `[Review contribution from …]` header.
26
+ - **Lifetime (D8).** 7 days by default (`chi share --expires Nh|Nd`, 1 hour to 30 days). Revoked by the owner
27
+ (`chi share revoke`, `DELETE …/participants`), and by an **archive** (the session and its delegates) or a
28
+ **delete** (`SessionManager.archive_session` / `delete_session` → `Participants.end_sessions`). A session pruned by
29
+ retention, or archived or deleted by a process that didn't revoke, is caught by the gate: a live grant whose session
30
+ file is gone or archived is revoked on sight (`revoked_by: gone | archive`). Unarchiving doesn't restore a grant.
31
+ An ended grant stays in the store for 30 days, so its token answers `token_revoked` / `token_expired` rather than a
32
+ bare 401; then it goes.
33
+ - **Store.** `$XDG_STATE_HOME/samagotchi/web-grants/grants.json`, next to `web-token`: the file 0600, in its own 0700
34
+ folder, written whole (`AtomicFile`) under a lock file (`grants.lock`) since chi web and a local `chi share` both
35
+ write it. Readers follow its mtime (a `chi share revoke` in another process takes effect at chi web's next request).
36
+ `last_used_at` is written at most once a minute per grant. **chi web refuses to start** when that folder or file can
37
+ be read by another user, or isn't this user's (`Grants.check_permissions!`, one message naming the `chmod`). Only
38
+ those two are checked: the state dir above is often 0755, and that's fine. Never point `XDG_STATE_HOME` at a
39
+ shared folder.
40
+ - Rotating the owner token (`chi web --new-token`) never touches grants; `chi share revoke --all` ends them all.
41
+
42
+ ## The gate
43
+
44
+ `App#principal_for` resolves every request to a `Web::Principal`:
45
+
46
+ 1. A loopback peer, or a chi web not on the LAN: the **owner** (unchanged).
47
+ 2. The owner's token (Bearer, then the cookie; `?token=` on the page's trade): the **owner**. Checked first, so the
48
+ owner's requests never touch the grants store (measured: no change to an owner route's time).
49
+ 3. A `chp_` token whose grant is active: a **participant** (`Principal.new(kind: :participant, grant:)`).
50
+ 4. Anything else: nil. `#token_gate` answers an ended grant's token **401 `token_revoked`** or **401
51
+ `token_expired`** (the page: "This review link has expired or was revoked. Ask the session's owner for a new
52
+ one."), any other **401 `unauthorized`** as before.
53
+
54
+ The `?token=` trade sets the `chi_token` cookie to the token that was presented, the owner's or the participant's
55
+ (one at a time per browser: a participant link opened in the owner's browser replaces the owner's cookie there).
56
+
57
+ Then `#route`, for each matching `ROUTES` row:
58
+
59
+ 1. A participant on a `/api/sessions/:id…` route whose id isn't their grant's session gets **404 `not_found`**, the
60
+ same answer as a session that doesn't exist, before the permission check: they can't learn which sessions exist.
61
+ 2. A route whose permission the principal lacks (`Principal#permits?`) gets **403 `not_permitted`**, worded for the
62
+ role: "a participant can't do that: only the session's owner can".
63
+
64
+ `spec/web/participant_gate_spec.rb` walks `ROUTES`: every `:owner` row answers a participant 403, and every session
65
+ row answers another session's id 404, so a new route is covered without a new example.
66
+
67
+ ## What a participant reaches (P2)
68
+
69
+ | Route | Permission | A participant gets |
70
+ |---|---|---|
71
+ | `GET /` | participant | the participant page (server-rendered, no script): "Alex's review", the session's title and status, "Comments open soon", the participants' names. Never the owner's page, where every route would refuse them. |
72
+ | `GET /api/info` | participant | `{app, version, features}` (what `SessionAccess::Remote`'s check needs); not `pid`, `cwd`, `lan`, `installed` |
73
+ | `GET /api/sessions/:id` | participant | their session only, whatever the query (`brief`, `cards`, `tail`, `timing`): `{session: {id, title, status, participants: [{id, name, kind, role}]}}` (the active ones). `title` is the session's own title, or "an untitled session": never `display_title`'s fallback, the first prompt's opening words |
74
+
75
+ Everything else is `:owner`, `POST …/turn` included (P3 opens it). Static assets (`/app.js`, …) are served to any
76
+ principal past the gate: they are the public frontend.
77
+
78
+ **Streams.** No stream route is a participant's yet (`App#stream_filter` raises for one, until P4's filter). The
79
+ seam for ending them is in place: `App#stream_running(principal)` is the proxy's running check (asked every
80
+ `BridgeClient::STREAM_POLL`), and for a participant it is false once the grant is revoked or expired, so P4's
81
+ participant streams close within a poll of a revoke, including one made by `chi share revoke` in another process.
82
+
83
+ ## The owner's routes
84
+
85
+ | Route | Body / params | Answer | Refusals |
86
+ |---|---|---|---|
87
+ | `GET /api/sessions/:id/participants` | | 200 `{participants: [grant]}`, every state | |
88
+ | `GET /api/participants` | | 200 `{participants: [grant]}`, every session's | |
89
+ | `POST /api/sessions/:id/participants` | `name`, `kind` (`human`/`agent`), `ttl` (seconds) | 201 `{participant: grant, token, link, lan}`: the token and the link once; `link` names this chi web's LAN address (`http://IP:PORT/?token=…#s=<id>`), `lan` false when it has none | 400 `invalid_participant` (name, kind, ttl, an archived session); 404 |
90
+ | `DELETE /api/sessions/:id/participants[?id=p_…]` | | 200 `{revoked: [grant]}`: one participant, or the session's | 400 `invalid_participant` |
91
+ | `DELETE /api/participants` | | 200 `{revoked: [grant]}`: every session's | |
92
+
93
+ A grant here is `Grant#public_h`: `id, session_id, kind, name, role, created_at, expires_at, revoked_at, revoked_by,
94
+ last_used_at, state` (`active | revoked | expired`). Never the token or its digest.
95
+
96
+ `chi share` (docs/cli.md) uses them through `--remote`, and `SessionAccess::Local`'s same-named operations here
97
+ (`participants`, `invite`, `revoke_participants`; `ParticipantView`, `Invitation`). A local invite's link names
98
+ the local chi web's LAN address when one runs (its `GET /api/info`), else `127.0.0.1` with a line saying it opens on
99
+ this machine only.
100
+
101
+ ## Files in the session dir
102
+
103
+ Neither is in the session JSON (chi web never saves that, and a worker would overwrite a field it added), and
104
+ neither is copied to a continue, a fork or a delegate (a continue starts with no participants). A session nobody was
105
+ invited to has neither: its files stay byte for byte as they were.
106
+
107
+ - **`participants.json`** (0600, `Participants.write`): `{participants: [{id, kind, name, role, added_at,
108
+ expires_at, removed_at}]}`, rewritten by whoever changes a grant (chi web, `chi share`). Never a token or a digest.
109
+ The worker reads it with `Participants.read` (P3a's system-prompt line, `ctx`).
110
+ - **`participants.jsonl`** (0600, append-only, `Participants.log`): one JSON line per event, with `at`: `issued`
111
+ (participant, name, kind, role, expires_at), `revoked` (participant, by), `expired` (the first refused use after the
112
+ expiry, once per chi web), `peer` (the first use and each new peer address since chi web started: ip, user agent),
113
+ `refused` (method, path, reason; at most once a minute per route). It goes with the session (retention, delete).
114
+
115
+ ## Errors through `--remote`
116
+
117
+ Each condition has one message, owned by its `SessionAccess` class and built from the remote's label
118
+ (`Remote.access_error`, keyed by chi web's error code, for any route):
119
+
120
+ | Code | Class | Message |
121
+ |---|---|---|
122
+ | 401 `token_revoked` | `TokenRevoked` (an `AuthRefused`) | `this review link was revoked by the session's owner` |
123
+ | 401 `token_expired` | `TokenExpired` (an `AuthRefused`) | `this review link expired: ask the session's owner for a new one` |
124
+ | 403 `not_permitted` | `NotPermitted` | `this token is a participant's: only the session's owner can do that` |
125
+ | 429 `too_many_turns` | `TooManyTurns` (P3b) | `this participant has too many turns waiting: wait for one to finish` |
126
+ | 429 `cap_reached` | `CapReached` (P3b) | `this participant's spending cap is reached: ask the session's owner to raise it` |
127
+
128
+ A 401 `unauthorized` stays `AuthRefused`'s "the token was refused".
129
+
130
+ ## Invariants
131
+
132
+ - The owner is a loopback peer or the owner's token; nothing else. A participant token never widens to the owner.
133
+ - A participant reaches one session, through the `:participant` rows only; another session is a 404.
134
+ - No token is written anywhere: the grants store has digests, the sidecar and the audit trail have neither.
135
+ - Owner sessions are unchanged: no sidecar, no audit file, no key in the session JSON until a grant exists.
136
+ - P2 lets no participant reach `POST …/turn`, `/command`, `/answer` or a stream. P3 adds the turn, with the author
137
+ stamped from the principal (never the body), and the worker's review boundary.