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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +100 -1
- data/bin/chi +6 -1
- data/docs/architecture.md +35 -5
- data/docs/cli.md +33 -8
- data/docs/configuration.md +74 -20
- data/docs/internals/participants.md +111 -12
- data/docs/internals/prompt-caching.md +7 -6
- data/docs/internals/remote-sessions.md +6 -1
- data/docs/internals/web-push.md +4 -2
- data/docs/plugins.md +24 -8
- data/docs/sessions.md +4 -3
- data/docs/testing.md +13 -2
- data/lib/samagotchi/answer_command.rb +2 -0
- data/lib/samagotchi/bridge.rb +56 -13
- data/lib/samagotchi/bridge_client/event_stream.rb +34 -3
- data/lib/samagotchi/bridge_client.rb +58 -19
- data/lib/samagotchi/bridge_secret.rb +35 -0
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +1 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/engine.rb +1 -0
- data/lib/samagotchi/kernel_loop.rb +5 -0
- data/lib/samagotchi/participant_messages.rb +5 -0
- data/lib/samagotchi/participants.rb +16 -6
- data/lib/samagotchi/plugin/api.rb +6 -3
- data/lib/samagotchi/review_command.rb +3 -1
- data/lib/samagotchi/run_options.rb +3 -1
- data/lib/samagotchi/session_access/contract.rb +4 -1
- data/lib/samagotchi/session_access/local.rb +15 -4
- data/lib/samagotchi/session_access/remote.rb +43 -7
- data/lib/samagotchi/session_access.rb +11 -1
- data/lib/samagotchi/session_manager.rb +1 -1
- data/lib/samagotchi/sessions_command.rb +1 -2
- data/lib/samagotchi/share_command.rb +29 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -0
- data/lib/samagotchi/terminal_ui/local_link.rb +18 -3
- data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +37 -1
- data/lib/samagotchi/toolkit.rb +50 -26
- data/lib/samagotchi/toolkit_binding.rb +32 -0
- data/lib/samagotchi/toolkit_command.rb +23 -0
- data/lib/samagotchi/tools/deferred_tools.rb +10 -2
- data/lib/samagotchi/tools/delegate_relay.rb +4 -0
- data/lib/samagotchi/tools/registry.rb +20 -6
- data/lib/samagotchi/tools/tool_index.rb +103 -20
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +117 -43
- data/lib/samagotchi/web/grants.rb +127 -16
- data/lib/samagotchi/web/link_codes.rb +24 -15
- data/lib/samagotchi/web/participant_access.rb +69 -13
- data/lib/samagotchi/web/participant_exposure.rb +44 -0
- data/lib/samagotchi/web/participant_review.rb +71 -9
- data/lib/samagotchi/web/participants_admin.rb +12 -6
- data/lib/samagotchi/web/proxy_gate.rb +18 -3
- data/lib/samagotchi/web/public/annotate_bar.css +18 -0
- data/lib/samagotchi/web/public/annotate_bar.js +10 -3
- data/lib/samagotchi/web/public/app.js +66 -155
- data/lib/samagotchi/web/public/composer.css +73 -0
- data/lib/samagotchi/web/public/composer_grip.js +21 -15
- data/lib/samagotchi/web/public/composer_mount.js +196 -0
- data/lib/samagotchi/web/public/forms.css +12 -0
- data/lib/samagotchi/web/public/index.html +14 -88
- data/lib/samagotchi/web/public/level_chip.js +2 -2
- data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
- data/lib/samagotchi/web/public/notifications.js +58 -8
- data/lib/samagotchi/web/public/participant.html +46 -10
- data/lib/samagotchi/web/public/participant.js +101 -18
- data/lib/samagotchi/web/public/participant_annotate.js +75 -0
- data/lib/samagotchi/web/public/participant_model.js +53 -1
- data/lib/samagotchi/web/public/participants_panel.js +37 -13
- data/lib/samagotchi/web/public/question_card.js +15 -1
- data/lib/samagotchi/web/public/question_cards.js +11 -2
- data/lib/samagotchi/web/public/review_history.js +60 -0
- data/lib/samagotchi/web/public/scope.js +12 -8
- data/lib/samagotchi/web/public/stage_frame.js +9 -4
- data/lib/samagotchi/web/public/stage_view.js +4 -4
- data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
- data/lib/samagotchi/web/push/notifier.rb +17 -3
- data/lib/samagotchi/web/running.rb +7 -4
- data/lib/samagotchi/web/server.rb +34 -11
- data/lib/samagotchi/web/session_create.rb +7 -7
- data/lib/samagotchi/web/start_dir.rb +26 -0
- data/lib/samagotchi/web/tunnel.rb +10 -1
- data/lib/samagotchi/worker_sidecar.rb +22 -11
- 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.
|
|
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
|
|
55
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
363
|
-
|
|
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
|
|
83
|
-
`ToolkitBinding#apply!`)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
data/docs/internals/web-push.md
CHANGED
|
@@ -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":
|
|
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).
|
|
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
|
|
615
|
-
|
|
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).
|
|
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),
|
|
1381
|
-
|
|
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)
|
|
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`,
|