samagotchi 0.110.0 → 0.111.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -1
  3. data/docs/architecture.md +8 -5
  4. data/docs/cli.md +3 -0
  5. data/docs/configuration.md +44 -0
  6. data/docs/internals/engine.md +39 -10
  7. data/docs/internals/participants.md +1 -1
  8. data/docs/internals/remote-sessions.md +8 -4
  9. data/docs/internals/web-push.md +210 -0
  10. data/docs/internals/working-set.md +7 -6
  11. data/docs/sessions.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/config.rb +4 -0
  14. data/lib/samagotchi/engine.rb +146 -490
  15. data/lib/samagotchi/engine_snapshots.rb +223 -0
  16. data/lib/samagotchi/event_fanout.rb +236 -0
  17. data/lib/samagotchi/model_binding.rb +138 -0
  18. data/lib/samagotchi/reply_wait/poll.rb +11 -10
  19. data/lib/samagotchi/reply_wait.rb +30 -3
  20. data/lib/samagotchi/send_command/wait_target.rb +10 -3
  21. data/lib/samagotchi/send_command.rb +2 -1
  22. data/lib/samagotchi/session.rb +12 -3
  23. data/lib/samagotchi/session_access/remote.rb +7 -2
  24. data/lib/samagotchi/session_access.rb +7 -3
  25. data/lib/samagotchi/session_manager.rb +5 -0
  26. data/lib/samagotchi/turn_endings.rb +137 -0
  27. data/lib/samagotchi/version.rb +1 -1
  28. data/lib/samagotchi/web/app.rb +27 -1377
  29. data/lib/samagotchi/web/grants.rb +3 -12
  30. data/lib/samagotchi/web/message_display.rb +274 -0
  31. data/lib/samagotchi/web/participant_access.rb +1 -1
  32. data/lib/samagotchi/web/private_files.rb +31 -0
  33. data/lib/samagotchi/web/public/app.js +30 -6
  34. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  35. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  36. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  37. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  38. data/lib/samagotchi/web/public/index.html +4 -0
  39. data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
  40. data/lib/samagotchi/web/public/notifications.js +178 -3
  41. data/lib/samagotchi/web/public/notify.js +1 -1
  42. data/lib/samagotchi/web/public/push.js +95 -0
  43. data/lib/samagotchi/web/public/stage_view.js +8 -0
  44. data/lib/samagotchi/web/public/sw.js +101 -0
  45. data/lib/samagotchi/web/public/turn_events.js +22 -0
  46. data/lib/samagotchi/web/push/attention.rb +105 -0
  47. data/lib/samagotchi/web/push/client.rb +103 -0
  48. data/lib/samagotchi/web/push/crypto.rb +126 -0
  49. data/lib/samagotchi/web/push/notifier.rb +230 -0
  50. data/lib/samagotchi/web/push/store.rb +295 -0
  51. data/lib/samagotchi/web/push.rb +73 -0
  52. data/lib/samagotchi/web/push_routes.rb +104 -0
  53. data/lib/samagotchi/web/server.rb +26 -2
  54. data/lib/samagotchi/web/session_access_routes.rb +19 -8
  55. data/lib/samagotchi/web/session_control.rb +441 -0
  56. data/lib/samagotchi/web/session_create.rb +384 -0
  57. data/lib/samagotchi/web/session_show.rb +411 -0
  58. data/lib/samagotchi/worker.rb +3 -2
  59. metadata +25 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '097197f78e75225202f6e84b40f7449f063416402e3ac1820015a824c82fc64b'
4
- data.tar.gz: 7ac596f01db65fc81761acccfade8c99c5543b963539083652cbb3022923ae13
3
+ metadata.gz: 2153d4c9c7afa58d9d06d330d1839a021032df3502f515d4a093bad7f9dde347
4
+ data.tar.gz: 9ac11fddf172afa0eb4c5ea520d3e64471c0b2502753539fafcce28aa4e99294
5
5
  SHA512:
6
- metadata.gz: 3b5aca203f09584369fe954da86514837eabd3addf2e3bc91f3f74ae1202f726b4170c99c1b591a999ffb8c23da7ce3f51d2f746ecc81a84fabef376c238b7fb
7
- data.tar.gz: d4df1f1ba87e37f81af6440c55c1aa3d0749c7abc734a53d9ec7356a35e9a513684495feb550138032f67c0e493cdacc97ce69ea29407fa407b1a07d7b22a20d
6
+ metadata.gz: cc73ef6cdd220a1dfb83b4e1b6f51ba4e42c80eff05ae661363b06d4d53b9bf4c0c2c954173997475571518ed98e7f589afe0eb9251787e3a894eab8dd1fec00
7
+ data.tar.gz: 9105631a20fcfe1e6889dbc904e146c4fcc68c75e5879111e589efe515ecaaecd4e03b6e7a3f389eb674bd1b0c0bb6a1420dca439b01cdefe04ed508d0830c9a
data/CHANGELOG.md CHANGED
@@ -8,6 +8,42 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.111.1] - 2026-10-25
12
+
13
+ ### Fixed
14
+
15
+ - `chi --remote NAME send --wait` with no message (and one whose message merged into the running turn) ends
16
+ `canceled` (`worker_stopped`) when that turn's worker dies and the next worker settles it between two of chi web's
17
+ 30 s wait slices, as it does on this machine, instead of waiting until `--timeout`.
18
+ - chi web: turning the phone's bell off and on again now replaces a subscription iOS dropped silently, instead of
19
+ keeping the dead one in `subscriptions.json`.
20
+ - chi web: a Home Screen app left open for days picks up a newer service worker (after `chi update`) when it comes
21
+ back to the front.
22
+
23
+ Update with `chi update` (the system bundle 0.111.1), then restart `chi web`.
24
+
25
+ ## [0.111.0] - 2026-10-24
26
+
27
+ ### Added
28
+
29
+ - chi web: push notifications to a phone's Home Screen app (approvals and questions) over an https public URL
30
+ (`chi web --tunnel tailscale`): add chi web to the Home Screen, tap the bell. An answered question's notification goes
31
+ when the app opens (every push shows one: iOS drops an app that pushes silently). New settings `web.push.enabled` (default on) and `web.push.contact`. See
32
+ docs/configuration.md (Phone notifications).
33
+
34
+ ### Fixed
35
+
36
+ - `chi send --new --wait` now knows the id of the turn the new session's first prompt runs, so a `--timeout` or
37
+ Ctrl-C there says `still running: chi send --wait --turn <T> <ID>` instead of the vaguer "waits again", and
38
+ `chi send --wait --turn <T> <ID>` gets that turn's reply (`--format json` carries its `turn_id`). chi web's
39
+ create answers the first turn's id as `first_turn_id`, so the same holds for `chi --remote … send --new --wait`.
40
+ - Web: a session's stream dropping mid-turn (a phone briefly offline) no longer shows "canceled (worker exited)"
41
+ while the worker runs on; the page re-reads first and shows it only when no worker took the turn. A re-read that
42
+ fails (the network still down) is retried with a backoff, and a stage that lets go of a turn clears its status row
43
+ and chip, so it no longer reads "thinking · step 1" for a turn it stopped following.
44
+
45
+ Update with `chi update` (the system bundle 0.111.0), then restart `chi web`.
46
+
11
47
  ## [0.110.0] - 2026-10-24
12
48
 
13
49
  ### Added
@@ -4228,7 +4264,9 @@ and long-lived sessions.
4228
4264
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
4229
4265
  hotkey panel that send selected text or the clipboard to your sessions.
4230
4266
 
4231
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.110.0...HEAD
4267
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.111.1...HEAD
4268
+ [0.111.1]: https://github.com/dm1try/samagotchi/compare/v0.111.0...v0.111.1
4269
+ [0.111.0]: https://github.com/dm1try/samagotchi/compare/v0.110.0...v0.111.0
4232
4270
  [0.110.0]: https://github.com/dm1try/samagotchi/compare/v0.109.0...v0.110.0
4233
4271
  [0.109.0]: https://github.com/dm1try/samagotchi/compare/v0.108.0...v0.109.0
4234
4272
  [0.108.0]: https://github.com/dm1try/samagotchi/compare/v0.107.0...v0.108.0
data/docs/architecture.md CHANGED
@@ -153,7 +153,7 @@ agent logic and can be used without any terminal rendering; the UI is a thin lay
153
153
  | UI | `Samagotchi::TerminalUI` | Interactive REPL (Reline), rendering (ANSI, spinner, status line), REPL commands. Delegates all core work to an `Engine`. |
154
154
  | Model loops and adapters | `KernelLoop`, `LLM::ChatLoop`, `Samagotchi::Client`, `LLM::OpenAIChat`, `LLM::AnthropicMessages`, `LLM::HTTP` | The model↔tool loops and the HTTP adapters they talk through (see "Model loops and adapters"). |
155
155
  | Bridge (SSE/HTTP) | `Samagotchi::Bridge`, `SessionManager` | The **single live client transport**: an SSE read stream + HTTP POST turn/cancel/answer surface that attaches to a worker's existing `Engine` via `Engine#subscribe`. Every session worker starts it (bound `127.0.0.1`, no auth, localhost-only). |
156
- | Web (Rack) | `Samagotchi::Web::App`, `SessionManager` | Single-port `127.0.0.1:4567` control plane via `rack`+`webrick` (serve `index.html` + `/api/*`). With `web.host: lan` (or one of this machine's IPv4 addresses) the server also listens on that address, where every request but this machine's needs the access token (`App#token_gate`: the `chi_token` cookie or `Authorization: Bearer`; [CLI](cli.md#chi-web-on-your-phone)). Experimental: behind an auth proxy (`web.public_url`, `Web::ProxyGate`) every request needs the token, this machine's too, and one that came through the proxy also the configured identity header for the owner ([configuration.md](configuration.md#behind-an-auth-proxy)). A request goes through, in order: the Host check (`local_host_header?`: loopback names, the LAN address, the public host), the cross-site check (`cross_site?`: Origin this server's or the public origin, no `Sec-Fetch-Site` cross-site/same-site on a POST or `/api`), the frame check (`framed?`: a frame/embed/object load from another origin, 403 `framed`; every HTML answer carries `X-Frame-Options: DENY` and `frame-ancestors 'none'`), the token gate (`token_gate` → `principal_for`, `proxied_principal` for a proxied request; the `?token=` / `POST /api/token` trade sets the cookie, `__Host-chi_token` through the proxy; with `web.public_url` a direct request takes Bearer only, and chi's token-less `/api/info` probe gets `probe_info`), then the route's permission (`Principal#permits?`). It talks to each worker's Bridge (`App#relay`, `bridge_get`; `/stream` proxies it) and reads the session files; no `Engine` of its own. Markdown (`web.markdown`) is rendered here only (`Web::MarkdownRenderer`): the answers it serves, card bodies, and `POST /api/render` for a text the page already shows. `bin/chi web` entrypoint. |
156
+ | Web (Rack) | `Samagotchi::Web::App`, `SessionManager` | Single-port `127.0.0.1:4567` control plane via `rack`+`webrick` (serve `index.html` + `/api/*`). With `web.host: lan` (or one of this machine's IPv4 addresses) the server also listens on that address, where every request but this machine's needs the access token (`App#token_gate`: the `chi_token` cookie or `Authorization: Bearer`; [CLI](cli.md#chi-web-on-your-phone)). Experimental: behind an auth proxy (`web.public_url`, `Web::ProxyGate`) every request needs the token, this machine's too, and one that came through the proxy also the configured identity header for the owner ([configuration.md](configuration.md#behind-an-auth-proxy)). A request goes through, in order: the Host check (`local_host_header?`: loopback names, the LAN address, the public host), the cross-site check (`cross_site?`: Origin this server's or the public origin, no `Sec-Fetch-Site` cross-site/same-site on a POST or `/api`), the frame check (`framed?`: a frame/embed/object load from another origin, 403 `framed`; every HTML answer carries `X-Frame-Options: DENY` and `frame-ancestors 'none'`), the token gate (`token_gate` → `principal_for`, `proxied_principal` for a proxied request; the `?token=` / `POST /api/token` trade sets the cookie, `__Host-chi_token` through the proxy; with `web.public_url` a direct request takes Bearer only, and chi's token-less `/api/info` probe gets `probe_info`), then the route's permission (`Principal#permits?`). It talks to each worker's Bridge (`App#relay`, `bridge_get`; `/stream` proxies it) and reads the session files; no `Engine` of its own. Markdown (`web.markdown`) is rendered here only (`Web::MarkdownRenderer`): the answers it serves, card bodies, and `POST /api/render` for a text the page already shows. `App` keeps the gate, `ROUTES` and the helpers the handlers share; its handlers live in mixins it includes, each a `Web::` module with private methods: `SessionAccessRoutes` (`web/session_access_routes.rb`), `ParticipantAccess` (`web/participant_access.rb`), `ParticipantsAdmin` (`web/participants_admin.rb`), `ParticipantReview` (`web/participant_review.rb`), `MessageDisplay` (`web/message_display.rb`), `SessionShow` (`web/session_show.rb`), `SessionCreate` (`web/session_create.rb`), `SessionControl` (`web/session_control.rb`), `PushRoutes` (`web/push_routes.rb`) and `ProxyGate` (`web/proxy_gate.rb`). Exact paths in `GATE_FREE_PATHS` are served before the token gate (the fragment trade's script, and the Home Screen app's manifest, service worker and icons). `bin/chi web` entrypoint. |
157
157
  | Sessions | `Samagotchi::Session`, `SessionManager` | File-based `~/.local/state/samagotchi/sessions/<uuid>.json` + sidecar `input/`/`output/`; retention (14d/500) + ordering (`updated_at desc`). |
158
158
 
159
159
  - `bin/chi` in REPL mode (see `LaunchMode` above) builds `TerminalUI`. `TerminalUI#run` is the single
@@ -530,7 +530,7 @@ model: [internals/remote-sessions.md](internals/remote-sessions.md).
530
530
  |---|---|---|---|---|
531
531
  | `GET /api/sessions/resolve?id=` | owner | an id or a unique prefix | 200 `{id}` | 404 `not_found`; 400 `ambiguous_id` with `ids` |
532
532
  | `GET /api/sessions/summaries` | owner | `dir` (a folder: its project; none: every project), `cwd`, `live=1`, `archived=1`, `tests=0`, `sort`, `order`, `limit` | 200: the rows `chi sessions list` prints (`SessionManager.session_summaries`: `status`, `live`, `busy`, `owner`, `recap`, `ctx_pct`, `waiting`, `waiting_id`, `title`, `preview`, …) | 400 `invalid_dir`, `invalid_cwd` |
533
- | `POST /api/sessions` (more fields) | owner | `toolkit`, `tools`, `no_tools`, `parent_answers`; `client_id` (`cli:send`/`cli:answer`: the first line is checked as chi send checks it); `check_vision: true` (images follow) | 201 adds `model_warning` and `parent_answers_warning` (a level looser than config.yml's, capped) | 400 `invalid_toolkit`, `invalid_parent_answers`, `invalid_continues` (beside `continues`); 403 `parent_answers_refused`, `parent_continue_refused`; 422 `no_vision` |
533
+ | `POST /api/sessions` (more fields) | owner | `toolkit`, `tools`, `no_tools`, `parent_answers`; `client_id` (`cli:send`/`cli:answer`: the first line is checked as chi send checks it); `check_vision: true` (images follow) | 201 adds `model_warning`, `parent_answers_warning` (a level looser than config.yml's, capped) and `first_turn_id` (the id the prompt it starts with gets for its turn; null for an `idle` create) | 400 `invalid_toolkit`, `invalid_parent_answers`, `invalid_continues` (beside `continues`); 403 `parent_answers_refused`, `parent_continue_refused`; 422 `no_vision` |
534
534
  | `POST /api/sessions/:id/turn` (more fields) | owner | `client_id` as above; `check_vision: true` with `images` | as before | 403 `parent_answers_refused`, `parent_continue_refused`; 422 `no_vision` |
535
535
  | `POST /api/sessions/:id/wait/prepare` | owner | `question_id` (absent: the pending one; `null`: none), `turn`, `created`, `busy` (+ `queue`) | 200 `{session_id, cursor, baseline: {messages, question_id, last_turn}, own_turn, known_turn, running, live, settled, last_turn_id}` (`WaitStart`) | 409 `busy`, `waiting` (`SendCommand::Busy`'s words, and its fields: `question`, `parent_strategy`) |
536
536
  | `POST /api/sessions/:id/wait` | owner | `cursor`, `baseline`, `turn`, `queued`, `ended_before`, `owner_grace`, `timeout` (capped at 30 s) | 200: `ReplyWait::Result`'s fields (`status`: `done`, `waiting_for_answer`, `no_reply`, `error`, `stopped`, `worker_gone`, or `timeout`: ask again; `text`, `file`, `question` as `PendingQuestion#to_file`, `outcome`, `limit`, `error_kind`, `retryable`, `cancel_reason`, `stopped_by`, `kept_steps`, `turn_id`, `origin`; never the session) and `parent_strategy` `{name, allowed, origin}` (`ParentReport.strategy_for`, this server's config.yml) | 400 `invalid_wait`; 503 `server_stopping` |
@@ -610,9 +610,10 @@ so later requests and resumed sessions pair them, and their reasoning (`thinking
610
610
  turn goes on from the run it resumes, `reasoning_since:`). It has its own system prompt
611
611
  (no raw-prompt tool declarations; the tools go as JSON schemas).
612
612
 
613
- `Engine#build_stream_event_handler` is the one seam both loops' streams pass
614
- through: with a `:generation_progress` hook registered it feeds a
615
- `Hooks::StreamWatch` each chunk's thinking and text after the UIs had it. A
613
+ `Engine#build_stream_event_handler` (`EventFanout`'s, which the Engine forwards
614
+ to) is the one seam both loops' streams pass through: with a
615
+ `:generation_progress` hook registered it feeds a `Hooks::StreamWatch` each
616
+ chunk's thinking and text after the UIs had it. A
616
617
  hook's `stop_generation` cancels the generation's own controller (a child of
617
618
  the turn's, `CancellationController#generation`), and each loop takes the cut
618
619
  as an empty answer and asks again.
@@ -700,6 +701,8 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
700
701
  | LLM context strategies | `llm_context_strategy.rb`, `llm_context_view.rb`, `llm_context_stale.rb`, `llm_context_forget.rb` | What a request sends of the history (none, stale, forget) ([internals/llm-context-forget.md](internals/llm-context-forget.md)). |
701
702
  | Session chains | `session_continue.rb` (`SessionContinue`), `session_chain.rb` (`SessionChain`) | A session that continues an earlier one. `SessionChain.opener_for` gives the text web `continue →` prefills in the new link: the previous link's own first typed message when that link continues one itself, else nil (the 201's `opener`; not saved) ([sessions.md](sessions.md)). |
702
703
  | Review sessions | `review_start.rb` (`ReviewStart`), `session_review.rb` (`SessionReview`, the session's `review` field), `review_command.rb` (`ReviewCommand`, `chi review`), `review_apply.rb` (`ReviewApply`) | A session made to be shared: idle, its subjects project files checked by `Guardrails::ReviewPaths`, a fork of a working session from its recap (`--from`); Apply quotes a participant's review turn into the owner's composer ([sessions.md](sessions.md#review-sessions), [internals/participants.md](internals/participants.md)). |
704
+ | chi web's routes | `web/app.rb` (`Web::App`: the gate, `ROUTES`, the shared helpers), `web/message_display.rb` (`Web::MessageDisplay`: saved messages and cards as display items, `POST /api/render`), `web/session_show.rb` (`Web::SessionShow`: `GET /api/sessions/:id` with its `cards`, `tail` and `timing` re-reads, `GET …/output`, the timing payload), `web/session_create.rb` (`Web::SessionCreate`: `POST /api/sessions`, a continue, a review fork), `web/session_control.rb` (`Web::SessionControl`: answer, command, turn and images, cancel, stop, restart, archive, delete), `web/push_routes.rb` (`Web::PushRoutes`: `/api/push`, its subscriptions and test) | The handlers `App`'s `ROUTES` name, in the file that holds their kind; each mixin is included in `App` and reads its helpers and ivars, so `app.send(:handle_show, …)` and the specs that stub `App` methods work as before. |
705
+ | Web Push | `web/push.rb` (`Web::Push`: `Runtime`, `preflight!`, the `web.push.*` settings), `web/push/crypto.rb` (`Push::Crypto`: RFC 8291 aes128gcm, RFC 8292 VAPID), `web/push/store.rb` (`Push::Store`, `Store::Sub`: `web-push/vapid.pem`, `subscriptions.json`), `web/push/client.rb` (`Push::Client`, `Client::Result`), `web/push/attention.rb` (`Push::Attention`: notify.js's rules in Ruby), `web/push/notifier.rb` (`Push::Notifier`, a hub subscriber with a sender thread), `web/push_routes.rb` (`Web::PushRoutes`), `web/private_files.rb` (`Web::PrivateFiles`, shared with the grants), `public/sw.js`, `public/push.js`, `public/manifest.webmanifest` | chi web pushes approvals and questions to the owner's Home Screen app over an https public URL: the Notifier turns the hub's new approvals and questions into show pushes after a hold (each carries the keys still open, and the badge; no close push: every push shows a notification); the bell (`notifications.js`) subscribes the device, and the service worker and the page close the answered ones. See [internals/web-push.md](internals/web-push.md). |
703
706
  | Behind an auth proxy | `web/public_url.rb` (`Web::PublicUrl`: `web.public_url`, `web.proxy.identity_header`, `web.proxy.owner`), `web/proxy_gate.rb` (`Web::ProxyGate`, mixed into App; `RawHeaderNames`, prepended to WEBrick's request by `Web::Server`), `web/link_codes.rb` (`Web::LinkCodes`: the links' one-time codes), `proxy_trace.rb` (`ProxyTrace`), `web/public/token_fragment.js`, `web/public/auth_lost.js`, `web/tunnel.rb` + `web/tunnel/tailscale.rb` (`Web::Tunnel`, `Web::Tunnel::Tailscale`: `chi web --tunnel tailscale`, Tailscale Serve set for one run and the PublicUrl it makes) | Experimental: chi web behind an auth proxy. Every request needs the token; a proxied one's owner also needs the identity header (one clean value under one name); the `__Host-` cookie, an absolute `Location` from the public URL, https links with a one-time code in the fragment; the page's lost-sign-in banner. Bridges refuse proxy traces ([configuration.md](configuration.md#behind-an-auth-proxy)). |
704
707
  | Participants | `participants.rb` (`Participants`, the `participants.json` sidecar), `share_command.rb` (`ShareCommand`, `chi share`), `session_participants.rb`, `participants_watch.rb`, `web/grants.rb` (`Web::Grants`), `web/principal.rb` (`Web::Principal`), `web/participant_access.rb` (`Web::ParticipantAccess`: the participant's gate and routes), `web/participants_admin.rb` (`Web::ParticipantsAdmin`: the owner's invite, list, revoke, cap and Apply routes), `web/participant_review.rb` + `web/participant_view.rb` (`Web::ParticipantReview`, `Web::ParticipantView`), `web/participant_stream_filter.rb`, `guardrails/review_boundary.rb` + `guardrails/review_paths.rb` | The people or agents an owner invites to review one session through chi web: their grants and gate, what they read, their read-only review turns ([internals/participants.md](internals/participants.md), [guardrails.md](guardrails.md)). |
705
708
  | Analytics | `analytics_file.rb` (`AnalyticsFile`), `session_metrics/totals.rb` (`SessionMetrics::Totals`), `session_metrics/model_segment.rb` (`SessionMetrics::ModelSegment`) | A session's `analytics.json` (`SessionMetrics#persist`); the sums over its turn records (`Totals`: tokens, cost, speeds, kept as running counters) and their split per model after a `/model` switch (`ModelSegment`: a run of turns on one model, the `model_segments` rows); `session_metrics/cost.rb` (`SessionMetrics::Cost`): the spend `ctx.cost` reads; `session_metrics/usage.rb` (`SessionMetrics::Usage`): the work `ctx.usage` reads. `session_metrics/side_totals.rb` (`SessionMetrics::SideTotals`) and `side_usage.rb` (`SideUsage`): the side requests' sums, apart from the turns'. |
data/docs/cli.md CHANGED
@@ -1178,6 +1178,9 @@ access token", paste the token there (the part of the link after
1178
1178
  - Off the home Wi-Fi: `chi web --tunnel tailscale` gives chi web an https
1179
1179
  name on your tailnet (Tailscale Serve) for its run and prints its link's QR
1180
1180
  code; see [Tailscale Serve](configuration.md#tailscale-serve-your-tailnet).
1181
+ - Notifications on the phone, also while chi web isn't open there: on the tunnel's https link, Add to Home Screen,
1182
+ open the Chi app and tap its bell; approvals and questions then push to the phone (see
1183
+ [Phone notifications](configuration.md#phone-notifications-web-push)).
1181
1184
 
1182
1185
  ### Remote sessions
1183
1186
 
@@ -1768,6 +1768,48 @@ Tailnet Lock is off: a device Tailscale's servers add to your tailnet could reac
1768
1768
  [chi web on your phone](cli.md#chi-web-on-your-phone)): token only, plain http in the browser (WireGuard encrypts
1769
1769
  the path) so no `Secure` cookie and no notifications, and no identity.
1770
1770
 
1771
+ ### Phone notifications (Web Push)
1772
+
1773
+ chi web as an app on your iPhone's Home Screen that buzzes (the Watch too) when a session waits on an **approval** or
1774
+ a **question**, also while the app is closed. A tap opens that session; once it is answered anywhere (the Mac, the
1775
+ phone, `chi answer`), its notification goes when you open the app (or swipe it away). Every
1776
+ push shows a notification: iOS turns off an app's pushes after a few that show none.
1777
+
1778
+ What it needs:
1779
+
1780
+ - chi web on an **https** public URL: `chi web --tunnel tailscale` ([above](#chi-web---tunnel-tailscale)) or a
1781
+ `web.public_url`. On the LAN's plain http there is no push (no secure context: the bell is hidden); use the tunnel.
1782
+ - iOS 16.4 or later, and chi web **added to the Home Screen**: iOS gives push to a Home Screen app only.
1783
+ - No Apple account or registration: chi web signs its pushes with a key of its own (VAPID).
1784
+
1785
+ Turning it on, on the phone: open the `Proxied:` link in Safari → Share → **Add to Home Screen** → open the **Chi**
1786
+ app → tap the **bell** at the top → Allow. A test notification arrives at once. Tap the
1787
+ bell again to turn it off on that phone. In a browser tab the bell stays the tab's own notifications (while the tab
1788
+ is in the background), as before.
1789
+
1790
+ - **What is sent**: the session's name and the reason ("needs approval", "needs an answer"), never the question or
1791
+ the command, as the tab's notification on the Mac. The session's name shows on the lock screen and the Watch.
1792
+ - **What Apple sees**: the push is end-to-end encrypted to the phone; Apple's push service sees that this machine
1793
+ pushes to that device, and when. A subscription holds no device name and no personal data (an opaque address on
1794
+ `web.push.apple.com` and two keys); chi names it itself ("iPhone · 2026-10-24", from the User-Agent's device class
1795
+ and the date).
1796
+ - **When**: an approval or a question that is still open 3 s after it was asked (3.5 s for a delegated child's
1797
+ approval, which its parent may relay instead); one push per question; a step-limit question, a failed or a long
1798
+ turn don't push yet. A push arrives even while you look at the page (the phone can't tell chi web that it is in
1799
+ front yet).
1800
+ - **The files**: `$XDG_STATE_HOME/samagotchi/web-push/` (next to `web-token`): `vapid.pem` (chi web's key) and
1801
+ `subscriptions.json` (the devices), 0600 in a 0700 folder; chi web refuses to start when others can read them.
1802
+ Subscriptions belong to the public URL they were made on: after renaming the machine or the tailnet, turn the bell
1803
+ on again on the phone (the old ones are never sent to). iOS can drop a subscription without telling chi web; if
1804
+ notifications stop arriving, turn the bell off and on again: the new subscription replaces the dead one.
1805
+ - `web.push.enabled: false` turns it off; `web.push.contact` (a `mailto:` address or an `https://` URL; default:
1806
+ the public URL) is the contact the push services see in chi web's key. A value that is neither stops `chi web`
1807
+ (Apple refuses it); a `[chi web] the push service refused chi's VAPID token` line means the contact or the key was
1808
+ refused, and pushes stop until chi web restarts.
1809
+ - The app keeps its own cookie (iOS copies Safari's when it is added, 400 days): `chi web --new-token` logs it out
1810
+ (it shows the "needs chi web's access token" page; paste the token there). Pushes keep coming meanwhile.
1811
+ - chi web not running: no pushes (Serve answers 502; a tap couldn't open the page anyway).
1812
+
1771
1813
  ## All settings
1772
1814
 
1773
1815
  Every setting below takes the three forms described in
@@ -1898,6 +1940,8 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
1898
1940
  | `web.public_url` | | | Experimental. The https origin people open `chi web` at through an auth proxy (`https://host` or `https://host:port`, no path); set, every request needs the access token, this machine's too, and links use it. A value that isn't an https origin stops `chi web` from starting. See [Behind an auth proxy](#behind-an-auth-proxy). |
1899
1941
  | `web.proxy.identity_header` | | | Experimental. The request header the proxy puts the signed-in user's email in (`X-Forwarded-Email` for oauth2-proxy), checked next to the token: only one the proxy sets *and* replaces. Unset or `none`: the token alone decides. See [Behind an auth proxy](#behind-an-auth-proxy). |
1900
1942
  | `web.proxy.owner` | | | Experimental. Your email as the identity header carries it (any case): a proxied request is the owner only with this identity and the token. Unset: nobody is the owner by identity. Needs `web.proxy.identity_header`. |
1943
+ | `web.push.enabled` | `true` | | Push notifications to a phone's Home Screen app (approvals and questions) when the bell there is on. Does anything only with an https public URL (`web.public_url`, or `chi web --tunnel tailscale`'s) and once a device subscribed; `false` turns the pushes and the routes off. See [Phone notifications](#phone-notifications-web-push). |
1944
+ | `web.push.contact` | the public URL | | The contact the push services see in chi web's VAPID token: a `mailto:` address or an `https://` URL. Anything else stops `chi web` from starting (Apple refuses it). |
1901
1945
  | `history.file` | state dir | | Prompt history path, shared by the TUI and the web composer. |
1902
1946
  | `no_interrupt` | `false` | `--no-interrupt` | Raise the step limit of a turn to 1000 (or `turn.max_iterations`, when larger); a top-level key. |
1903
1947
  | `no_default_input` | `false` | `--no-default-input` | Don't pre-fill `default.input`; a top-level key. |
@@ -93,7 +93,7 @@ StandardError in run_turn:
93
93
  → if turn.ended (it came from the after_turn/session_end hooks): logged as post_turn_error, re-raised
94
94
  → else: end_turn(turn, "failed", save: true) (a save that fails there is logged, turn_end_save_failed)
95
95
  # Both loops call LLM::FailedTurn.attach(e, conversation) before re-raising, so
96
- # failed_messages keeps the loop's partial conversation (tool iterations included),
96
+ # failed_messages (TurnEndings) keeps the loop's partial conversation (tool iterations included),
97
97
  # else turn.messages, with TurnNote.failed(summary, continued: turn.continue) at the tail.
98
98
  # :turn_failed {error_class, message, duration_ms}; a provider error adds
99
99
  # error_kind, retryable, host and summary.
@@ -157,7 +157,7 @@ Stopped before the model was asked (prepare_turn returned false):
157
157
  The Engine does not talk to models directly. `Engine#backend` picks the loop the effective model's host speaks:
158
158
 
159
159
  ```
160
- Engine#backend → backend_for(effective_model) # EffectiveModel.of(@host_registry, @model_choice)
160
+ Engine#backend → backend_for(effective_model) # EffectiveModel.of(@host_registry, @model_binding.choice)
161
161
  → host entry chat? (an api in LLM::Adapters::BY_API: openai, anthropic): LLM::ChatLoop (one per Engine, built on first use)
162
162
  → else: LLM::NativeBackend wrapping the Engine's KernelLoop
163
163
  ```
@@ -199,7 +199,9 @@ KernelLoop#run(messages, max_iterations:, on_stream_event:, cancel_controller:,
199
199
 
200
200
  `turn.conversation` is the loop's own copy, not the Engine's `turn.messages`. A failure attaches it to the error
201
201
  (`LLM::FailedTurn.attach`), so `Engine#failed_messages` can keep it; an Interrupt (Ctrl-C) too, for
202
- `Engine#ctrl_c_messages` (see Error paths).
202
+ `Engine#ctrl_c_messages` (see Error paths). Both are `TurnEndings`', with the other pure builders of a turn's end
203
+ (`kept_messages`, `failed_event`, `turn_summary`, `stop_facts`); the Engine forwards to it and keeps `end_turn`,
204
+ `complete_turn`, `store_answer_display` and `record_last_turn`.
203
205
 
204
206
  ### ChatLoop (the chat APIs)
205
207
 
@@ -397,13 +399,14 @@ what a resolve gives, all without a `/model`. Frozen, so any thread may hold one
397
399
  - The Engine's readers take one (`Engine#effective_model`); those whose fallback always covered the resolve go
398
400
  through `SessionModelSettings#model_setting` (`session_model_settings.rb`: the Engine forwards its readers of the session's settings there). A turn takes one in `turn_settings` (before the hooks) and one in `generate` (after
399
401
  them); `system_prompt`, `steer_cut_after` (at `:turn_started`) and the turn-end warm-up take their own;
400
- `stats_snapshot` and `session_state_snapshot` take one each (the latter nil-tolerant).
402
+ `stats_snapshot` and `session_state_snapshot` take one each (the latter nil-tolerant; both `EngineSnapshots`',
403
+ `engine_snapshots.rb`, which the Engine forwards to).
401
404
  - Never resolved (they hold on a broken resolve): `effective_model_ref`, `bare_model_name`,
402
405
  `guardrail_model_name`, `sync_model_key!`, `served_model(probe: false)` (`HostRegistry#model_ref`/`#bare_name`).
403
406
  - Readers on other threads: the Bridge (`/state`, `/stats`, the SSE reset, a steer's cut wait), the worker's
404
407
  command thread, the recap thread and plugin threads (`session_model_recap_target`), the REPL.
405
- - `ModelChoice` (`@model_choice`) holds the name and the typed name as one value, so a reader on another thread
406
- never pairs one model's name with another's typed name.
408
+ - `ModelChoice` (`ModelBinding#choice`) holds the name and the typed name as one value, so a reader on another
409
+ thread never pairs one model's name with another's typed name.
407
410
  - `EffectiveModel.of_session` builds a saved session's (no worker): `Web::App`, `LLMContextStrategy.for_session`.
408
411
 
409
412
  `SessionSetting` names the four per-session settings (llm_context, thinking, reasoning_history, toolkit): the
@@ -411,6 +414,21 @@ session's own value, the commands that move each chip, its chip from an Effectiv
411
414
  readers, a saved session's chip, and how the web takes a worker snapshot's. The session state, `/stats` (no
412
415
  toolkit), a `:command_ran` and the web's session JSON list them from it.
413
416
 
417
+ ### ModelBinding (the choice and its profile)
418
+
419
+ `ModelBinding` (`model_binding.rb`) holds the session's model choice, the profile given to `Engine.new` (until a
420
+ switch), the prompt profile resolved for the choice (kept as a `[choice, resolution]` pair) and the memory overlay
421
+ keys (`#sync_model_key!`), under one mutex; it is ToolkitBinding's counterpart, and the Engine forwards
422
+ `profile_resolution`, `refresh_profile!`, `store_profile`, `apply_profile`, `resolve_profile`, `sync_model_key!`
423
+ and `model_key` to it.
424
+
425
+ - `#switch!` (`Engine#switch_model!`'s swap) is one assignment under the mutex: a reader on another thread sees
426
+ the old pair or the new one. It drops the given profile and the kept resolution.
427
+ - `#store_profile` keeps and applies a resolution only while the choice is still the one it was resolved for,
428
+ by identity (`equal?`, not `==`): a `/stats` probing on a Bridge thread while `/model` runs keeps nothing.
429
+ - `#apply_profile` runs under the mutex: the system prompts built for another profile are dropped, then
430
+ `KernelLoop#use_profile!`. Nothing else takes the mutex.
431
+
414
432
  ### ThinkingNotices
415
433
 
416
434
  What a turn's thinking level leads to, said once per session and host (`thinking_notices.rb`): an effort the
@@ -476,7 +494,8 @@ drain hands the message to the model at a boundary, or until the turn ends (`Ste
476
494
 
477
495
  ### The turn's sink (`on_event`) and the observers
478
496
 
479
- `Engine#emit_event` sends each turn event to the turn's sink (`on_event`, the REPL's renderer) and then to the
497
+ `Engine#emit_event` (`EventFanout#emit_event`, `event_fanout.rb`, which also builds the loops' stream handler and
498
+ emits the working set) sends each turn event to the turn's sink (`on_event`, the REPL's renderer) and then to the
480
499
  persistent observers (`Engine#subscribe`), which get a copy with a monotonic `event_seq`. The observers are the
481
500
  metrics, the debug log, the thinking tails, and in a worker the Bridge, which streams to the web and the attached
482
501
  TUI. Errors in the sink or an observer are caught; they never break the turn.
@@ -584,8 +603,11 @@ hook registry (`@hooks.fire`), not on the sink or the observers. The other hook
584
603
  ```
585
604
  Engine initialization (in this order, among other things):
586
605
  @turn_state, @steer_cut (SteerCut: its GenerationPhase and WaitingSteer)
587
- @host_registry, @model_choice (ModelChoice: the name and the name as typed); @model_settings (SessionModelSettings: the choice and the session read at use); @client = effective_model.client
588
- @given_profile = profile or nil (resolved on first need, so building an Engine makes no network call)
606
+ @event_fanout (EventFanout: emit_event, the stream handler, the working set; its lookups read at call time)
607
+ @turn_endings (TurnEndings: the turn endings' builders)
608
+ @host_registry, @model_binding (ModelBinding: the ModelChoice, i.e. the name and the name as typed, and the given
609
+ profile or nil: resolved on first need, so building an Engine makes no network call); @model_settings
610
+ (SessionModelSettings: the choice and the session read at use); @client = effective_model.client
589
611
  MemoryBundle::SystemBundle.ensure! (the shipped system bundle installed; a failure is logged, not raised)
590
612
  @event_routes (EventRoutes: where a card or a notice goes: held while the plugins load, an anytime command's, an init task's, a turn event, else announced or held)
591
613
  @hook_routes (HookRoutes: notices (through @event_routes), hook questions, stop_turn/stop_generation, steer attribution)
@@ -609,9 +631,12 @@ Engine initialization (in this order, among other things):
609
631
  @native_backend = LLM::NativeBackend.new(kernel: @kernel)
610
632
  @prompt_builder = SystemPrompt.new(...)
611
633
  @session_observer with the metrics, the log subscriber and the thinking tails subscribed (subscribe_session_observers)
634
+ @engine_snapshots (EngineSnapshots: session_state_snapshot and stats_snapshot), built last: nothing reads a
635
+ snapshot while the Engine is built
612
636
 
613
637
  Model switching, Engine#switch_model!(model_name, persist_default:, typed:):
614
- → checks the host (ModelProfile.check_host!), sets @model_choice (name and typed name in one assignment)
638
+ → checks the host (ModelProfile.check_host!), sets the choice (ModelBinding#switch!: name and typed name in
639
+ one assignment)
615
640
  → drops the given profile and the profile resolution (resolved again on first need; one that another
616
641
  thread finishes for the old model is neither kept nor applied: #store_profile)
617
642
  → sync_model_key!, @prompt_builder.reset! (the system prompt is built again)
@@ -810,6 +835,10 @@ Paths are under `lib/samagotchi/`.
810
835
  | File | Role |
811
836
  |---|---|
812
837
  | `engine.rb` | Engine: turn lifecycle, event emission, plugins, model switching |
838
+ | `event_fanout.rb` | `EventFanout`: how an event reaches the turn's sink and the observers (`emit_event`), the loops' stream handler, the working set's emit and dedupe; the Engine forwards |
839
+ | `engine_snapshots.rb` | `EngineSnapshots`: the UIs' read model, `session_state_snapshot` (cheap, never raises) and `stats_snapshot` (may probe `/props`); the Engine forwards |
840
+ | `model_binding.rb` | `ModelBinding`: the model choice and the prompt profile resolved for it, under one mutex, and the overlay keys; the Engine forwards |
841
+ | `turn_endings.rb` | `TurnEndings`: what each turn ending keeps and announces (the kept messages and their `TurnNote`, `failed_event`, `turn_summary`, `stop_facts`); the Engine forwards |
813
842
  | `turn_state.rb` | Cross-thread turn state (running, controller, sink, steers, activity clock) |
814
843
  | `steer_cut.rb` | `SteerCut`: the Engine's seam for a message cutting the generation |
815
844
  | `generation_phase.rb`, `waiting_steer.rb` | Whether a steer may cut the running generation, and the steer that waits to |
@@ -234,7 +234,7 @@ fields: a field added to an event later reaches nobody until it is named there.
234
234
  and never after a key word whose value is still to come, `Redact::OPEN_VALUE`; past 1600 with no such space,
235
235
  the line's last unbroken run and the rest of that line go out as one `[redacted]`). The owner's stream never
236
236
  goes through it (a byte pipe).
237
- - **The conversation** (`.messages`, over `App#messages_for_display(parts: true)`): a user message only when it is a
237
+ - **The conversation** (`.messages`, over `App#messages_for_display(parts: true)`, `Web::MessageDisplay`): a user message only when it is a
238
238
  turn's prompt (it has the turn's id) or a person's merged line, and never one from a source that isn't a person's;
239
239
  so the owner's `!` line and its output, a delegate's report (a wake turn's too), and the notes chi adds (a declined
240
240
  Continue) never show. A turn no person sent (its record's `automatic`, `SessionMetrics`: a task's or a plugin's,
@@ -98,7 +98,11 @@ registry flag (`--recap-base-url` …) is this machine's config: `LOCAL_ONLY` wi
98
98
  5. **The wait.** `ParentWait#wait_for_reply` → `Remote#wait_reply` loops on `POST …/wait` with
99
99
  `timeout: min(time left, WAIT_SLICE)` (30 s). The server runs `ReplyWait.call` for at most `WAIT_MAX_SECONDS` (30 s)
100
100
  and returns the `ReplyWait::Result` fields plus `parent_strategy`. On a `timeout` status the client asks again until
101
- its own `--timeout` runs out, at which point it builds the `timeout` result itself. A 503 `server_stopping` (chi web
101
+ its own `--timeout` runs out, at which point it builds the `timeout` result itself. Each slice is a fresh `ReplyWait`
102
+ there, so a `timeout` answer carries the slice's `running_look` (`ReplyWait::RunningLook`: the running turn it saw
103
+ with its checkpoint mark, `{last_end, claimed}`, else null) and the client sends it back on the next slice, as is:
104
+ a dead worker's turn settled between two slices ends the wait `canceled` (`worker_stopped`) as it does locally,
105
+ not at the timeout. A 503 `server_stopping` (chi web
102
106
  shutting down mid-wait: `ReplyWait`'s `cancelled:` returns `:canceled`) is retried after `RETRY_AFTER` (1 s).
103
107
  `HTTPTransport::READ_TIMEOUT` (75 s) is set above the longest answer, a wait slice or a create's wait for its worker.
104
108
  6. **The report.** `ParentReport.report` prints it as it does locally. It uses `Remote#parent_strategy`, which
@@ -166,12 +170,12 @@ with a "Machine-local" comment; the rest branch on `@access.remote?` (`SendComma
166
170
  | Check | Local (in the command) | Under `--remote` |
167
171
  |---|---|---|
168
172
  | Images to a model that can't see them (`SendCommand#vision_refusal`) | client | server: `check_vision: true` on the create and turn routes → 422 `no_vision` |
169
- | A parent's `/continue yes`, a loosening `/parent-answers` (`SendCommand::ParentGate`) | client, before any target | server: `App#parent_gate` on create/turn for a `cli:send`/`cli:answer` `client_id` → 403; each target refuses its own |
173
+ | A parent's `/continue yes`, a loosening `/parent-answers` (`SendCommand::ParentGate`) | client, before any target | server: `App#parent_gate` (`Web::SessionCreate`) on create/turn for a `cli:send`/`cli:answer` `client_id` → 403; each target refuses its own |
170
174
  | `--parent-answers` looser than config.yml (`Guardrails::ParentPolicy.capped_note`) | client | server: the create's `parent_answers_warning` |
171
175
  | A parent's option on `chi answer` (`AnswerCommand#parent_refusal`) | client, then the worker | skipped on the client; the worker checks (the answer route forwards `cli:answer`), and its 403 `parent_answer_refused` passes through |
172
176
  | What a parent may pick, for the report (`ParentReport.strategy_for`) | client | server: the wait route's `parent_strategy` |
173
177
  | `send --wait` busy check (`SendCommand::Busy`) | client | server: `wait/prepare` with `busy: true` → 409 `busy`/`waiting` with the `Busy` fields (`question`, `parent_strategy`); the client words it (`Busy#message(via:)`) |
174
- | `--continues`' previous link and model (`SendCommand#continued_model`) | client | server: `handle_continue` |
178
+ | `--continues`' previous link and model (`SendCommand#continued_model`) | client | server: `handle_continue` (`Web::SessionCreate`) |
175
179
  | `--toolkit`, `--tool`, `--set` (`Toolkit.from_flags`, `SessionConfig.from_flags`) | client | server: sent as `SetupWords` → 400 `invalid_*` → exit 2 |
176
180
  | `--dir` (default: the cwd) | client, `File.directory?` | required and absolute on the client (`remote_new_options`); chi web's `scope_dir` checks it exists |
177
181
  | `sessions list`'s default project (`ProjectScope.root_for(Dir.pwd)`) | client | every project's; `--scope=project` needs `--cwd` (a folder there) |
@@ -212,7 +216,7 @@ with a "Machine-local" comment; the rest branch on `@access.remote?` (`SendComma
212
216
  `web.public_url`, its host exactly, port included). `http://studio.local`
213
217
  gets a 403 `forbidden`, which the client words as "use the LAN IP". The check guards against DNS rebinding.
214
218
  `cross_site?` lets CLI clients through because they send no `Origin` or `Sec-Fetch-Site`.
215
- - **`client_id` only tightens.** `handle_question_answer` forwards `client_id` only when it is `ClientId::CLI_ANSWER`
219
+ - **`client_id` only tightens.** `handle_question_answer` (`Web::SessionControl`) forwards `client_id` only when it is `ClientId::CLI_ANSWER`
216
220
  and drops any other value, so a remote answer is held to the parent rules and can't pass for the user's own. On
217
221
  create and turn, a `cli:send` / `cli:answer` id adds checks (`CLI_SEND_IDS`) and never removes any.
218
222
  - **A remote attach's `client_id`** (`tui:<pid>@<host>`, P4-D6) is forwarded as given on `/turn` and `/command`, as
@@ -0,0 +1,210 @@
1
+ # Web Push (internals)
2
+
3
+ chi web pushes to the owner's phone when a session waits on an approval or a question: the phone runs chi web as a
4
+ Home Screen app (iOS 16.4+), its bell subscribes the device, and chi web, the only sender, pushes through the
5
+ device's push service (Apple's `web.push.apple.com`). The user side is in
6
+ [configuration.md](../configuration.md#phone-notifications-web-push). This page is how it is built (P1 of the plan:
7
+ approvals and questions; the step-limit question, cards, failed and done turns, presence and the device list come
8
+ later).
9
+
10
+ ```
11
+ SessionHub ──emit──▶ Notifier#call(event) ──Queue──▶ sender thread
12
+ Attention rules hold 3 s → still open?
13
+ Crypto.encrypt + VAPID → Client#deliver(endpoint)
14
+ 201 ok · 404/410 drop the device · 403 BadJwtToken stop + log
15
+ service worker ◀── Apple (web.push.apple.com) ◀──────── {v, kind: show, tag, title, body, url, badge, open?}
16
+ ```
17
+
18
+ **Every push chi sends shows a notification.** WebKit removes a subscription after 3 pushes that show no
19
+ notification ("Enforce silent push quota", WebKit commit 108ccb44), and Apple keeps answering 201 for it (Apple
20
+ Developer Forums 719990), so chi can't see it happen. The user check of 2026-10-24 lost its subscription this way:
21
+ 7 question pushes and 6 close pushes in ~6 min, then a question push and a test push both answered 201 and never
22
+ showed. So there is no close push and no badge-only push: answered notifications leave the lock screen through the
23
+ next show's `open` list (the service worker) or when the app opens (the page).
24
+
25
+ ## The crypto (`web/push/crypto.rb`)
26
+
27
+ OpenSSL only, no gem (the `web-push` gem would bring `jwt` and the `openssl` gem for ~60 lines); it runs on Ruby 3.3's
28
+ openssl gem (`KDF.hkdf`, `PKey::EC#derive`, `to_octet_string`). Base64url is `pack("m0")` (`base64` is no default
29
+ gem from Ruby 3.4).
30
+
31
+ - `Crypto.encrypt(plaintext, p256dh:, auth:)`: RFC 8291 §3.4 on RFC 8188's aes128gcm, one record (rs 4096, the 0x02
32
+ last-record delimiter, no padding), a fresh salt and sender key per message. `spec/web/push/crypto_spec.rb` pins
33
+ RFC 8291 Appendix A byte for byte (the RFC's fixed sender key and salt).
34
+ - `Crypto.vapid_authorization(endpoint, key:, sub:)`: RFC 8292, `vapid t=<JWT>, k=<public key>`; the JWT is ES256
35
+ (OpenSSL's DER signature turned into r‖s), `aud` the endpoint's origin, `exp` now + 12 h, `sub` the contact.
36
+ - `Crypto.valid_sub?`: a `mailto:` with an `@` or an `https://` URL with a host. Apple answers 403
37
+ `{"reason":"BadJwtToken"}` for anything else (the spike: `chi@localhost`), and 201 for the public origin, which is
38
+ the default contact (`web.push.contact` overrides it).
39
+
40
+ `spec/support/web_push_receiver.rb` is a device's side for the specs (its key pair, decryption, the JWT check), so a
41
+ spec reads what chi web sent rather than trusting it.
42
+
43
+ ## The store (`web/push/store.rb`)
44
+
45
+ `$XDG_STATE_HOME/samagotchi/web-push/` (`Store.dir`: Grants' rule, next to the sessions dir), a 0700 folder with 0600
46
+ files, one per machine, never in config.yml:
47
+
48
+ - `vapid.pem`: chi web's one P-256 key, made the first time (`Store#vapid_key`). Replacing it makes every device's
49
+ subscription useless (the push services tie it to the key): the bell's subscribe drops a subscription made with
50
+ another key and makes a new one.
51
+ - `subscriptions.json`: `{"version": 1, "subscriptions": [{id, endpoint, keys: {p256dh, auth}, origin, label,
52
+ created_at, last_ok_at, last_error, prefs}]}`, read as `Store::Sub` values. `id` is the first 8 hex of the
53
+ endpoint's SHA-256: the only name of a device outside the store. `Sub#public_h` and `#inspect` never carry the
54
+ endpoint or the keys; neither do the log lines.
55
+
56
+ `Store#add` checks what `pushManager.subscribe` gave, one message per refusal (the route answers it as 400
57
+ `invalid_subscription`): an endpoint and both keys; https on port 443 without userinfo, at most 2048 bytes; a host in
58
+ `Store::PUSH_SERVICES` (Apple, Google's FCM, Mozilla, `*.notify.windows.com`; a `services:` seam for specs, not
59
+ config): chi web POSTs to that URL, so it never takes an arbitrary host; `p256dh` a 65-byte uncompressed point,
60
+ `auth` 16 bytes. The same endpoint again replaces the old entry. `replaces:` (the device's id from its last
61
+ subscribe, which the page keeps) drops that entry on this origin too, before the `MAX_SUBS` cap is counted, and
62
+ yields it (the route logs `push_replaced`); its own endpoint is never dropped that way, and an unknown id is ignored.
63
+ That is how a subscription iOS revoked silently (Apple keeps answering 201, so `:gone` never comes) leaves the store:
64
+ the user turns the bell off and on, the page has no live subscription to delete, and the new one replaces it. Every write is a read-change-write under
65
+ `store.lock` (chi web's request threads and the sender thread both write). A `subscriptions.json` that doesn't read
66
+ lists no device and is never written over. `Store#check_permissions!` uses `Web::PrivateFiles.check!`, the check the
67
+ grants use, worded with what the folder holds; `Server.launch` runs it (`Push.preflight!`, with the contact and the
68
+ key) before any tunnel goes up, and a refusal is one `Error:` line, exit 1.
69
+
70
+ Each subscription keeps the public origin it was made on; the Notifier sends only to this run's. A renamed machine or
71
+ tailnet leaves the old ones stale (never sent to: a tap would open an origin that no longer serves chi).
72
+
73
+ ## The client (`web/push/client.rb`)
74
+
75
+ `Client#deliver(sub, payload, ttl:, urgency:)` encrypts the payload's JSON, POSTs it over TLS (open 5 s, read 10 s,
76
+ no redirects, no retry) with `Content-Encoding: aes128gcm`, `TTL`, `Urgency` and the VAPID header, and answers a
77
+ `Client::Result`: `:ok` (200/201/202), `:gone` (404/410: the device dropped it), `:bad_jwt` (403 with
78
+ `BadJwtToken`), `:failed` (anything else; a connection error as its class name or `timeout`, never its message). The
79
+ transport is injectable: `spec/web/push/client_spec.rb` runs a fake push service (WEBrick, plain http through the
80
+ injected transport) that decrypts each body with the device's key and checks the headers and the JWT.
81
+
82
+ ## The payload and the service worker
83
+
84
+ The service worker is the only reader of the payload (JSON, at most `Notifier::OPEN_LIMIT`, 3000 bytes, with
85
+ `open`; one aes128gcm record leaves 3993 bytes of plaintext under Web Push's 4096: 86 header, 1 delimiter, 16 tag):
86
+
87
+ - `{"v":1,"kind":"show","tag":KEY,"title":…,"body":…,"url":"/#/s/<id>","badge":N,"open":[KEY…]}`: TTL 12 h,
88
+ `Urgency: high`. `open` only while the whole list fits the limit; the test push (`chi:test`) has neither `open`
89
+ nor `badge`.
90
+
91
+ `badge` and `open` are the keys still open in the Notifier's summaries (`Attention.open_keys`: a pending approval
92
+ or question, a hook's too, not relayed), so an approval pushed by an earlier chi web run still counts after a
93
+ restart. `public/sw.js` is a classic script (module service workers on iOS are unverified) at scope `/`. Every push
94
+ event ends in `showNotification`: a payload that isn't a readable v1 show (no data, bad JSON, another `v`, a stale
95
+ `close` from an older chi web, any other kind) shows a fallback (`chi` / "open chi", tag `chi:other`, url `/`), and a
96
+ show iOS refuses is shown once more without its icon and data. A show calls `showNotification(title, {body, tag,
97
+ icon, data: {url}})` first; then, when `open` is an array, closes every `getNotifications()` whose tag is a
98
+ question's `"<sid>:<qid>"` (never `chi:*`), isn't the new one and isn't in `open` (after the show: closing before it
99
+ fails on iOS; errors are swallowed); then `setAppBadge(badge)` when `badge` is a number. A tap focuses the app's first window and navigates it to the url, else opens one (only a path of chi web's); `install`
100
+ skips waiting, `activate` claims the clients. A Home Screen app can stay open for days, and the browser checks for
101
+ a newer `sw.js` only on `register()` and on navigations: so when the page comes back in front (`backInFront` in
102
+ `notifications.js`) it calls `reg.update()` (errors swallowed), at most once per `SW_UPDATE_MS` (an hour; counted
103
+ from the register, which has just checked), with the bell on or off, never without push. No fetch handler and no caching: chi web has no offline mode, and a
104
+ fetch handler would change how the page loads. `spec/web/public/sw.test.js` runs it in a `vm` with a fake worker
105
+ scope.
106
+
107
+ The manifest, `sw.js` and the icons are `App::GATE_FREE_PATHS` (exact paths, still after the Host, cross-site and
108
+ frame checks): Chrome fetches a manifest without credentials, and none of them holds a secret. The icons are
109
+ rendered from `favicon.svg` by `script/render-icons` (Playwright's Chromium), full bleed and opaque (iOS fills
110
+ transparency with black).
111
+
112
+ ## The Notifier (`web/push/notifier.rb`) and its rules (`web/push/attention.rb`)
113
+
114
+ `Push::Attention` is the server's port of `web/public/notify.js` for what P1 pushes, so a push and the tab's
115
+ notification ring for the same thing under the same key:
116
+
117
+ - `Attention.for(prev, next)`: a new `pending_question` id that isn't `relayed_to` (absent when nil: the summary
118
+ compacts it), whose **reason** (notify.js `questionReason`: `approval`, `continue`, anything else incl. `hook` is
119
+ a question) is approval or question. Filtering on the reason, never on `kind == "question"`, keeps hook questions.
120
+ A delegated child's (`parent_id`) approval or hook question holds `RELAY_HOLD` 3.5 s (notify.js `RELAY_HOLD_MS`):
121
+ its parent may relay it. The key is `"<sid>:<qid>"`.
122
+ - `Attention.closed_keys(prev, next)`: the question answered, replaced, newly relayed, or the session gone.
123
+ - `Attention.text(summary, reason)`: notify.js `attentionText`'s words: the session's title, first preview or last
124
+ prompt clipped to 61, and "needs approval" / "needs an answer". Never the question or the command.
125
+
126
+ `Notifier#call(event)` is a `SessionHub` sink. **The hub calls it under its Monitor**, from the tick thread and also
127
+ from chi web's request threads (`SessionHub#touch`, from the stop/archive/delete routes), so it only enqueues: a push
128
+ is a 0.5 s https call. `emit` already rescues a raising sink. The sink keeps the last summary per session and turns
129
+ each `session` / `session_gone` event into a `Show` (with its text) or a `Close` (it was answered anywhere) on a
130
+ Queue.
131
+
132
+ **Seeding**: `Notifier#start(hub)` subscribes with `snapshot: true` and takes the summaries as the seed, so what is
133
+ open already never pushes. The hub's projection is empty before its first scan, so `Server.start` runs `hub.scan`
134
+ once after the `chi` line's subscribe and before `notifier.start(hub)`, then `hub.start`. Events that reach the sink
135
+ before the seed is in (they are newer than the snapshot) are applied after it.
136
+
137
+ The sender thread holds each show (`HOLD` 3 s, or the event's own), then pushes it only while its key is still in
138
+ `Attention.open_keys` of the summaries (read under the sink's mutex at flush time), to every subscription on this
139
+ run's origin, with that list as `open` and its size as `badge`. A close only drops a show still in its hold: it
140
+ pushes nothing. Results: `:ok` stamps the device's
141
+ `last_ok_at`; `:gone` removes it; `:failed` keeps its code in `last_error` and logs `push_failed`; `:bad_jwt` logs
142
+ one `push_bad_jwt` warning (on chi web's terminal too) and stops all pushes until chi web restarts. Log lines carry
143
+ `device:` (the short id), `code:`, `ms:`, `kind:`: never the endpoint, keys or text. `#stop` drops pending holds and
144
+ joins with a timeout; `Server.start`'s ensure calls it. Specs drive `#pump` with a moved clock
145
+ (`start(hub, sender: false)`), and one example runs the real thread with a client that blocks.
146
+
147
+ ## The routes (`web/push_routes.rb`)
148
+
149
+ `:owner` rows in `App::ROUTES` (so `participant_gate_spec` refuses each to a participant), handlers in
150
+ `Web::PushRoutes`; `@push` is the run's `Push::Runtime` (store, client, origin), nil when push is off (no https
151
+ public URL, or `web.push.enabled: false`):
152
+
153
+ - `GET /api/push[?endpoint=E]` → `{enabled, public_key, origin, this: {id, label}|null}`; off: `enabled: false` and
154
+ no key.
155
+ - `POST /api/push/subscriptions {subscription: {endpoint, keys}, replaces?: id}` → 201 `{id, label}`; the label is
156
+ chi's own ("iPhone · <date>", from the User-Agent's device class: iPhone, iPad, Mac, else Browser); `replaces`
157
+ drops the device's earlier entry (`Store#add`).
158
+ - `DELETE /api/push/subscriptions {endpoint}` → 204.
159
+ - `POST /api/push/test {endpoint?}` → a test push to that device or every one on this origin, answered per device
160
+ (`{devices: [{id, label, status, code, ms}]}`); the bell sends one after subscribing.
161
+
162
+ With push off the three writes answer 409 `push_off`.
163
+
164
+ ## The page (`public/push.js`, `public/notifications.js`)
165
+
166
+ `push.js` holds the pure parts: `pushSupported({win, nav})` (a secure context, `serviceWorker`, `PushManager`, and a
167
+ standalone app: `matchMedia("(display-mode: standalone)")` or iOS's `navigator.standalone`), `urlBase64ToBytes`,
168
+ `subscribe({reg, key, post})` (drops a subscription made with another key), `unsubscribe({reg, del})`. In the Home
169
+ Screen app the bell (`notifications.js`) registers `/sw.js`, asks `GET /api/push` with its subscription's endpoint,
170
+ and is "push to this phone" when chi web pushes: on while chi web has this device. A tap asks permission from the tap
171
+ (iOS asks only from a user gesture), subscribes, posts it (with `replaces`: the id chi web answered last time,
172
+ kept in localStorage as `chi_push_device`, `PUSH_DEVICE_KEY`; a storage that throws only loses the replace) and sends
173
+ a test push; a tap when on unsubscribes and deletes, and forgets the id, unless there was no subscription left to
174
+ delete (revoked meanwhile): then the id stays for the next subscribe to replace. The tab's own OS notification is left to the push there; the title count stays. In a browser tab, or with
175
+ chi web not pushing, the bell is the in-tab one, unchanged (no duplicate desktop pushes).
176
+
177
+ **The device's tidy-up** (`syncDevice`, the Home Screen app with the bell on): closes the shown notifications whose
178
+ tag is a question's (`push.js isQuestionTag`, so `chi:test` stays) and isn't open in the page's `byId`
179
+ (`push.js openPushKeys`: `Attention.open_keys`'s rule, with notify.js's `questionReason`), and sets or clears the app
180
+ badge (`navigator.setAppBadge` / `clearAppBadge`); the bell turned off clears it. It runs once push is known, when
181
+ the page comes back in front, and after each hub frame while visible (so answering on the Mac with the app open
182
+ clears it at once), one at a time with one rerun. Never before the first snapshot (an empty `byId` would close
183
+ everything) and never on a project's page (`createNotifications({scoped})`, app.js passes `!!scope.dir`: its
184
+ `/api/events?dir=` leaves out the other projects' sessions).
185
+
186
+ Accepted staleness: an approval answered elsewhere keeps its banner until the app opens (the worker's cleanup on the next push doesn't work on iOS yet: see iOS facts)
187
+ (or a swipe), and the icon badge may be too high until then; a show already handed to Apple just before its answer
188
+ can land after the page's tidy-up and stay until the next one. Unverified on iOS: `getNotifications()` and
189
+ `setAppBadge` from the page (a Window); the worker's cleanup still runs on the next push if they do nothing.
190
+
191
+ ## iOS facts (the spike, 2026-10-24, on an iPhone)
192
+
193
+ - **Close by tag lookup** (the P1b phone check, 2026-10-24, iOS 26): inside a push handler, after `showNotification`,
194
+ the unfiltered `getNotifications()` lists the shown ones with their tags, but `close()` on one from that list left
195
+ it on the lock screen; one from `getNotifications({tag})` closed. So `sw.js`'s cleanup reads the tags from the list
196
+ and closes through the tag lookup. Not yet known whether that is a WebKit bug (unfiltered close never probed alone). **But** on a real
197
+ question pair (A shown, answered, B pushed ~6 s later, the new worker confirmed) A's banner still stayed: the tag
198
+ lookup closed a banner minutes old (the probe) but not a seconds-old one. So the worker's cleanup doesn't work on iOS
199
+ yet (cause unknown: timing, or the banner still presenting); the page's cleanup when the app opens does.
200
+ - **No replace by `tag`**: two notifications with one tag both stay, and closing same-tag ones inside the push
201
+ handler before `showNotification` doesn't remove the older one. A separate close push that only closed
202
+ `getNotifications({tag})` did, but it shows nothing (see the quota below), so the worker now closes after its show.
203
+ - **The service worker can't see the app in front** (`clients.matchAll` gave 0 windows with the app open) and a
204
+ banner shows anyway: "don't push while I'm looking" has to be decided server-side, from the pages' presence (P2).
205
+ - **Silent pushes**: the spike's 4 close pushes in a row didn't revoke the subscription, but that was luck or lazy
206
+ enforcement: the user check's 6 did (WebKit's quota is 3 pushes that show nothing; see the top of this page).
207
+ - iOS sent the cookie with the manifest, `sw.js` and icon fetches, but Chrome fetches manifests without credentials:
208
+ they are gate-free rather than relying on that.
209
+ - `navigator.setAppBadge(n)` from the service worker sets the icon's badge; a push mirrors to the Watch.
210
+ - The Home Screen app copies Safari's cookie when it is added and keeps its own jar after (400 days).