samagotchi 0.119.0 → 0.121.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -1
  3. data/bin/chi +6 -1
  4. data/docs/architecture.md +35 -5
  5. data/docs/cli.md +33 -8
  6. data/docs/configuration.md +74 -20
  7. data/docs/internals/participants.md +111 -12
  8. data/docs/internals/prompt-caching.md +7 -6
  9. data/docs/internals/remote-sessions.md +6 -1
  10. data/docs/internals/web-push.md +4 -2
  11. data/docs/plugins.md +24 -8
  12. data/docs/sessions.md +4 -3
  13. data/docs/testing.md +13 -2
  14. data/lib/samagotchi/answer_command.rb +2 -0
  15. data/lib/samagotchi/bridge.rb +56 -13
  16. data/lib/samagotchi/bridge_client/event_stream.rb +34 -3
  17. data/lib/samagotchi/bridge_client.rb +58 -19
  18. data/lib/samagotchi/bridge_secret.rb +35 -0
  19. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/claude-peer/scripts/pair +1 -1
  21. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  23. data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  25. data/lib/samagotchi/config.rb +3 -0
  26. data/lib/samagotchi/engine.rb +1 -0
  27. data/lib/samagotchi/kernel_loop.rb +5 -0
  28. data/lib/samagotchi/participant_messages.rb +5 -0
  29. data/lib/samagotchi/participants.rb +16 -6
  30. data/lib/samagotchi/plugin/api.rb +6 -3
  31. data/lib/samagotchi/review_command.rb +3 -1
  32. data/lib/samagotchi/run_options.rb +3 -1
  33. data/lib/samagotchi/session_access/contract.rb +4 -1
  34. data/lib/samagotchi/session_access/local.rb +15 -4
  35. data/lib/samagotchi/session_access/remote.rb +43 -7
  36. data/lib/samagotchi/session_access.rb +11 -1
  37. data/lib/samagotchi/session_manager.rb +1 -1
  38. data/lib/samagotchi/sessions_command.rb +1 -2
  39. data/lib/samagotchi/share_command.rb +29 -8
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -0
  41. data/lib/samagotchi/terminal_ui/local_link.rb +18 -3
  42. data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
  43. data/lib/samagotchi/tool_declarations.rb +1 -1
  44. data/lib/samagotchi/tool_runner.rb +37 -1
  45. data/lib/samagotchi/toolkit.rb +50 -26
  46. data/lib/samagotchi/toolkit_binding.rb +32 -0
  47. data/lib/samagotchi/toolkit_command.rb +23 -0
  48. data/lib/samagotchi/tools/deferred_tools.rb +10 -2
  49. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  50. data/lib/samagotchi/tools/registry.rb +20 -6
  51. data/lib/samagotchi/tools/tool_index.rb +103 -20
  52. data/lib/samagotchi/version.rb +1 -1
  53. data/lib/samagotchi/web/app.rb +117 -43
  54. data/lib/samagotchi/web/grants.rb +127 -16
  55. data/lib/samagotchi/web/link_codes.rb +24 -15
  56. data/lib/samagotchi/web/participant_access.rb +69 -13
  57. data/lib/samagotchi/web/participant_exposure.rb +44 -0
  58. data/lib/samagotchi/web/participant_review.rb +71 -9
  59. data/lib/samagotchi/web/participants_admin.rb +12 -6
  60. data/lib/samagotchi/web/proxy_gate.rb +18 -3
  61. data/lib/samagotchi/web/public/annotate_bar.css +18 -0
  62. data/lib/samagotchi/web/public/annotate_bar.js +10 -3
  63. data/lib/samagotchi/web/public/app.js +66 -155
  64. data/lib/samagotchi/web/public/composer.css +73 -0
  65. data/lib/samagotchi/web/public/composer_grip.js +21 -15
  66. data/lib/samagotchi/web/public/composer_mount.js +196 -0
  67. data/lib/samagotchi/web/public/forms.css +12 -0
  68. data/lib/samagotchi/web/public/index.html +14 -88
  69. data/lib/samagotchi/web/public/level_chip.js +2 -2
  70. data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
  71. data/lib/samagotchi/web/public/notifications.js +58 -8
  72. data/lib/samagotchi/web/public/participant.html +46 -10
  73. data/lib/samagotchi/web/public/participant.js +101 -18
  74. data/lib/samagotchi/web/public/participant_annotate.js +75 -0
  75. data/lib/samagotchi/web/public/participant_model.js +53 -1
  76. data/lib/samagotchi/web/public/participants_panel.js +37 -13
  77. data/lib/samagotchi/web/public/question_card.js +15 -1
  78. data/lib/samagotchi/web/public/question_cards.js +11 -2
  79. data/lib/samagotchi/web/public/review_history.js +60 -0
  80. data/lib/samagotchi/web/public/scope.js +12 -8
  81. data/lib/samagotchi/web/public/stage_frame.js +9 -4
  82. data/lib/samagotchi/web/public/stage_view.js +4 -4
  83. data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
  84. data/lib/samagotchi/web/push/notifier.rb +17 -3
  85. data/lib/samagotchi/web/running.rb +7 -4
  86. data/lib/samagotchi/web/server.rb +34 -11
  87. data/lib/samagotchi/web/session_create.rb +7 -7
  88. data/lib/samagotchi/web/start_dir.rb +26 -0
  89. data/lib/samagotchi/web/tunnel.rb +10 -1
  90. data/lib/samagotchi/worker_sidecar.rb +22 -11
  91. metadata +10 -1
@@ -38,7 +38,14 @@ participant** and **one role**, until it expires or is revoked.
38
38
  bare 401; then it goes.
39
39
  - **Store.** `$XDG_STATE_HOME/samagotchi/web-grants/grants.json`, next to `web-token`: the file 0600, in its own 0700
40
40
  folder, written whole (`AtomicFile`) under a lock file (`grants.lock`) since chi web and a local `chi share` both
41
- write it. Readers follow its mtime (a `chi share revoke` in another process takes effect at chi web's next request).
41
+ write it. Its `version` is 1, and 2 once a grant is bound (never lowered); a store above `Grants::MAX_VERSION` is a
42
+ newer chi's: read (its links work, but a grant with keys this chi doesn't know is left out: such a key may
43
+ restrict it, so it fails closed) and never written (`Grants::Newer`, one message saying to update chi; a revoke
44
+ the gate can't write still refuses the grant). A grant's keys this chi doesn't know are kept on a rewrite
45
+ (`Grant#extra`), so a field a later chi adds the omit-if-default way survives. A chi from before the version check
46
+ doesn't look at it: **mixed installs** on one state dir are a hazard, as a chi before 0.109 (no `identity`) rewriting
47
+ the store unbinds every grant, and one before this change drops unknown keys. A stored `identity` that isn't one
48
+ `#issue` would write (`false`, `""`, a list, upper case) makes the store Corrupt (fails closed). Readers follow its mtime (a `chi share revoke` in another process takes effect at chi web's next request).
42
49
  `last_used_at` is written at most once a minute per grant. **chi web refuses to start** when that folder or file can
43
50
  be read by another user, or isn't this user's (`Grants.check_permissions!`, one message naming the `chmod`). Only
44
51
  those two are checked: the state dir above is often 0755, and that's fine. Never point `XDG_STATE_HOME` at a
@@ -48,11 +55,55 @@ participant** and **one role**, until it expires or is revoked.
48
55
  an invite can't keep only its own grant and lose every other digest. The owner's invite, cap, revoke and list are
49
56
  refused with one message naming the file (`Grants::Corrupt`: `chi share` exits 1, chi web answers 500 with it);
50
57
  an archive or delete still goes ahead. Fix the file, or move it aside to end every link.
58
+ - **Exposure** (`exposure`, `Web::ParticipantExposure`; the review-composer plan's D3): what the participant learns
59
+ of the review's usage, their review data's `viewer.usage` ([below](#what-a-participant-sees)). One level today,
60
+ `own`, every grant's: their own spend and cap, their own queued turns and queue limit, their link's expiry, and the
61
+ session's model; never another participant's or the owner's usage, nor the session's. `ParticipantExposure::LEVELS`
62
+ maps a level to its fields, and `ParticipantReview#review_usage` names each field's source: a later tier adds its
63
+ row and its sources there. A level this chi doesn't know (a newer chi's, a hand edit) reads as `own`, the narrowest,
64
+ and is written back as it was. `own` is left out of the store (as an unbound `identity`). A non-default level
65
+ **doesn't bump the store's version**, unlike `identity`: identity restricts a grant, so a chi that ignored it would
66
+ widen access (the version stops it writing); a chi that ignores `exposure` shows the participant `own`, so it fails
67
+ closed, and one with `Grant#extra` keeps the key on its rewrites. A chi from before `Grant#extra` drops it on a
68
+ rewrite: the grant goes back to `own`. No `--expose` flag until a second level exists; adding one touches
69
+ `Participants.invite`, `SessionAccess` Local/Remote `#invite`, the invite route, the web participants panel, the
70
+ TUI's participant commands, `SessionAccess::Contract` and `SessionAccess::ParticipantView`'s fields. The owner's
71
+ routes (`public_h`) don't show it yet.
51
72
  - Rotating the owner token (`chi web --new-token`) never touches grants; `chi share revoke --all` ends them all.
52
73
  - **Bound grants** (`identity`, an email in lower case; nil for any, and absent from the store then): a bound grant
53
74
  is honoured only on a request through the proxy (`web.public_url`) whose identity header carries that email; on the
54
- LAN or this machine it is refused (401). The gate checks it (`participant_principal(identity:, proxied:)`);
55
- issuing one (`chi share --identity`, the Invite dialog's email) is shared-sessions P6.
75
+ LAN, this machine or for another identity it is refused **401 `token_bound`** (`ParticipantMessages::TOKEN_BOUND`,
76
+ the page says it as a sentence; neither identity is named). One check, `ParticipantAccess#bound_ok?`, for the gate
77
+ (`participant_principal(identity:, proxied:)`) and the code trade (below).
78
+ - **Issuing.** `chi share ID --name NAME --identity EMAIL`, `POST …/participants {identity}`, the Invite dialog's
79
+ email field (shown when `GET …/participants` gives `identity_header`). `Grants.clean_identity` strips and
80
+ lower-cases it as `ProxyGate#proxy_identity` does (both ASCII only: the gate refuses a non-ASCII header, so the
81
+ two lower cases always agree), and refuses anything but one address (one `@`, no space,
82
+ control character, comma, quote or angle bracket, at most 254 characters; `Grants::BadIdentity`); a dot-less
83
+ domain is fine. `Participants.invite(identity:, public_url:)` refuses it unless that chi web's `PublicUrl` has an
84
+ identity header (`Grants::Unbindable`: the link could never open). Locally `public_url` is config's
85
+ `web.public_url` + `web.proxy.identity_header`, or a running `chi web --tunnel`'s (web.json records its origin and
86
+ identity header). The `issued` audit line names the identity; `chi share` warns after an unbound invite when the
87
+ chi web could have bound it (decision B).
88
+ - **Through `--remote`.** `/api/info` lists the feature `bound_grants` (`App::BOUND_FEATURE`); `Remote#invite` with
89
+ an identity requires it first, so an older chi web (which would drop the unknown `identity` and issue an unbound
90
+ grant) is never asked (`RemoteTooOld`). A grant that still comes back unbound is revoked at once and refused, its
91
+ link never printed. The owner's `/api/info` carries `proxy: {identity_header}` (nil without a proxy):
92
+ `Remote#identity_header`, for the warning and `chi review`'s hint.
93
+ - **The code (decision C).** A bound grant's link code is checked before it is used up: `LinkCodes#redeem` asks its
94
+ block under the lock, and `App#code_identity_ok?` declines unless the trade is proxied with the grant's identity.
95
+ Declined, the code stays and the trade answers `token_bound`, so a forwarded link opened by someone else still
96
+ opens, once, for the account it was sent to. Owner codes and unbound grants' codes are used up whatever the gate
97
+ says, as before. Owner codes burn on purpose (user decision 2026-10-26, don't "align" them with bound grants): an
98
+ owner code is the whole control plane, so one presented by another identity has leaked and is killed at once;
99
+ the owner prints a new link (spec: proxy_gate_spec "uses up an owner's code someone else presents").
100
+ - **Which identity string.** The grant binds to whatever the header this run trusts carries: through Okta and
101
+ oauth2-proxy the work email (`alex@corp.com`), through Tailscale Serve the Tailscale login (`alex@github`, or a
102
+ private-relay address). The same person is two strings: bind to the one that proxy sends. A request from a
103
+ tagged Tailscale device carries no user login, so a bound grant refuses it.
104
+ - **Local header spoofing (decision D).** Any process on this machine that reaches chi web's port directly can send
105
+ the identity header itself: it is trusted only as the proxy's word. That is fine on a single-user machine or pod
106
+ (whoever runs code there is the owner already); on a host other users share, the identity header isn't a boundary.
56
107
 
57
108
  ## The gate
58
109
 
@@ -60,7 +111,9 @@ participant** and **one role**, until it expires or is revoked.
60
111
 
61
112
  0. With `web.public_url` (`Web::ProxyGate`; `chi web --tunnel tailscale` sets the same for its run, never in config:
62
113
  public_url = the machine's `ts.net` name, identity_header `Tailscale-User-Login`, owner `web.proxy.owner` else this
63
- machine's Tailscale login, `Web::Tunnel::Tailscale`), a **proxied** request (a proxy trace: `X-Forwarded-*`, `Forwarded`,
114
+ machine's Tailscale login, `Web::Tunnel::Tailscale`), a **proxied** request (from a loopback peer only,
115
+ `ProxyGate.loopback_peer?`: the proxy runs on this machine, and a LAN peer's traces are its own words, so with
116
+ `web.host: lan` and `web.public_url` a LAN device forging them is a LAN request; with a proxy trace: `X-Forwarded-*`, `Forwarded`,
64
117
  `X-Real-IP`, a known identity header, the configured one, or the public Host) is `#proxied_principal`'s: the owner
65
118
  only with the owner's token **and**, when `web.proxy.identity_header` is set, that header being one clean value
66
119
  (not repeated, no comma, not empty, under one name with no underscore: the raw header names) equal to
@@ -73,7 +126,8 @@ participant** and **one role**, until it expires or is revoked.
73
126
  grant:)`), from any peer: loopback and a chi web not on the LAN too. An ended or unknown one is refused
74
127
  (below), never the owner.
75
128
  3. No `chp_` token from a loopback peer, or on a chi web not on the LAN: the **owner** (unchanged).
76
- 4. Anything else: nil. `#token_gate` answers an ended grant's token **401 `token_revoked`** or **401
129
+ 4. Anything else: nil. `#token_gate` answers a bound grant's token without its identity **401 `token_bound`**, an
130
+ ended grant's token **401 `token_revoked`** or **401
77
131
  `token_expired`** (the page says the same words as a sentence: "This review link was revoked by the session's
78
132
  owner." / "This review link expired: ask the session's owner for a new one."), any other **401
79
133
  `unauthorized`** as before.
@@ -105,7 +159,7 @@ row answers another session's id 404, so a new route is covered without a new ex
105
159
  | `GET /api/sessions/resolve?id=` | participant | their session for its id or a prefix of it; anything else 404 as no session (never an ambiguity listing others) |
106
160
  | `POST /api/sessions/:id/wait/prepare`, `…/wait` | participant | only for a turn of theirs (`turn`: the enqueued id `POST …/turn` answered; else 403); `wait/prepare` answers only what that wait needs, `{session_id, baseline: {last_turn}, known_turn, live, last_turn_id}` (`last_turn_id` only when it is their turn), never the owner's cursor, message count, question or running turn; the answer trimmed to `{status, text, turn_id, cancel_reason}`, the reply's `text` through `ParticipantView.reply` (no thinking or tool call, redacted) and any other text (an error's exception, a failed turn's summary) `ParticipantMessages::TURN_FAILED`; a question or approval their turn raised, or their turn held by the owner's step-limit Continue offer ([Turns](#turns)), is `{status: "waiting_for_owner", detail: "waiting for the session's owner"}` with nothing of it; a revoke or the expiry ends a `wait` already running (401 `token_revoked` / `token_expired`), as it ends their stream |
107
161
  | `GET /` | participant | the participant page's shell (`participant.html`, its body's `data-session` and `data-name` the grant's; `participant.js` reads the rest through the two routes below). Never the owner's page, where every route would refuse them. |
108
- | `GET /api/sessions/:id/review` | participant | the page's data, filtered ([What a participant sees](#what-a-participant-sees)): `{viewer, session: {id, title, status}, subjects, participants, messages, current_turn, waiting_for_owner, queued: {mine, others}, last_event_id}` |
162
+ | `GET /api/sessions/:id/review` | participant | the page's data, filtered ([What a participant sees](#what-a-participant-sees)): `{viewer, session: {id, title, status}, subjects, participants, messages, current_turn, waiting_for_owner, queued: {mine, others}, last_event_id}`; a participant's `viewer` has their `usage` |
109
163
  | `GET /api/sessions/:id/stream` | participant | the session's stream through `ParticipantStreamFilter` (only `from_seq` and `snapshot` reach the Bridge); it ends within a poll of a revoke or the expiry |
110
164
  | `GET /api/info` | participant | `{app, version, features}` (what `SessionAccess::Remote`'s check needs); not `pid`, `cwd`, `lan`, `installed` |
111
165
  | `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 |
@@ -205,7 +259,8 @@ made by `chi share revoke` in another process; the page then reads a 401 and sho
205
259
 
206
260
  D5 (a): prompts and replies with their authors, one-line tool rows, turn boundaries, the queue and waiting states.
207
261
  Not tool output or arguments, thinking, approvals' and questions' content, memories (text or names), context notes,
208
- plugin steers, automatic and delegate input, cards, command output, the working set (the user decided: not), cost,
262
+ plugin steers, automatic and delegate input, cards, command output, the working set (the user decided: not), cost
263
+ (but their own usage, as their grant's exposure shows it),
209
264
  timing (durations, records: only the wall time `at` of each prompt, reply and turn start, for the page's gap lines
210
265
  and hover times, D6 of the session-time-gaps plan), host paths or the owner's prompt history. Every read a participant reaches goes through one module,
211
266
  `Web::ParticipantView` (`lib/samagotchi/web/participant_view.rb`), whose every answer is a new hash built from named
@@ -247,6 +302,20 @@ fields: a field added to an event later reaches nobody until it is named there.
247
302
  `html` is rendered from the filtered text. The turn in progress (`.current_turn`, over the Bridge snapshot's) gives
248
303
  its start's fields, the text so far, its tool rows (with `running`) and `waiting_for_owner`.
249
304
  - **The queue.** Their own queued turns (enqueued id and text, for Take back) and how many others wait.
305
+ - **Their usage** (`viewer.usage`, `ParticipantReview#review_usage`, built from named fields by the exposure policy
306
+ beside `ParticipantView`'s): the fields their grant's exposure shows
307
+ ([Grants](#grants)); at `own`, `{spent, cap, queued, queue_limit, expires_at, model}`. `spent` is dollars: the saved
308
+ analytics' count by their author (the cap check's), or the live worker's `spend_by_author` for them when it is more
309
+ (its count holds the saved turns, the running turn's finished generations and the side requests they caused, so a
310
+ running turn shows before it is saved; the two are never added), read from the Bridge snapshot the review already
311
+ reads. `queued` counts their own queued turns on the worker; `model` is the session's model as its host is sent it
312
+ (the alias applied, no host name), live else saved, redacted. Every source reads the grant's own author: another
313
+ participant's spend, the owner's and the session's totals never reach them (the red-team spec checks the body). The
314
+ owner's participant view has none. The page draws it as one line under the comment box (`usageItems`): "$0.42 of
315
+ $2.00 cap · 1/3 queued · link: 6 d left · model", each with a title in words; warn colours from 80% of the cap, a
316
+ full queue and a link's last day, "over" at the cap. On a phone it is the only line under the box (the hint is
317
+ hidden there), cut at the end (the model first); folded, it goes with the hint. It is redrawn at each re-read (a
318
+ turn's end, a queued or taken-back comment), not while a turn streams.
250
319
  - **A fork's note.** A review fork (`chi review --from`, `/coordinate review`) starts with a note holding the source's
251
320
  recap and, from the coordinator, its handoff's body: a participant's review turn reads it like the rest of the
252
321
  history and may quote it (host paths, worktree names). That is by design (it is what the review is about); the
@@ -265,6 +334,28 @@ fields: a field added to an event later reaches nobody until it is named there.
265
334
  page shows them as the Plan: beside the thread on a wide screen (≥ 1100 px), a Plan | Thread toggle on a narrower
266
335
  one (the thread first, the composer pinned in both views); several subjects get a file picker. It re-reads them
267
336
  with the rest at every turn boundary and redraws the Plan only when they changed.
337
+ - **Annotate** (`participant_annotate.js` over the owner's `annotate_bar.js`, styled by the shared
338
+ `annotate_bar.css`). A participant who can comment selects text in the Plan or the thread and gets **Annotate** and
339
+ the owner's `web.annotate_presets` (the participant shell's `data-annotate-presets`, as `index.html`'s), which append
340
+ the quote to the comment box; nothing is sent. Labels: the Plan `From docs/plan.md:` (the subject shown), a comment
341
+ `From Sam's comment:` / `From my earlier comment:` / `From the owner's message:` (the message's `data-by`,
342
+ `data-name`), a reply none. Not annotatable: the reply still streaming, tool rows, waiting lines, gap lines, the
343
+ header, the Plan's truncation note. No plugin pills (a review turn refuses `/` lines), and none of it in the owner's
344
+ participant view (no presets in its shell) or while `can_comment` is false. On a touch screen the bar docks above the
345
+ composer. A re-read keeps unchanged thread nodes and the Plan's (redrawn only on change), so a selection survives it.
346
+ - **The comment box** is the owner's composer (`composer_mount.js`, styled by the shared `composer.css`), mounted by
347
+ `participant.js` with a subset on. It has: the height that grows with the text, the grip (drag to resize,
348
+ double-click resets, click or drag down folds it; folded, the grip is the handle on the dock's edge and typing on the
349
+ page opens it; Annotate opens it for the visit), the one-row layout of the owner's session composer (Send at its
350
+ bottom right; a phone keeps it one row), and ↑/↓ over the participant's own comments: the ones the server took from
351
+ this browser, in localStorage under `chi_review_history:<grant id>` (`review_history.js`; the grant id is the review
352
+ data's `viewer.id`), at most 50, forgotten when the link ends (a 401 with chi's words), never the owner's `GET
353
+ /api/history` (the owner may open a link in the same browser). It lacks: the `/` list and plugin pills (a review turn
354
+ refuses `/` lines), the delivery line and its steer/queue choices (every comment is `POST …/turn`, a queued review
355
+ turn; Alt+Enter is Enter), the model picker, Stop turn, and images: the chips are mounted but `imagesOff` refuses a
356
+ paste, drop or pick with one line (the image button is hidden) until participant images land. The grip's floor and
357
+ the fold are the owner page's keys (`chi_composer_floor`, `chi_composer_collapsed`), per browser. The owner's
358
+ participant view mounts none: its dock stays hidden.
268
359
  - **Redaction** (`ParticipantView::Redact`, on every string that goes out): secret shapes (`sk-…`, `ghp_…`,
269
360
  `github_pat_…`, `AKIA…`, `xox…-`, `chp_…`, `AIza…`, `rk_live_…`, JWTs, Slack webhooks, private key blocks, an
270
361
  `Authorization:` header's value, any `…token` / `…api_key` / `…secret` / `…password` value, `=` or `:`, quoted or
@@ -329,16 +420,17 @@ gate and routes) answers these.
329
420
 
330
421
  | Route | Body / params | Answer | Refusals |
331
422
  |---|---|---|---|
332
- | `GET /api/sessions/:id/participants` | | 200 `{participants: [grant], warning}`, every state; `warning`: the share warning for a session that isn't a review session (`ReviewStart.share_warning`, null for one), shown before the panel's Invite with Start a review of it | |
423
+ | `GET /api/sessions/:id/participants` | | 200 `{participants: [grant], warning, identity_header}`, every state; `warning`: the share warning for a session that isn't a review session (`ReviewStart.share_warning`, null for one), shown before the panel's Invite with Start a review of it; `identity_header`: the header this chi web's proxy names the user in (null without one), the Invite's email field | |
333
424
  | `GET /api/participants` | | 200 `{participants: [grant]}`, every session's | |
334
- | `POST /api/sessions/:id/participants` | `name`, `kind` (`human`/`agent`), `ttl` (seconds) | 201 `{participant: grant, token, link, lan, qr_svg, warning}`: the token and the link once; `warning` for a session that isn't a review session (`ReviewStart.share_warning`, null for one); `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 |
425
+ | `POST /api/sessions/:id/participants` | `name`, `kind` (`human`/`agent`), `ttl` (seconds), `cap`, `identity` (an email: a bound grant) | 201 `{participant: grant, token, link, lan, qr_svg, warning}`: the token and the link once; `warning` for a session that isn't a review session (`ReviewStart.share_warning`, null for one); `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 identity that isn't one address or with no proxy to check it, an archived session); 404 |
335
426
  | `GET /api/sessions/:id/apply[?turn=T]` | `turn`: a turn id or a prefix (none: the latest review turn with a reply) | 200 `{text, turn_id, author, author_name}`: the Apply turn for the composer, never sent | 404 `not_found` (no such turn, no review turn), 400 `not_review_turn` (an owner's turn), 409 `no_reply` |
336
427
  | `DELETE /api/sessions/:id/participants[?id=p_…]` | | 200 `{revoked: [grant]}`: one participant, or the session's | 400 `invalid_participant` |
337
428
  | `DELETE /api/participants` | | 200 `{revoked: [grant]}`: every session's | |
338
429
  | `POST /api/sessions/:id/participants/:p/cap` | `cap` (dollars, 0 to 1000) | 200 `{participant: grant}`: their cap from their next turn on | 400 `invalid_participant` (the cap, no such active participant) |
339
430
 
340
431
  A grant here is `Grant#public_h`: `id, session_id, kind, name, role, created_at, expires_at, revoked_at, revoked_by,
341
- last_used_at, state` (`active | revoked | expired`), `cap` (dollars) and `queue`. Never the token or its digest.
432
+ last_used_at, state` (`active | revoked | expired`), `cap` (dollars), `queue` and `identity` (null: unbound). Never
433
+ the token or its digest.
342
434
  `POST …/participants` also takes `cap`. A grant from before caps takes the config's.
343
435
 
344
436
  `chi share` (docs/cli.md) uses them through `--remote`, and `SessionAccess::Local`'s same-named operations here
@@ -359,8 +451,12 @@ invited to has none: its files stay byte for byte as they were.
359
451
  The worker reads it with `Participants.read` (P3a's system-prompt line, `ctx`).
360
452
  - **`participants.jsonl`** (0600, append-only, `Participants.log`): one JSON line per event, with `at`: `issued`
361
453
  (participant, name, kind, role, expires_at), `revoked` (participant, by), `expired` (the first refused use after the
362
- expiry, once per chi web), `peer` (the first use and each new peer address since chi web started: ip, user agent),
363
- `refused` (method, path, reason; at most once a minute per route; or `what: command`, a participant's `/word` or
454
+ expiry, once per chi web), `peer` (the first use and each new peer since chi web started: ip, user agent; behind a
455
+ proxy, where every ip is 127.0.0.1, the peer is the signed-in identity, else the proxy's X-Forwarded-For hop:
456
+ `identity`, `peer`), `issued` names a bound grant's `identity`,
457
+ `refused` (method, path, reason; at most once a minute per route; `token_bound` with the `identity` that presented
458
+ it, or `(none)`, once a minute per grant and identity whatever the path; at most 256 refusals a minute are
459
+ remembered and audited; or `what: command`, a participant's `/word` or
364
460
  `!line` the Bridge or the worker refused), `turn` (chars, `status: accepted` with its enqueued id, or `refused` with
365
461
  the reason: `not_permitted`, `too_many_turns`, `cap_reached`, `review_unavailable`, `worker_timeout`), `cap` (a new
366
462
  cap). It goes with the session (retention, delete).
@@ -412,6 +508,7 @@ code, for any route):
412
508
  |---|---|---|
413
509
  | 401 `token_revoked` | `TokenRevoked` (an `AuthRefused`) | `this review link was revoked by the session's owner` |
414
510
  | 401 `token_expired` | `TokenExpired` (an `AuthRefused`) | `this review link expired: ask the session's owner for a new one` |
511
+ | 401 `token_bound` | `TokenBound` (an `AuthRefused`) | `this review link is for another account: open it through the proxy, signed in as the person it was sent to` |
415
512
  | 403 `not_permitted` | `NotPermitted` | `a participant can't do that: only the session's owner can` |
416
513
  | 429 `too_many_turns` | `TooManyTurns` | `this participant has too many turns waiting: wait for one to finish` |
417
514
  | 429 `cap_reached` | `CapReached` | `this participant's spending cap is reached: ask the session's owner to raise it` |
@@ -448,6 +545,8 @@ participant TUI in v1.
448
545
  no participant token; nothing else. Through a proxy the owner is the token and, with an identity header
449
546
  configured, that identity: never the identity alone, never a loopback peer. A participant token never widens to the
450
547
  owner, from any peer.
548
+ - A bound grant works only on a proxied request whose identity header is its identity: never on the LAN, this
549
+ machine's loopback or for another identity, and its link code is used up only by that identity.
451
550
  - A participant reaches one session, through the `:participant` rows only; another session is a 404.
452
551
  - No token is written anywhere: the grants store has digests, the sidecar and the audit trail have neither.
453
552
  - Owner sessions are unchanged: no sidecar, no audit file, no key in the session JSON until a grant exists.
@@ -79,11 +79,13 @@ right-hand column happens.
79
79
  `Toolkit::Resolved` the Registry holds, so a switch that changes only the text is a toolkit change
80
80
  (`apply_toolkit!` → `reset!`). `full` and kits without one send the bytes they sent before.
81
81
  - find_tools' description is a function of the toolkit and the source groups **at load**: `ToolIndex#declare`
82
- snapshots the MCP groups' lines (name, summary, callable count) and `ToolIndex#refresh!` (from
83
- `ToolkitBinding#apply!`) rebuilds only the `chi` line, the built-ins the toolkit reaches. An MCP server that
84
- finishes booting between load and the first turn changes search answers only, so two sessions on the same
85
- toolkit with the same MCP cache send the same bytes. Whether find_tools/call_tool are declared at all reads the
86
- same two things (`Registry#index_needed?`), never live groups.
82
+ snapshots source group names and description lines (name, summary, callable count). `ToolIndex#refresh!` (from
83
+ `ToolkitBinding#apply!`) filters that snapshot by the new toolkit and rebuilds the `chi` line from the built-ins
84
+ it reaches; it never consults live group names. The same group filter drives searches, deferred MCP tools and
85
+ whether `find_tools`/`call_tool` are declared (`Registry#index_needed?`). An MCP server that finishes booting
86
+ between load and the first turn changes search answers only, so two sessions on the same toolkit with the same
87
+ MCP cache send the same bytes. Kits that omit `groups:` keep their previous request bytes; switching only
88
+ groups changes the prompt key and asks `/toolkit` to confirm the cache cost once the session has history.
87
89
  - A worker's `execute` and `task_create` descriptions carry the finish wake's sentence ("if you end your turn before
88
90
  it finishes, chi tells you when it does"; `Engine.new(task_finish_notes:)`, set once as the worker builds its
89
91
  Engine); the `--no-shared` REPL's, and a worker's under `session.task_wakes: off`, don't. A session that moves
@@ -240,4 +242,3 @@ is prefilled once. The native and the chat loop use the same plan.
240
242
  `cache_prompt: true` on every native request.
241
243
  - **OpenRouter / Anthropic:** the default cache TTL is 5 minutes (`cache.ttl: 1h` for an hour, see above).
242
244
  Provider routing (which upstream serves a request) also decides whether a cache is there to hit.
243
-
@@ -6,7 +6,8 @@ session's `context`, `sessions list/stop/restart/archive/unarchive/delete`) agai
6
6
  sessions, workers, memories and code stay on that machine; this one only sends requests and prints the answers.
7
7
  User-facing behaviour is in [cli.md](../cli.md#remote-sessions). This page is the code's shape, for changing it.
8
8
 
9
- **The one rule:** the remote side is that machine's **chi web**, never a worker's Bridge. Bridges stay on loopback.
9
+ **The one rule:** the remote side is that machine's **chi web**, never a worker's Bridge. Bridges stay on loopback,
10
+ and answer only a request that names their secret from `bridge.json` ([architecture.md](../architecture.md)).
10
11
  chi web answers each request by calling the same `SessionAccess::Local` operation the CLI would call there.
11
12
 
12
13
  ## The seam: `SessionAccess`
@@ -160,6 +161,10 @@ the session commands*. Keep that table in step with `SessionAccessRoutes`.
160
161
  only when its detail starts with `not found: /` (the 404 from `serve_static`, for a path no route takes). A route's
161
162
  own 404 (no such session or source) stays a refusal. **Keep that rule:** a new route's 404 must never use the
162
163
  `not found: /…` wording.
164
+ - **A field an older chi web would ignore.** A body field whose meaning is a restriction (`share --identity`'s
165
+ `identity`: an older chi web drops it and issues a link anyone could open) gets a feature of its own
166
+ (`bound_grants`, `App::BOUND_FEATURE`), and `Remote` requires it before sending the field (`require_feature!`):
167
+ never a silent downgrade.
163
168
 
164
169
  ## Which checks run where
165
170
 
@@ -96,10 +96,12 @@ injected transport) that decrypts each body with the device's key and checks the
96
96
  The service worker is the only reader of the payload (JSON, at most `Notifier::OPEN_LIMIT`, 3000 bytes, with
97
97
  `open`; one aes128gcm record leaves 3993 bytes of plaintext under Web Push's 4096: 86 header, 1 delimiter, 16 tag):
98
98
 
99
- - `{"v":1,"kind":"show","tag":KEY,"title":…,"body":…,"url":"/#/s/<id>","badge":N,"open":[KEY…]}`: a waiting
99
+ - `{"v":1,"kind":"show","tag":KEY,"title":…,"body":…,"url":URL,"badge":N,"open":[KEY…]}`: a waiting
100
100
  reason's (approval, question, continue, card) TTL 12 h (`SHOW_TTL`), `Urgency: high`; a failed or done turn's
101
101
  TTL 10 min (`EVENT_TTL`), `Urgency: normal`. `open` only while the whole list fits the limit; the test push
102
- (`chi:test`) has neither `open` nor `badge`.
102
+ (`chi:test`) has neither `open` nor `badge`. URL (`Notifier.session_url`) is the session in its own project's
103
+ view, `/?dir=<project root>#/s/<id>` (the folder URL-encoded, its slashes kept), or `/?scope=all#/s/<id>` for a
104
+ session in no repo: never the bare `/`, which is chi web's start project.
103
105
 
104
106
  `badge` and `open` are the waiting keys still open in the Notifier's summaries (`Attention.open_keys`: a pending
105
107
  question of any reason, a hook's and a continue's too, not relayed, and a card with actions; never a failed or done
data/docs/plugins.md CHANGED
@@ -579,13 +579,16 @@ end
579
579
  - Each task runs once per session start. A `-p … --non-interactive` run
580
580
  starts them with its turn and shows nothing but the answer.
581
581
 
582
- ### `chi.toolkit(name, base:, add: [], remove: [], prompt: nil)`
582
+ ### `chi.toolkit(name, base:, add: [], remove: [], prompt: nil, groups: nil)`
583
583
 
584
584
  A toolkit sessions can be put on: `base`'s tools (`full`, `lean`, `child`, a
585
585
  config or bundle toolkit) with `add` declared and `remove` left out, and
586
586
  `prompt`: text the sessions on it get in their system prompt, verbatim (nil
587
587
  inherits the base's, `""` clears it; at most 2000 characters, and one that
588
- isn't a String or is longer is a load error). Users pick
588
+ isn't a String or is longer is a load error). `groups` selects the source
589
+ groups the toolkit reaches (names or globs, sorted and deduplicated; nil
590
+ inherits the base's, `[]` reaches none, and `:all` reaches every group).
591
+ Groups match against the tool index's load-time names. Users pick
589
592
  it like chi's own (`chi --toolkit NAME`, `toolkit.default`, `/toolkit NAME`),
590
593
  and the plugin with `ctx.use_toolkit` (below). Names are `a-z`, `0-9`, `_` and
591
594
  `-`; one chi has (`full`, `lean`, `child`) or another bundle declared is a
@@ -593,11 +596,13 @@ load error. A session saved on a toolkit whose bundle is gone declares every
593
596
  tool (full) until it comes back, with a warning at its first turn. See
594
597
  [Toolkits](configuration.md#toolkits). `prompt:` needs chi 0.72.0 (an older
595
598
  chi refuses the keyword, a load error: a bundle that passes it sets
596
- `requires_chi: ">= 0.72.0"`).
599
+ `requires_chi: ">= 0.72.0"`). `groups:` needs chi 0.121.0: an older chi refuses that keyword
600
+ too, so a bundle passing it sets `requires_chi: ">= 0.121.0"`.
597
601
 
598
602
  ```ruby
599
603
  chi.toolkit "review", base: "lean", add: %w[delegate delegate_result]
600
604
  chi.toolkit "coding", base: "child", prompt: "- Be concise in your responses"
605
+ chi.toolkit "web", base: "lean", groups: %w[chrome db*]
601
606
  ```
602
607
 
603
608
  ### `chi.tool_source(call:, acts_as:, label:, wait:, prefix:)`
@@ -611,12 +616,19 @@ first source's `label`). The index has one group of its own, **`chi`**: the
611
616
  built-ins the session's toolkit leaves out but doesn't remove
612
617
  ([toolkits](configuration.md#toolkits)), each with its registered schema.
613
618
  The two tools are registered once, after the plugins load, and declared per
614
- toolkit: when a plugin registered a source then, or when the toolkit has a
615
- reachable built-in (`full` with no source declares neither). Nothing a
619
+ toolkit when it reaches a source group or has a reachable built-in (`full`
620
+ with no source declares neither). Nothing a
616
621
  source publishes later changes the request's tools, only what a search
617
622
  answers, so the prompt cache keeps: the search tool's description lists the
618
623
  source groups as they were at load, and a `chi` line first, per toolkit.
619
- The mcp bundle is the only source so far (a group per server). A source's
624
+ The mcp bundle is the only source so far (a group per server). `groups:`
625
+ filters the groups find_tools searches, the source descriptions in the
626
+ request, and the MCP tools `call_tool` can reach. Its group snapshot is
627
+ filtered again at each toolkit change; new groups published after load do
628
+ not alter that prompt description. An unreached MCP tool call is refused
629
+ before the guardrails without starting the server; a deferred stub says to
630
+ switch to a toolkit that reaches its group. `/mcp` marks servers outside the
631
+ session's toolkit. A source's
620
632
  group named `chi`, or named like an earlier group, is left out of search,
621
633
  lookup and the description, with one warning per name.
622
634
 
@@ -1377,8 +1389,12 @@ bundles:
1377
1389
  its tools as `(failed: …)`. The saved list stays: the next session tries
1378
1390
  again.
1379
1391
  - **`start: eager`** on a server starts it with every session (in an init
1380
- task, after the Bridge is up), for a server whose start does something
1381
- you want at once.
1392
+ task, after the Bridge is up), even when that session's toolkit does not
1393
+ reach its group. A first-run init task still blocks the first turn until
1394
+ it finishes, even for an unreached group. A stale-cache refresh likewise
1395
+ starts its short-lived server regardless of the toolkit. Switching away
1396
+ from a group refuses its calls without starting a lazy cached server, and
1397
+ does not stop a server already running; switching back reuses it.
1382
1398
  - **`npx -y …@latest` checks the npm registry on every start** (~3.4 s for
1383
1399
  `chrome-devtools-mcp`, against ~0.9 s for the installed binary). The cache
1384
1400
  hides it from new sessions, but not from the first call. For a faster first
data/docs/sessions.md CHANGED
@@ -90,9 +90,9 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
90
90
  **Ordering:**
91
91
 
92
92
  - `Session.list` / `SessionManager.list_sessions` / `GET /api/sessions?sort=&order=&limit=&offset=` default to `updated_at desc` (newest activity first). Also supports `created_at`, `asc`. `X-Total-Count` header when paginated.
93
- - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new sessions start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new session there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat, and the grip on the chat's top edge — the same thin handle as the composer's, its twin — puts them away and brings them back: a drag up (or a click, or Enter/Space on it) hides them, a drag down (or a click) shows them again, and the choice is remembered. A hidden strip leaves its status in the top bar (`N sessions · ● 1 live · ● 1 waiting`, from the cards), and a click on that pill opens All sessions, without showing the strip first. In a window under 900 px wide, where the strip scrolls sideways, the grip stays under it, outside the scroller; a window under 700 px tall hides the strip by itself, so a question or approval card in the stage keeps room, and the grip shows it until the window is tall again. A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new session. The message box grows with its text; drag its top edge to keep it taller (double-click resets). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links, bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked; a link whose text is such a ref alone (github-pr's link to the PR diff) becomes a ref too, and its plain click still goes to the PR. A plugin's refs on an answer (an `after_turn` hook's, see [Refs on the answer](hooks.md#refs-on-the-answer-display-only)) are paired with its links and text whatever the rows touched: they can link a file the session didn't touch, or an issue or a commit, and add their own actions to the ref's menu. A plugin tool's row carries the refs its `refs:` gives (see [Refs on a tool's row](plugins.md#refs-on-a-tools-row)): its title opens the first, and its `▾` menu adds the others as `<ref> · <action>` (the URL on hover); their URL actions work on a phone too. A ref with more than one target shows a small `▾` on hover: it opens a menu of them, in order **PR diff** (a link's), **Open in editor**, **Show in turn** and **Copy path**, and a plain click runs the first (a link's own, else the editor, else show in turn). **Show in turn** scrolls to the tool row that touched the file (looked for in the ref's own step, then its own turn, then the rows before it, then the nearest after it; there, the last one showing the ref's line, else the last one), opens the steps, the stage's steps and the row's diff (or the read showing the line) and flashes the row and the line; an answer's or a step's ref and an approval card's path have it, not a row's own title or lines. It needs no editor, so a viewer without one (a phone on the LAN) gets an answer's refs with **Show in turn** as their click; a row's title and diff lines stay plain text there. On a touch screen a tap opens the menu. A ref with a target is a tab stop: Enter runs its first target, ↓ opens the menu (↑/↓ move, Enter picks, Esc closes back to the ref). Diff and read lines have their click only. The info bar copies `chi --attach <id>` for a terminal. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. Its `think` chip does the same for the thinking level (`/thinking`), and on a chat host its `reasoning` chip for the reasoning history (`reasoning all · session`, `/reasoning-history`; `session.reasoning_history`, null on a native host). A `/thinking` or `/reasoning-history` change that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the chips' Set sends that line itself; see [Changes that restart the prompt cache](configuration.md#changes-that-restart-the-prompt-cache)). It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`); after a `/model` switch the info bar's tooltip adds a `per model:` row per run of turns on one model above those totals (`splash:qwen3-35b · 6 turns · 96.0k in / 9.0k out · ~$0.02`, a switch's prefill under its row: `└ switch prefill 58.0k uncached prompt ≈$0.01`; the timing's `model_segments`, replaced by each `generation_completed`'s; the session cards stay as they were), and right after the switch its ctx % is the new model's window's; and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new session starts on, and a fresh chain link's (see [Session chains](#session-chains); there a pick shows as a `/model` command bubble in the link); it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new session only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
93
+ - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new sessions start in that folder; the header chip says `<project> · all`, and `all` opens the same place as `?scope=all` (every session; a new session there starts in chi web's start folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the start folder's project). A bare `/` (the LAN link, the Home Screen app) is the start folder's project too: the folder chi web runs in, or `web.start_dir` / `chi web --start-dir DIR`; outside a repo it shows every session. A pushed notification opens its session in that session's own project (`/?dir=<its project>#/s/<id>`; `?scope=all` outside a repo). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat, and the grip on the chat's top edge — the same thin handle as the composer's, its twin — puts them away and brings them back: a drag up (or a click, or Enter/Space on it) hides them, a drag down (or a click) shows them again, and the choice is remembered. A hidden strip leaves its status in the top bar (`N sessions · ● 1 live · ● 1 waiting`, from the cards), and a click on that pill opens All sessions, without showing the strip first. In a window under 900 px wide, where the strip scrolls sideways, the grip stays under it, outside the scroller; a window under 700 px tall hides the strip by itself, so a question or approval card in the stage keeps room, and the grip shows it until the window is tall again. A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new session. The message box grows with its text; drag its top edge to keep it taller (double-click resets). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links, bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked; a link whose text is such a ref alone (github-pr's link to the PR diff) becomes a ref too, and its plain click still goes to the PR. A plugin's refs on an answer (an `after_turn` hook's, see [Refs on the answer](hooks.md#refs-on-the-answer-display-only)) are paired with its links and text whatever the rows touched: they can link a file the session didn't touch, or an issue or a commit, and add their own actions to the ref's menu. A plugin tool's row carries the refs its `refs:` gives (see [Refs on a tool's row](plugins.md#refs-on-a-tools-row)): its title opens the first, and its `▾` menu adds the others as `<ref> · <action>` (the URL on hover); their URL actions work on a phone too. A ref with more than one target shows a small `▾` on hover: it opens a menu of them, in order **PR diff** (a link's), **Open in editor**, **Show in turn** and **Copy path**, and a plain click runs the first (a link's own, else the editor, else show in turn). **Show in turn** scrolls to the tool row that touched the file (looked for in the ref's own step, then its own turn, then the rows before it, then the nearest after it; there, the last one showing the ref's line, else the last one), opens the steps, the stage's steps and the row's diff (or the read showing the line) and flashes the row and the line; an answer's or a step's ref and an approval card's path have it, not a row's own title or lines. It needs no editor, so a viewer without one (a phone on the LAN) gets an answer's refs with **Show in turn** as their click; a row's title and diff lines stay plain text there. On a touch screen a tap opens the menu. A ref with a target is a tab stop: Enter runs its first target, ↓ opens the menu (↑/↓ move, Enter picks, Esc closes back to the ref). Diff and read lines have their click only. The info bar copies `chi --attach <id>` for a terminal. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. Its `think` chip does the same for the thinking level (`/thinking`), and on a chat host its `reasoning` chip for the reasoning history (`reasoning all · session`, `/reasoning-history`; `session.reasoning_history`, null on a native host). A `/thinking` or `/reasoning-history` change that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the chips' Set sends that line itself; see [Changes that restart the prompt cache](configuration.md#changes-that-restart-the-prompt-cache)). It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`); after a `/model` switch the info bar's tooltip adds a `per model:` row per run of turns on one model above those totals (`splash:qwen3-35b · 6 turns · 96.0k in / 9.0k out · ~$0.02`, a switch's prefill under its row: `└ switch prefill 58.0k uncached prompt ≈$0.01`; the timing's `model_segments`, replaced by each `generation_completed`'s; the session cards stay as they were), and right after the switch its ctx % is the new model's window's; and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new session starts on, and a fresh chain link's (see [Session chains](#session-chains); there a pick shows as a `/model` command bubble in the link); it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new session only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
94
94
  - Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
95
- - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `#N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `stage_view.js` (the stage view: the running turn pinned above the composer, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`), `markdown_place.js` (where rendered markdown and its refs go: a text as markdown HTML, its `path:line` refs, a final answer's rendering), `images.js` (image chips in the composer: paste, drop and the image button, the upload before a turn, the thumbnails and the lightbox), `composer_grip.js` (the composer's grip and fold: the drag, tap and keys, the fold per visit), `strip_grip.js` (the latest-sessions strip's grip, the composer grip's twin: hides and shows the strip), `storage.js` (the page's per-browser choices in localStorage, guarded: a choice that can't be read or written is not remembered), `unqueue.js` (the × on a message queued for after the turn: takes it back, its text into an empty composer or a toast), `toasts.js` (the page's toast stack: a timer each, sticky ones until acted on, the same text reusing its item), `toast_place.js` (where the toast sits: above the composer card, the stage and an open model panel), `dock_reserve.js` (what floats over the history: the history's bottom reserve (`--dock-h`) at the composer card's and the showing stage's height, a reader at the end kept there, and the toast's place measured for `toast_place.js`), `notice_links.js` (a hook notice line's http(s) URLs as links), `start_page.js` (the start page's model picker and its LLM context and thinking chips, a choice for the next new session only; a fresh chain link's picker and its `/model`), `session_actions.js` (Continue, Archive and its Undo, Delete and Stop, and the start page when the open session is gone), `send_turn.js` (the composer's send: a new session first on the start page, the route, the echo, the image upload, the `/turn` ack, a fast failure's restore and each error's place). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
95
+ - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `#N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `stage_view.js` (the stage view: the running turn pinned above the composer, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`), `markdown_place.js` (where rendered markdown and its refs go: a text as markdown HTML, its `path:line` refs, a final answer's rendering), `images.js` (image chips in the composer: paste, drop and the image button, the upload before a turn, the thumbnails and the lightbox), `composer_grip.js` (the composer's grip and fold: the drag, tap and keys, the fold per visit), `composer_mount.js` (the composer mounted on a page's markup: the message box and its height, the image chips from a paste, a drop or the image button, the grip and fold, ↑/↓ over the prompt history and the keys; the page's own parts come in as hooks: what Enter and Send do, keys taken first, Esc, where the history comes from, the info bar's Stop turn and drop while folded, typing anywhere to open it; its styles are the shared `composer.css`, linked after `tokens.css` by `index.html` and `participant.html`), `strip_grip.js` (the latest-sessions strip's grip, the composer grip's twin: hides and shows the strip), `review_history.js` (a participant's ↑/↓ list on the participant page: their own comments, in localStorage per grant, never the owner's `/api/history`; `participant.js` mounts `composer_mount.js` with it, no `/` list, no delivery choices, images off), `storage.js` (the page's per-browser choices in localStorage, guarded: a choice that can't be read or written is not remembered), `unqueue.js` (the × on a message queued for after the turn: takes it back, its text into an empty composer or a toast), `toasts.js` (the page's toast stack: a timer each, sticky ones until acted on, the same text reusing its item), `toast_place.js` (where the toast sits: above the composer card, the stage and an open model panel), `dock_reserve.js` (what floats over the history: the history's bottom reserve (`--dock-h`) at the composer card's and the showing stage's height, a reader at the end kept there, and the toast's place measured for `toast_place.js`), `notice_links.js` (a hook notice line's http(s) URLs as links), `start_page.js` (the start page's model picker and its LLM context and thinking chips, a choice for the next new session only; a fresh chain link's picker and its `/model`), `session_actions.js` (Continue, Archive and its Undo, Delete and Stop, and the start page when the open session is gone), `send_turn.js` (the composer's send: a new session first on the start page, the route, the echo, the image upload, the `/turn` ack, a fast failure's restore and each error's place). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
96
96
  - Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
97
97
  - The web session page's message box folds away for room to read. Drag the grip on its top edge (the pill that shows on hover; always on a phone) down, past the box by 32 px: the pill turns accent while releasing would hide it, and on release the box folds and the info bar stays as the page's bottom edge. The grip stays as a handle on the info bar's top edge, drawn always: pull it up (8 px opens it, and the box follows the pointer in the same gesture), click or tap it (opens it and focuses the box), or just type on the page (the key lands in the box) to bring it back; Enter or Space on the focused grip folds or opens too. A draft (text or images, which folding never clears) colours the handle with the accent, a session waiting on you with the attention colour; its tooltip says so. Stop turn moves into the info bar while it is folded. The fold is remembered per browser (`chi_composer_collapsed`), an archived session opens folded for the visit, and opening it there, like the page opening it itself (a restored line, a quote, continue →'s opener), is for that visit only. The fold keeps the height the grip set (`chi_composer_floor`): the box opens again at the height it had, and only a double-click on the grip forgets it. That height never takes more than half of the window the box is drawn in: a floor saved in a bigger window is held to that half in a smaller one (a rotated phone, a resized window, another device on the same origin), and the saved floor itself stays, so the height comes back when the room does. A click on the expanded grip hides the box as well: it folds 250 ms after the press, so a second click inside that window is read as a double-click and resets the height instead (a drag, Enter or Space act at once, and a drag after a click takes the wait back). The start page's grip only resizes.
98
98
 
@@ -274,7 +274,8 @@ sessions](cli.md#review-sessions)); both start idle and print the `chi share` li
274
274
  continue keeps it; a fork (`ctx.sessions.fork`) and a delegate child don't.
275
275
  - **The subjects** are checked where the session starts (`ReviewStart.check_subjects`), by the same check a review
276
276
  turn's `read` goes through (`Guardrails::ReviewPaths`): an existing file under the project root, as given and as it
277
- resolves, never a secret file or chi's own. They are what participants read beside the thread.
277
+ resolves, never a secret file or chi's own. They are what participants read beside the thread, and may quote into
278
+ a comment (Annotate: [participants.md](internals/participants.md#what-a-participant-sees)).
278
279
  - **The note:** it starts with a context note from `review` naming the subjects and what a review turn does; a fork's
279
280
  starts with `This review session starts from 3f9a1c2e (2026-10-23 09:12), a working session: it brings that
280
281
  session's recap, not its transcript.` and the recap (as a continue's carry note), so nothing of the source's
data/docs/testing.md CHANGED
@@ -44,7 +44,7 @@ exception's text changes between Ruby versions: write the string yourself).
44
44
  The web specs run in plain node. One that loads lit-html, itself or through a module it imports (`turn_view.js`,
45
45
  `turn_html.js`, `card.js`, `copy.js`, `images.js`, `scope.js`, `command_view.js`, `diff_view.js`, `read_view.js`,
46
46
  `question_cards.js`, `stream_handlers.js`, `stage_view.js`, `bubbles.js`, `cards_place.js`, the chips, `info_bar.js`, `info_bar_model.js`, `lists_view.js`, `context_chips.js`, `families_view.js`,
47
- `chains.js`, `working_set_view.js`, `working_set_rail.js`, `unqueue.js`, `toasts.js`, `notice_links.js`, `start_page.js`, `session_actions.js`, `send_turn.js`, `dock_reserve.js`,
47
+ `chains.js`, `working_set_view.js`, `working_set_rail.js`, `unqueue.js`, `toasts.js`, `notice_links.js`, `start_page.js`, `session_actions.js`, `send_turn.js`, `composer_mount.js`, `dock_reserve.js`,
48
48
  `vendor/lit-html.js`) needs a DOM: `import "./dom_shim.js";` as its first import (happy-dom, a devDependency only,
49
49
  never shipped; lit reads `document` as it loads). The shim's `markupOf(template)`,
50
50
  `htmlOf(nodes)` and `canon(html)` compare markup without lit's marker comments; the DOM writes text with only `&`, `<`, `>`
@@ -157,6 +157,15 @@ Every flake is classified into one of the four patterns, each with its own fix:
157
157
  A second flake of the same pattern at a new site means that pattern needs a seam in lib there (for example an `on_*:`
158
158
  callback the spec can wait on), not another local fix in the spec.
159
159
 
160
+ ### Talking to a real Bridge
161
+
162
+ A session's Bridge answers only a request that names its secret (`X-Chi-Bridge`, from the worker's `bridge.json`;
163
+ see [architecture.md](architecture.md)). A spec that writes its own request to a real Bridge (`Net::HTTP`, a raw
164
+ socket) names it too: `include BridgeAuth` (spec/support/bridge_auth.rb, required by the spec that needs it) gives
165
+ `bridge_auth(session_dir_or_sidecar)`, the header as a Hash, and `bridge_auth_line(...)` for a raw request; or read
166
+ `port` and `secret` from the sidecar in one go, as `BridgeClient.discover` does. A client made with `BridgeClient.new`
167
+ takes `secret:` (nil for a fake Bridge that doesn't check it).
168
+
160
169
  ## Bundle specs: the test kit
161
170
 
162
171
  A shipped bundle's plugin and hook files are tested through the bundle test kit (`samagotchi/testing`,
@@ -221,7 +230,9 @@ A builder runs these while working, not the full suite: the specs the change tou
221
230
  files it changed (`lib/samagotchi/foo/bar.rb` → `spec/foo/bar_spec.rb`, `bar_*_spec.rb`…), the specs that read
222
231
  docs or source as text (its `RULES`: add one when a full run catches what the names missed) and the golden of a changed
223
232
  file's layer (any `terminal_ui/*.rb` → `spec/terminal_ui_golden_spec.rb`; `turn_flow.rb` and `turn_note.rb`, whose
224
- words the REPL prints, pick it by a rule), then the other name matches (a spec named after several changed files first, e.g. `engine_toolkit_spec.rb` for `engine.rb` and
233
+ words the REPL prints, pick it by a rule). Changes to `tool_declarations.rb` and `system_prompt.rb` also pick the
234
+ effective-model, request, prompt-snapshot and full system-prompt goldens because they hash the request's prompt and
235
+ tools. Then come the other name matches (a spec named after several changed files first, e.g. `engine_toolkit_spec.rb` for `engine.rb` and
225
236
  `toolkit.rb`; then one beside the file, so `plugin/context.rb` picks `spec/samagotchi/plugin/*context*` before
226
237
  `spec/guardrails/context_spec.rb`), then the specs that name a method the change touches (the innermost `def` around a
227
238
  changed line, read with Prism; one `git grep` for up to 12 names; names under 4 characters, `initialize`, `to_s`,
@@ -196,6 +196,8 @@ module Samagotchi
196
196
  end
197
197
  return failure(worker_gone(id)) unless reply
198
198
 
199
+ return failure(BridgeSecret::REFUSED) if reply.secret_refused?
200
+
199
201
  case reply.status
200
202
  when 200 then nil
201
203
  when 409