samagotchi 0.111.1 → 0.113.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 +53 -1
- data/bin/chi +8 -0
- data/docs/architecture.md +2 -2
- data/docs/cli.md +4 -2
- data/docs/configuration.md +41 -21
- data/docs/internals/web-push.md +132 -42
- data/docs/sessions.md +3 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/push_command.rb +188 -0
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/session.rb +4 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +49 -3
- data/lib/samagotchi/web/public/chains.js +9 -6
- data/lib/samagotchi/web/public/format.js +20 -5
- data/lib/samagotchi/web/public/index.html +47 -5
- data/lib/samagotchi/web/public/lists_view.js +3 -2
- data/lib/samagotchi/web/public/notifications.js +91 -16
- data/lib/samagotchi/web/public/notify.js +3 -3
- data/lib/samagotchi/web/public/participant.html +4 -0
- data/lib/samagotchi/web/public/push.js +16 -17
- data/lib/samagotchi/web/public/push_devices.js +187 -0
- data/lib/samagotchi/web/public/session_state.js +4 -3
- data/lib/samagotchi/web/public/sw.js +6 -5
- data/lib/samagotchi/web/push/attention.rb +80 -35
- data/lib/samagotchi/web/push/client.rb +23 -6
- data/lib/samagotchi/web/push/notifier.rb +152 -20
- data/lib/samagotchi/web/push/presence.rb +60 -0
- data/lib/samagotchi/web/push/store.rb +86 -4
- data/lib/samagotchi/web/push.rb +13 -4
- data/lib/samagotchi/web/push_routes.rb +58 -8
- data/lib/samagotchi/web/server.rb +48 -15
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc980123c32fef67a2b47defad94735286ba983537b227ba79961cf6b15f1f1a
|
|
4
|
+
data.tar.gz: 5768a7d054c2a36abd746b6a19b7126fa6e992915bbae6040d2990b1903754bf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 906dcb1ea43aeb750b201f75ef585648c609c89e77b0a8ff2bc06e11aa06ece5bec4b7734a1ecff888099952c90e7898c1dc91d64fbe52b4ddbbf102ddefff3a
|
|
7
|
+
data.tar.gz: 05a0edaf338b7a33bb3ffa73a4b590bff8498d789004c12c3f85a7e52ccb2516c4a58c332ae7fb203b9e04131fb36d1b5bb8c1f0f2fc2373e03ef6ded9ccd366
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,56 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.113.0] - 2026-10-25
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- chi web: in the phone's Home Screen app the bell opens a notifications sheet: this device's switch, then every
|
|
16
|
+
device chi web pushes to with its last delivery, a "Long turns done" switch each and Remove; devices from an
|
|
17
|
+
earlier address are one line.
|
|
18
|
+
- `chi web push ls|rm ID|test [ID]`: list the phones chi web pushes to, remove one, or send a test notification,
|
|
19
|
+
from the terminal (chi web need not run; never prints a push address or keys).
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- chi web behind an auth proxy: a browser on this machine opening `127.0.0.1:<port>` got the token page with a paste
|
|
24
|
+
box that cannot work there (loopback takes the token as `Authorization: Bearer` only, so no cookie is traded); the
|
|
25
|
+
401 page now says chi web opens at its public address and links it, and the API's 401 detail says the same.
|
|
26
|
+
- `chi web --tunnel tailscale`: a Ctrl-C while it turned Tailscale Serve on left port 443 forwarding to chi web's port
|
|
27
|
+
after it exited, and a second Ctrl-C during the teardown could cut it short with 443 still on: both now wait for the
|
|
28
|
+
command and turn 443 off.
|
|
29
|
+
- `chi web --tunnel tailscale` turns Tailscale Serve on only once chi web has bound its port: a program that took the
|
|
30
|
+
port between the free-port check and the bind was reachable from the tailnet until chi web gave up.
|
|
31
|
+
|
|
32
|
+
Update with `chi update` (the system bundle 0.113.0), then restart `chi web`.
|
|
33
|
+
|
|
34
|
+
## [0.112.0] - 2026-10-25
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- chi web: the phone's push notifications now come for everything the tab notifies for: a step-limit question, a card
|
|
39
|
+
that asks (check-in's Nudge / Keep going / Stop) and a failed turn, beside approvals and questions. Done turns are
|
|
40
|
+
off by default, per device (the switch comes with the devices list).
|
|
41
|
+
- chi web: no push while a chi web page is in front and was touched in the last 2 minutes (you are looking); at most
|
|
42
|
+
30 pushes an hour; a busy push service is tried once more; an approval asked while chi web was restarting is pushed
|
|
43
|
+
once when it starts.
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- chi web: an untitled session's card, top bar and browser tab read its recap's first sentence (what the session is
|
|
48
|
+
doing) instead of its first message, so long-lived sessions started the same way no longer look alike; the card
|
|
49
|
+
then leaves out its recap line, and a session with no recap yet still reads its first message.
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- chi web: tapping the composer (or any other text field) on an iPhone or iPad no longer zooms the page in. Text
|
|
54
|
+
fields are 16px on phones and touch devices, the size under which iOS Safari zooms on focus (and the Home Screen
|
|
55
|
+
app never zooms back); a mouse desktop keeps its sizes.
|
|
56
|
+
- chi web: the sessions list's cards on an iPhone show their whole preview, title and recap again instead of cutting
|
|
57
|
+
them through the middle.
|
|
58
|
+
|
|
59
|
+
Update with `chi update` (the system bundle 0.112.0), then restart `chi web`.
|
|
60
|
+
|
|
11
61
|
## [0.111.1] - 2026-10-25
|
|
12
62
|
|
|
13
63
|
### Fixed
|
|
@@ -4264,7 +4314,9 @@ and long-lived sessions.
|
|
|
4264
4314
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
4265
4315
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
4266
4316
|
|
|
4267
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
4317
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.113.0...HEAD
|
|
4318
|
+
[0.113.0]: https://github.com/dm1try/samagotchi/compare/v0.112.0...v0.113.0
|
|
4319
|
+
[0.112.0]: https://github.com/dm1try/samagotchi/compare/v0.111.1...v0.112.0
|
|
4268
4320
|
[0.111.1]: https://github.com/dm1try/samagotchi/compare/v0.111.0...v0.111.1
|
|
4269
4321
|
[0.111.0]: https://github.com/dm1try/samagotchi/compare/v0.110.0...v0.111.0
|
|
4270
4322
|
[0.110.0]: https://github.com/dm1try/samagotchi/compare/v0.109.0...v0.110.0
|
data/bin/chi
CHANGED
|
@@ -97,6 +97,14 @@ if (path, klass = SUBCOMMANDS[ARGV[0]])
|
|
|
97
97
|
exit Samagotchi.const_get(klass).new(ARGV[1..] || []).run
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
+
# `chi web push ls|rm|test`: this machine's Web Push devices. Not a
|
|
101
|
+
# SUBCOMMANDS key: `chi web` itself is RunOptions' (and a remote's refusal
|
|
102
|
+
# came above: chi web runs on this machine only).
|
|
103
|
+
if ARGV[0] == "web" && ARGV[1] == "push"
|
|
104
|
+
require "samagotchi/push_command"
|
|
105
|
+
exit Samagotchi::PushCommand.new(ARGV[2..]).run
|
|
106
|
+
end
|
|
107
|
+
|
|
100
108
|
# `chi scratch`: the run options below, for a session that leaves nothing
|
|
101
109
|
# behind (TerminalUI scratch:). The word goes before OptionParser sees it.
|
|
102
110
|
scratch = ARGV[0] == "scratch"
|
data/docs/architecture.md
CHANGED
|
@@ -701,8 +701,8 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
|
|
|
701
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)). |
|
|
702
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)). |
|
|
703
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
|
|
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
|
|
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, prefs, test and presence) | 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, shared cases in `spec/fixtures/attention_cases.json`), `web/push/presence.rb` (`Push::Presence`: the pages' "in front" reports), `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 what the tab notifies for (approvals, questions, step-limit questions, cards, failed turns; done turns to a device that asks) to the owner's Home Screen app over an https public URL: the Notifier turns the hub's new attentions into show pushes after a hold, unless a chi web page is in front, at most 30 an hour, with one retry and a restart catch-up (`sent.json`) (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). |
|
|
706
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)). |
|
|
707
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)). |
|
|
708
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
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
- `chi web [--port 4567] [--open] [--scope=all]` — start the Web UI (single localhost port session control plane) on this git project's sessions (`--scope=all`, or a folder in no repo: every session); if a chi web already runs on the port, print (with `--open`, open) its page for this folder and exit. Something else on the port (an older chi web too) exits 1 with "port N is in use"
|
|
15
15
|
- `chi web --web-host lan` — the Web UI on your home network too, for your phone: a link with an access token and its QR code (see [chi web on your phone](#chi-web-on-your-phone)); `chi web --new-token` replaces the token
|
|
16
16
|
- `chi web --tunnel tailscale` — the Web UI over https on your tailnet, for your phone anywhere: Tailscale Serve (never Funnel) on this machine's `<machine>.<tailnet>.ts.net` name for this run, the owner your Tailscale login, turned off on exit (see [Tailscale Serve](configuration.md#tailscale-serve-your-tailnet)); `--tunnel` without `web` exits 2
|
|
17
|
+
- `chi web push ls [--format json]`, `chi web push rm ID`, `chi web push test [ID]` — the phones chi web pushes to (see [Phone notifications](configuration.md#phone-notifications-web-push)): list them (short id, label, origin, added, last delivery, done turns, last error; `stale` for one made on another public URL than chi web's now: never sent to), remove one, or send a test notification to ID or to every device not stale. chi web need not run; nothing printed holds a device's push address or keys. A usage error exits 2; a store others can read, a corrupt one, an unknown device or a failed test push exit 1. This machine only: `chi --remote X web push` exits 2
|
|
17
18
|
- `chi web --web-markdown` — opt in to sanitized Markdown rendering for completed assistant messages, the stage's asking text and a resolved question card's reply
|
|
18
19
|
- `chi web --web-view turn` — draw turns with the turn view (each turn as one block of steps, the running one at the bottom of the history) instead of the default stage view (the running turn pinned above the composer); `?view=stage|turn` on the page URL overrides it (see [Web views](#web-views))
|
|
19
20
|
- `chi web --no-web-working-set` — turns the stage view's working set off (on by default; `--web-working-set` turns a config's `web.working_set: false` back on): what each turn brought into the model's context, in the stage and under each finished turn; `?working_set=1|0` on the page URL overrides it (see [The working set](#the-working-set))
|
|
@@ -1179,8 +1180,9 @@ access token", paste the token there (the part of the link after
|
|
|
1179
1180
|
name on your tailnet (Tailscale Serve) for its run and prints its link's QR
|
|
1180
1181
|
code; see [Tailscale Serve](configuration.md#tailscale-serve-your-tailnet).
|
|
1181
1182
|
- 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
|
|
1183
|
-
|
|
1183
|
+
open the Chi app, tap its bell and turn on **This device**; approvals, questions and failed turns then push to the
|
|
1184
|
+
phone. The same sheet lists the other devices (a long-turns-done switch each, Remove), and `chi web push ls|rm|test`
|
|
1185
|
+
does it from the Mac (see [Phone notifications](configuration.md#phone-notifications-web-push)).
|
|
1184
1186
|
|
|
1185
1187
|
### Remote sessions
|
|
1186
1188
|
|
data/docs/configuration.md
CHANGED
|
@@ -1618,7 +1618,8 @@ What changes once `web.public_url` is set:
|
|
|
1618
1618
|
its defaults) would otherwise look like this machine. On this machine the page opens through the proxy too: a
|
|
1619
1619
|
request that didn't come through it takes the token only as `Authorization: Bearer` (no cookie: a cookie for
|
|
1620
1620
|
127.0.0.1 would go to every server on any local port; a phone on the LAN, with `web.host: lan`, keeps its LAN link
|
|
1621
|
-
and cookie).
|
|
1621
|
+
and cookie). A browser on this machine that opens the port directly gets a 401 page with no paste box: it links
|
|
1622
|
+
`web.public_url`, the address to open instead. chi's own probe of the port (a second `chi web`, `chi share`,
|
|
1622
1623
|
`chi update`) sends no token; chi web answers it the few fields it checks. Links and the start line come from this
|
|
1623
1624
|
machine's config, never from whatever answers on the port.
|
|
1624
1625
|
- **A request through the proxy** (it carries `X-Forwarded-For`, `Forwarded`, `X-Real-IP`, a known identity header,
|
|
@@ -1733,8 +1734,9 @@ Tailnet Lock is off: a device Tailscale's servers add to your tailnet could reac
|
|
|
1733
1734
|
```
|
|
1734
1735
|
|
|
1735
1736
|
- It checks before it changes anything: Tailscale is connected, this machine has its `ts.net` name and a certificate
|
|
1736
|
-
for it, and what Serve already has on port 443. Nothing there:
|
|
1737
|
-
|
|
1737
|
+
for it, and what Serve already has on port 443. Nothing there: once chi web has bound its port (so Serve never
|
|
1738
|
+
forwards your tailnet to another program that took the port first), it runs
|
|
1739
|
+
`tailscale serve --bg --https=443 http://127.0.0.1:<port>`, then prints its lines. Exactly that one handler for this port (a leftover of an
|
|
1738
1740
|
earlier run killed hard, or step 3 done by hand): it adopts it. Anything else on 443 (another port, other paths, TCP
|
|
1739
1741
|
forwarding, Funnel) is refused and left alone. Serve's other ports are never touched.
|
|
1740
1742
|
- The owner is `web.proxy.owner` if config.yml has it, else this machine's own Tailscale login (a tagged machine has
|
|
@@ -1742,7 +1744,9 @@ Tailnet Lock is off: a device Tailscale's servers add to your tailnet could reac
|
|
|
1742
1744
|
config.yml: a `web.public_url` or `web.proxy.identity_header` that says otherwise (in config.yml or the environment)
|
|
1743
1745
|
is refused, so a config from step 4 with this machine's name works as it is.
|
|
1744
1746
|
- On exit (Ctrl-C, a kill, closing the terminal) it runs `tailscale serve --https=443 off`, but only while 443 still
|
|
1745
|
-
serves exactly what it set; if you changed 443 meanwhile it leaves it and says so.
|
|
1747
|
+
serves exactly what it set; if you changed 443 meanwhile it leaves it and says so. A Ctrl-C while it turns Serve on
|
|
1748
|
+
or off waits for that command (at most 15 s each) and the teardown still runs; a teardown that fails is one warning
|
|
1749
|
+
line. Killed hard (`kill -9`, a power
|
|
1746
1750
|
loss) the serve stays on: Serve answers 502 for the name while nothing listens on the port, and whatever later
|
|
1747
1751
|
listens on 127.0.0.1:<port> is reachable from your tailnet; the next `chi web --tunnel tailscale` adopts it and
|
|
1748
1752
|
turns it off on exit, a plain `chi web` doesn't look.
|
|
@@ -1770,10 +1774,11 @@ Tailnet Lock is off: a device Tailscale's servers add to your tailnet could reac
|
|
|
1770
1774
|
|
|
1771
1775
|
### Phone notifications (Web Push)
|
|
1772
1776
|
|
|
1773
|
-
chi web as an app on your iPhone's Home Screen that buzzes (the Watch too) when a session
|
|
1774
|
-
a **question**,
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
+
chi web as an app on your iPhone's Home Screen that buzzes (the Watch too) when a session needs you: an
|
|
1778
|
+
**approval**, a **question**, a **step-limit** question, a **card** that asks (check-in's Nudge / Keep going / Stop),
|
|
1779
|
+
or a **failed turn**, also while the app is closed. A tap opens that session; once it is answered anywhere (the Mac,
|
|
1780
|
+
the phone, `chi answer`), its notification goes when you open the app (or swipe it away; a failed turn's stays until
|
|
1781
|
+
you do). Every push shows a notification: iOS turns off an app's pushes after a few that show none.
|
|
1777
1782
|
|
|
1778
1783
|
What it needs:
|
|
1779
1784
|
|
|
@@ -1783,22 +1788,37 @@ What it needs:
|
|
|
1783
1788
|
- No Apple account or registration: chi web signs its pushes with a key of its own (VAPID).
|
|
1784
1789
|
|
|
1785
1790
|
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.
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1791
|
+
app → tap the **bell** at the top → turn on **This device** in the sheet → Allow. A test notification arrives at once.
|
|
1792
|
+
In a browser tab the bell stays the tab's own notifications (while the tab is in the background), as before.
|
|
1793
|
+
|
|
1794
|
+
**The devices** (the bell's sheet in the app; a bottom sheet on the phone): this device first, with its switch (off
|
|
1795
|
+
removes it from chi web and is the same as the bell turned off), then every other device chi web pushes to: its label,
|
|
1796
|
+
the last delivery and the last error, a **Long turns done** switch each, and **Remove** (it can turn its bell on again
|
|
1797
|
+
to come back). Devices made on an earlier public URL show as one line ("1 more device from an earlier address"): they
|
|
1798
|
+
are never sent to; `chi web push rm ID` removes them. From the Mac: `chi web push ls` lists them, `chi web push test
|
|
1799
|
+
[ID]` sends a test notification, `chi web push rm ID` removes one ([CLI](cli.md)). A device iOS dropped silently still
|
|
1800
|
+
shows a recent delivery (Apple keeps accepting pushes for it): if one stops getting notifications, switch it off and on
|
|
1801
|
+
there; the new subscription replaces the dead one.
|
|
1802
|
+
|
|
1803
|
+
- **What is sent**: the session's name and the reason ("needs approval", "needs an answer", "hit its step limit",
|
|
1804
|
+
"needs you", "turn failed", "done in N s"), never the question or the command, as the tab's notification on the
|
|
1805
|
+
Mac. The session's name shows on the lock screen and the Watch.
|
|
1792
1806
|
- **What Apple sees**: the push is end-to-end encrypted to the phone; Apple's push service sees that this machine
|
|
1793
1807
|
pushes to that device, and when. A subscription holds no device name and no personal data (an opaque address on
|
|
1794
1808
|
`web.push.apple.com` and two keys); chi names it itself ("iPhone · 2026-10-24", from the User-Agent's device class
|
|
1795
1809
|
and the date).
|
|
1796
|
-
- **When**:
|
|
1797
|
-
approval, which its parent may relay instead); one push
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
- **
|
|
1801
|
-
|
|
1810
|
+
- **When**: the same things the tab notifies for, still open 3 s after they came (3.5 s for a delegated child's
|
|
1811
|
+
approval, which its parent may relay instead); one push each. A delegated child's failed or finished turn is its
|
|
1812
|
+
parent's to report. **Done turns** (10 s or longer, or one nobody started: a delegate's report, a context change, a
|
|
1813
|
+
finished task) are **off** by default, per device: the **Long turns done** switch in the devices sheet turns them on.
|
|
1814
|
+
- **Quiet while you look**: nothing is pushed while any chi web page (a tab on the Mac, or the app) is in front and
|
|
1815
|
+
was touched in the last 2 minutes; what came meanwhile is never pushed later (the page has it). A page left in
|
|
1816
|
+
front untouched stops quieting the phone after about 2 minutes.
|
|
1817
|
+
- **Limits**: at most 30 pushes an hour (the rest are dropped, with one warning on chi web's terminal); a busy push service is tried once more. An approval or question asked while chi web was stopped or
|
|
1818
|
+
restarting (under an hour ago, still open) is pushed once when it starts; the first restart after updating may
|
|
1819
|
+
push one you already saw, once.
|
|
1820
|
+
- **The files**: `$XDG_STATE_HOME/samagotchi/web-push/` (next to `web-token`): `vapid.pem` (chi web's key),
|
|
1821
|
+
`subscriptions.json` (the devices) and `sent.json` (what was pushed in the last day), 0600 in a 0700 folder; chi web refuses to start when others can read them.
|
|
1802
1822
|
Subscriptions belong to the public URL they were made on: after renaming the machine or the tailnet, turn the bell
|
|
1803
1823
|
on again on the phone (the old ones are never sent to). iOS can drop a subscription without telling chi web; if
|
|
1804
1824
|
notifications stop arriving, turn the bell off and on again: the new subscription replaces the dead one.
|
|
@@ -1940,7 +1960,7 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
|
|
|
1940
1960
|
| `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). |
|
|
1941
1961
|
| `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). |
|
|
1942
1962
|
| `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
|
|
1963
|
+
| `web.push.enabled` | `true` | | Push notifications to a phone's Home Screen app (approvals, questions, step-limit questions, cards, failed turns; done turns per device) 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
1964
|
| `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). |
|
|
1945
1965
|
| `history.file` | state dir | | Prompt history path, shared by the TUI and the web composer. |
|
|
1946
1966
|
| `no_interrupt` | `false` | `--no-interrupt` | Raise the step limit of a turn to 1000 (or `turn.max_iterations`, when larger); a top-level key. |
|
data/docs/internals/web-push.md
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
# Web Push (internals)
|
|
2
2
|
|
|
3
|
-
chi web pushes to the owner's phone when a session
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
chi web pushes to the owner's phone when a session needs them, for what the tab notifies for: an approval, a
|
|
4
|
+
question, a step-limit question, a card with actions, a failed turn, and (per device, off by default) a done one. The
|
|
5
|
+
phone runs chi web as a Home Screen app (iOS 16.4+), its bell subscribes the device, and chi web, the only sender,
|
|
6
|
+
pushes through the device's push service (Apple's `web.push.apple.com`). The user side is in
|
|
7
|
+
[configuration.md](../configuration.md#phone-notifications-web-push). This page is how it is built (P1, P2a and P2b of the
|
|
8
|
+
plan: P2b is the devices sheet and `chi web push`).
|
|
9
9
|
|
|
10
10
|
```
|
|
11
11
|
SessionHub ──emit──▶ Notifier#call(event) ──Queue──▶ sender thread
|
|
12
|
-
Attention rules hold 3 s → still open?
|
|
12
|
+
Attention rules hold 3 s → still open? a page in front? the cap?
|
|
13
13
|
Crypto.encrypt + VAPID → Client#deliver(endpoint)
|
|
14
14
|
201 ok · 404/410 drop the device · 403 BadJwtToken stop + log
|
|
15
|
+
429/5xx one retry · handled keys → sent.json
|
|
16
|
+
page ──POST /api/push/presence──▶ Presence (in front, touched < 2 min)
|
|
15
17
|
service worker ◀── Apple (web.push.apple.com) ◀──────── {v, kind: show, tag, title, body, url, badge, open?}
|
|
16
18
|
```
|
|
17
19
|
|
|
@@ -50,8 +52,16 @@ files, one per machine, never in config.yml:
|
|
|
50
52
|
another key and makes a new one.
|
|
51
53
|
- `subscriptions.json`: `{"version": 1, "subscriptions": [{id, endpoint, keys: {p256dh, auth}, origin, label,
|
|
52
54
|
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`
|
|
54
|
-
endpoint or the keys; neither do the log lines.
|
|
55
|
+
endpoint's SHA-256: the only name of a device outside the store. `Sub#public_h` (id, label, origin, the dates,
|
|
56
|
+
`last_error`, `prefs: {done:}`) and `#inspect` never carry the endpoint or the keys; neither do the log lines.
|
|
57
|
+
`prefs` is `{"done": false}` until `Store#set_prefs(id, done:, origin:)` (the PATCH route) sets it; `Sub#done?`.
|
|
58
|
+
`Store#remove_device(id, origin:)` removes by short id only on that origin (nil: any; the DELETE route, `chi web
|
|
59
|
+
push rm`); `#subscriptions!` is `#subscriptions` that raises `Corrupt` instead of reading none (the CLI says so).
|
|
60
|
+
- `sent.json`: `{"version": 1, "keys": {KEY: iso time}}`, the attention keys already handled (a device took the
|
|
61
|
+
show, or it was skipped while a page was in front, or capped), written by `Store#mark_sent` under `store.lock`,
|
|
62
|
+
keys older than a day (`SENT_KEEP`) pruned on each write. `Store#sent_keys` reads it; one that doesn't read is
|
|
63
|
+
logged once (`push_sent_corrupt`) and read as none, and the next write replaces it (it only spares a restart's
|
|
64
|
+
repeat). It is in `check_permissions!`'s list.
|
|
55
65
|
|
|
56
66
|
`Store#add` checks what `pushManager.subscribe` gave, one message per refusal (the route answers it as 400
|
|
57
67
|
`invalid_subscription`): an endpoint and both keys; https on port 443 without userinfo, at most 2048 bytes; a host in
|
|
@@ -73,9 +83,11 @@ tailnet leaves the old ones stale (never sent to: a tap would open an origin tha
|
|
|
73
83
|
## The client (`web/push/client.rb`)
|
|
74
84
|
|
|
75
85
|
`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
|
|
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).
|
|
86
|
+
no redirects, no retry of its own) with `Content-Encoding: aes128gcm`, `TTL`, `Urgency` and the VAPID header, and
|
|
87
|
+
answers a `Client::Result`: `:ok` (200/201/202), `:gone` (404/410: the device dropped it), `:bad_jwt` (403 with
|
|
88
|
+
`BadJwtToken`), `:failed` (anything else; a connection error as its class name or `timeout`, never its message).
|
|
89
|
+
A 429 or 5xx also carries `retry_after`: its `Retry-After` delta-seconds up to `RETRY_MAX` 60, else (missing, an
|
|
90
|
+
HTTP date) `RETRY_DEFAULT` 10; nil for anything else, a connection error too (it may have been delivered). The
|
|
79
91
|
transport is injectable: `spec/web/push/client_spec.rb` runs a fake push service (WEBrick, plain http through the
|
|
80
92
|
injected transport) that decrypts each body with the device's key and checks the headers and the JWT.
|
|
81
93
|
|
|
@@ -84,18 +96,20 @@ injected transport) that decrypts each body with the device's key and checks the
|
|
|
84
96
|
The service worker is the only reader of the payload (JSON, at most `Notifier::OPEN_LIMIT`, 3000 bytes, with
|
|
85
97
|
`open`; one aes128gcm record leaves 3993 bytes of plaintext under Web Push's 4096: 86 header, 1 delimiter, 16 tag):
|
|
86
98
|
|
|
87
|
-
- `{"v":1,"kind":"show","tag":KEY,"title":…,"body":…,"url":"/#/s/<id>","badge":N,"open":[KEY…]}`:
|
|
88
|
-
|
|
89
|
-
|
|
99
|
+
- `{"v":1,"kind":"show","tag":KEY,"title":…,"body":…,"url":"/#/s/<id>","badge":N,"open":[KEY…]}`: a waiting
|
|
100
|
+
reason's (approval, question, continue, card) TTL 12 h (`SHOW_TTL`), `Urgency: high`; a failed or done turn's
|
|
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`.
|
|
90
103
|
|
|
91
|
-
`badge` and `open` are the keys still open in the Notifier's summaries (`Attention.open_keys`: a pending
|
|
92
|
-
|
|
93
|
-
restart. `public/sw.js` is a classic script (module service workers on iOS are unverified) at scope `/`. Every push
|
|
104
|
+
`badge` and `open` are the waiting keys still open in the Notifier's summaries (`Attention.open_keys`: a pending
|
|
105
|
+
question of any reason, a hook's and a continue's too, not relayed, and a card with actions; never a failed or done
|
|
106
|
+
turn), so an approval pushed by an earlier chi web run still counts after a restart. `public/sw.js` is a classic script (module service workers on iOS are unverified) at scope `/`. Every push
|
|
94
107
|
event ends in `showNotification`: a payload that isn't a readable v1 show (no data, bad JSON, another `v`, a stale
|
|
95
108
|
`close` from an older chi web, any other kind) shows a fallback (`chi` / "open chi", tag `chi:other`, url `/`), and a
|
|
96
109
|
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>"`
|
|
110
|
+
icon, data: {url}})` first; then, when `open` is an array, closes every `getNotifications()` whose tag is a waiting
|
|
111
|
+
key (`WAITING_TAG`: a question's `"<sid>:<qid>"` or a card's `"<sid>:card:<cid>"`, never `chi:*` nor a failed or
|
|
112
|
+
done turn's `"<sid>:<ended_at>"`, whose time has colons), isn't the new one and isn't in `open` (after the show: closing before it
|
|
99
113
|
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
114
|
skips waiting, `activate` claims the clients. A Home Screen app can stay open for days, and the browser checks for
|
|
101
115
|
a newer `sw.js` only on `register()` and on navigations: so when the page comes back in front (`backInFront` in
|
|
@@ -111,17 +125,30 @@ transparency with black).
|
|
|
111
125
|
|
|
112
126
|
## The Notifier (`web/push/notifier.rb`) and its rules (`web/push/attention.rb`)
|
|
113
127
|
|
|
114
|
-
`Push::Attention` is the server's port of `web/public/notify.js
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
128
|
+
`Push::Attention` is the server's port of `web/public/notify.js`, so a push and the tab's notification ring for the
|
|
129
|
+
same thing under the same key. **One set of rules for both sides**: `spec/fixtures/attention_cases.json` holds the
|
|
130
|
+
cases (`attention`: `{name, prev, next, attention: {reason, key, hold_ms}|null, closed}` in the summary's JSON;
|
|
131
|
+
`open_keys`: `{name, by_id, open}`), and `spec/web/push/attention_cases_spec.rb` (the summary's key types: symbols at
|
|
132
|
+
the top and in `pending_question` / `pending_card`, strings in `last_turn`), `notify.test.js` (`attentionFor`,
|
|
133
|
+
`closedKeys`) and `push.test.js` (`openPushKeys`) run every row. A change to either side adds its row there.
|
|
134
|
+
|
|
135
|
+
- `Attention.for(prev, next)`, in notify.js's order:
|
|
136
|
+
1. a new `pending_question` id: none if it is `relayed_to` already (absent when nil: the summary compacts it);
|
|
137
|
+
else its **reason** (notify.js `questionReason`: `approval`, `continue`, anything else incl. `hook` is a
|
|
138
|
+
question), key `"<sid>:<qid>"`. A delegated child's (`parent_id`) approval or hook question holds `RELAY_HOLD`
|
|
139
|
+
3.5 s (notify.js `RELAY_HOLD_MS`): its parent may relay it.
|
|
140
|
+
2. a new `pending_card` id: `card`, key `"<sid>:card:<cid>"`.
|
|
141
|
+
3. a top-level turn whose `last_turn["ended_at"]` changed (a delegated child's is its parent's to report):
|
|
142
|
+
`failed`, or `done` when `completed` and `seconds` ≥ `DONE_MIN_SECONDS` 10 or its origin is in
|
|
143
|
+
`UNWATCHED_ORIGINS` (`delegate_report`, `context`, `task`); key `"<sid>:<ended_at>"`. A reminder turn follows
|
|
144
|
+
the same rules (no reminder event of its own).
|
|
145
|
+
- `WAITING` (approval, question, continue, card) are the reasons that stay open until answered: badged, in `open`,
|
|
146
|
+
cleaned up, and pushed only while still open. A failed or done turn has nothing to answer.
|
|
147
|
+
- `Attention.closed_keys(prev, next)`: the question answered, replaced, newly relayed; the card resolved or
|
|
148
|
+
replaced; both when the session is gone.
|
|
123
149
|
- `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"
|
|
150
|
+
prompt clipped to 61, and "needs approval" / "needs an answer" / "hit its step limit" / "needs you" / "turn
|
|
151
|
+
failed" / "done in N s" (`last_turn["seconds"]` rounded). Never the question or the command.
|
|
125
152
|
|
|
126
153
|
`Notifier#call(event)` is a `SessionHub` sink. **The hub calls it under its Monitor**, from the tick thread and also
|
|
127
154
|
from chi web's request threads (`SessionHub#touch`, from the stop/archive/delete routes), so it only enqueues: a push
|
|
@@ -134,10 +161,30 @@ open already never pushes. The hub's projection is empty before its first scan,
|
|
|
134
161
|
once after the `chi` line's subscribe and before `notifier.start(hub)`, then `hub.start`. Events that reach the sink
|
|
135
162
|
before the seed is in (they are newer than the snapshot) are applied after it.
|
|
136
163
|
|
|
137
|
-
The sender thread holds each show (`HOLD` 3 s, or the event's own)
|
|
138
|
-
`Attention.open_keys` of the summaries (read under the sink's mutex at flush time),
|
|
139
|
-
|
|
140
|
-
|
|
164
|
+
The sender thread holds each show (`HOLD` 3 s, or the event's own); then a waiting one goes only while its key is
|
|
165
|
+
still in `Attention.open_keys` of the summaries (read under the sink's mutex at flush time), a failed or done one as
|
|
166
|
+
is. It goes to every subscription on this run's origin (a done turn only to those whose `prefs.done` is true), with
|
|
167
|
+
that list as `open` and its size as `badge`, unless:
|
|
168
|
+
|
|
169
|
+
- **a page is in front** (`Push::Presence#present?`, below): no send at all, `push_skipped reason: present` (no
|
|
170
|
+
device), and the key is handled (never pushed later: the tab's notification and badge have it);
|
|
171
|
+
- **the cap**: `CAP` 30 shows in a rolling `CAP_WINDOW` hour, counted per show event (one to N devices is one; the
|
|
172
|
+
test push isn't the Notifier's): over it, not sent, handled, and one `push_capped` warning per hour (on chi web's
|
|
173
|
+
terminal too).
|
|
174
|
+
|
|
175
|
+
A close only drops a show still in its hold: it pushes nothing. **Retry**: a result with `retry_after` (a 429 or
|
|
176
|
+
5xx) puts that device's show in the sender's retry table (`Notifier::Retry`, per key and device, due like a hold:
|
|
177
|
+
the sender never sleeps, other shows go out meanwhile; `push_retry` logs it); it goes once more to that device, still
|
|
178
|
+
there, only while a waiting key is open (a failed or done one as is), with the open list of that moment, and isn't
|
|
179
|
+
counted against the cap again. A second failure is final: `push_failed` and `last_error`, as any failure. A key a
|
|
180
|
+
device took (first try or retry), or one skipped or capped, goes in `sent.json` (`Store#mark_sent`).
|
|
181
|
+
|
|
182
|
+
**Restart catch-up**: `Session#waiting_question` carries the question's `created_at` (`PendingQuestion#created_at`,
|
|
183
|
+
iso8601; the summary's `pending_question` has it, the page ignores it). `Notifier#seed` shows each seed summary's
|
|
184
|
+
open, not relayed question asked under `CATCH_UP` (an hour) ago whose key isn't in `sent.json` (`push_catch_up`):
|
|
185
|
+
asked while chi web was down or restarting, it gets the normal hold, presence and the cap. Everything else seeds as
|
|
186
|
+
before (cards carry no time). The first restart after P2a may push a seen, still-open question once (no `sent.json`
|
|
187
|
+
yet). Results: `:ok` stamps the device's
|
|
141
188
|
`last_ok_at`; `:gone` removes it; `:failed` keeps its code in `last_error` and logs `push_failed`; `:bad_jwt` logs
|
|
142
189
|
one `push_bad_jwt` warning (on chi web's terminal too) and stops all pushes until chi web restarts. Log lines carry
|
|
143
190
|
`device:` (the short id), `code:`, `ms:`, `kind:`: never the endpoint, keys or text. `#stop` drops pending holds and
|
|
@@ -150,16 +197,41 @@ joins with a timeout; `Server.start`'s ensure calls it. Specs drive `#pump` with
|
|
|
150
197
|
`Web::PushRoutes`; `@push` is the run's `Push::Runtime` (store, client, origin), nil when push is off (no https
|
|
151
198
|
public URL, or `web.push.enabled: false`):
|
|
152
199
|
|
|
153
|
-
- `GET /api/push[?endpoint=E]` → `{enabled, public_key, origin, this: {id, label}|null
|
|
154
|
-
|
|
200
|
+
- `GET /api/push[?endpoint=E]` → `{enabled, public_key, origin, this: {id, label}|null, devices, stale}`: `devices`
|
|
201
|
+
this origin's (`Sub#public_h` + `this: bool`), `stale` how many were made on another origin; off: `enabled:
|
|
202
|
+
false` and no key.
|
|
155
203
|
- `POST /api/push/subscriptions {subscription: {endpoint, keys}, replaces?: id}` → 201 `{id, label}`; the label is
|
|
156
204
|
chi's own ("iPhone · <date>", from the User-Agent's device class: iPhone, iPad, Mac, else Browser); `replaces`
|
|
157
205
|
drops the device's earlier entry (`Store#add`).
|
|
158
|
-
- `DELETE /api/push/subscriptions {endpoint}` → 204.
|
|
206
|
+
- `DELETE /api/push/subscriptions {endpoint}` → 204: this device's bell turned off.
|
|
207
|
+
- `DELETE /api/push/subscriptions/ID` → 204, 404 `unknown_device` (another origin's, or not a short id); logs
|
|
208
|
+
`push_removed`. The devices sheet's Remove for another device; this device's own row never uses it (its switch is
|
|
209
|
+
the bell, so the page's state and the store stay one).
|
|
210
|
+
- `PATCH /api/push/subscriptions/ID {prefs: {done: true|false}}` → 200 the device's `Sub#public_h`; 404
|
|
211
|
+
`unknown_device` (not this origin's, or not a short id: the id is a `([^/]+)` capture checked in the handler, as
|
|
212
|
+
`participant_gate_spec`'s `path_for` fills only those), 400 `invalid_prefs`; logs `push_prefs`. ROUTES' first
|
|
213
|
+
PATCH row.
|
|
214
|
+
- `POST /api/push/presence {page, front}` → 204 (also with push off: a desk tab has nothing to be told), 400
|
|
215
|
+
`invalid_presence`.
|
|
159
216
|
- `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.
|
|
217
|
+
(`{devices: [{id, label, status, code, ms}]}`); the bell sends one after subscribing. The payload is
|
|
218
|
+
`Push::TEST_PAYLOAD` (TTL `TEST_TTL`), shared with `chi web push test`.
|
|
219
|
+
|
|
220
|
+
With push off the subscription writes and the test answer 409 `push_off`.
|
|
161
221
|
|
|
162
|
-
|
|
222
|
+
## Presence (`web/push/presence.rb`)
|
|
223
|
+
|
|
224
|
+
"Don't push while I'm looking" is decided here, as the iPhone's service worker can't see the app in front. Every
|
|
225
|
+
chi web page, a desk tab or the Home Screen app (`notifications.js`, whether or not it gets pushes itself), reports
|
|
226
|
+
`POST /api/push/presence {page, front}`: `page` a random id per page load, `front` = visible + `hasFocus()` +
|
|
227
|
+
touched (pointerdown, keydown, touchstart) in the last 2 min (`TOUCH_FRESH_MS`; the load counts as a touch). It
|
|
228
|
+
reports on visibilitychange, window focus and blur, the first touch that brings it to the front, every 30 s
|
|
229
|
+
(`PRESENCE_EVERY_MS`) while front and once more when 2 min pass untouched; a hide or `pagehide` sends `front: false`
|
|
230
|
+
as a beacon (`presenceSender`: `navigator.sendBeacon` with a JSON Blob, same origin so the cookie goes; else the
|
|
231
|
+
api's fetch; errors swallowed). `Push::Presence` keeps each page's last report in memory (a Mutex; at most
|
|
232
|
+
`MAX_PAGES` 64, the longest unheard dropped); `present?` is any `front: true` younger than `TTL` 45 s. It is part of
|
|
233
|
+
`Push::Runtime` (`presence`), so the route writes and the Notifier (built in `Server.start` from the Runtime) reads
|
|
234
|
+
the same one. A page left in front untouched quiets the phone at most ~2 min after the last touch.
|
|
163
235
|
|
|
164
236
|
## The page (`public/push.js`, `public/notifications.js`)
|
|
165
237
|
|
|
@@ -174,9 +246,27 @@ a test push; a tap when on unsubscribes and deletes, and forgets the id, unless
|
|
|
174
246
|
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
247
|
chi web not pushing, the bell is the in-tab one, unchanged (no duplicate desktop pushes).
|
|
176
248
|
|
|
249
|
+
**The devices sheet** (`public/push_devices.js`, `createPushDevices` on `chip_popover.js`'s shell: a bottom sheet
|
|
250
|
+
under 600 px, else a popover at the bell; made once chi web says it pushes, so a tab never gets one): in push mode
|
|
251
|
+
the bell's tap opens it (`toggleNotify` → `sheet.toggle()`), and it reads `GET /api/push?endpoint=` on each open.
|
|
252
|
+
This device's row has the switch, the bell's own `togglePush` (permission from that tap; off forgets
|
|
253
|
+
`PUSH_DEVICE_KEY`), never Remove; every device has a **Long turns done** switch (`PATCH …/ID {prefs: {done}}`),
|
|
254
|
+
another device Remove (`DELETE …/ID`); `GET`'s `stale` is one line. One change at a time, the list read again after
|
|
255
|
+
each; a failure is said in the sheet (and the bell's tooltip). Pure `deviceRows` / `staleText` / `sheetModel` /
|
|
256
|
+
`sheetTemplate` run under node (push_devices.test.js); notifications.js takes the sheet as a `devices` factory.
|
|
257
|
+
|
|
258
|
+
**`chi web push`** (`lib/samagotchi/push_command.rb`, `PushCommand`): `ls [--format json]`, `rm ID`, `test [ID]` on
|
|
259
|
+
this machine's `Store` directly (chi web need not run). "Stale" is another origin than
|
|
260
|
+
`PushCommand.current_origin`: the running chi web's (`Web::Running.live_public_url`), else `web.public_url`'s, none
|
|
261
|
+
marked when neither. `test` builds a `Client` per device (`Push.load_key`, `Push.contact(sub.origin)`), stamps the
|
|
262
|
+
delivery (`Store#mark`) and removes a `:gone` device, as the route does. bin/chi branches on `ARGV[0..1] == web
|
|
263
|
+
push` after the `SUBCOMMANDS` dispatch and before `RunOptions.parse` (which refuses a stray word after `web`); it is
|
|
264
|
+
no `SUBCOMMANDS` key (`web` is RunOptions', and the contract spec would want a TABLE/MACHINE entry). `chi --remote X
|
|
265
|
+
web push …` is refused before that by `SessionAccess::Dispatch` (`web` is local only).
|
|
266
|
+
|
|
177
267
|
**The device's tidy-up** (`syncDevice`, the Home Screen app with the bell on): closes the shown notifications whose
|
|
178
|
-
tag is a
|
|
179
|
-
(`push.js openPushKeys`: `Attention.open_keys`'s rule
|
|
268
|
+
tag is a waiting key (`push.js isWaitingTag`, so `chi:test` and a failed or done turn's stay) and isn't open in the
|
|
269
|
+
page's `byId` (`push.js openPushKeys`: `Attention.open_keys`'s rule), and sets or clears the app
|
|
180
270
|
badge (`navigator.setAppBadge` / `clearAppBadge`); the bell turned off clears it. It runs once push is known, when
|
|
181
271
|
the page comes back in front, and after each hub frame while visible (so answering on the Mac with the app open
|
|
182
272
|
clears it at once), one at a time with one rerun. Never before the first snapshot (an empty `byId` would close
|
|
@@ -201,7 +291,7 @@ can land after the page's tidy-up and stay until the next one. Unverified on iOS
|
|
|
201
291
|
handler before `showNotification` doesn't remove the older one. A separate close push that only closed
|
|
202
292
|
`getNotifications({tag})` did, but it shows nothing (see the quota below), so the worker now closes after its show.
|
|
203
293
|
- **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 (
|
|
294
|
+
banner shows anyway: "don't push while I'm looking" has to be decided server-side, from the pages' presence (see Presence above).
|
|
205
295
|
- **Silent pushes**: the spike's 4 close pushes in a row didn't revoke the subscription, but that was luck or lazy
|
|
206
296
|
enforcement: the user check's 6 did (WebKit's quota is 3 pushes that show nothing; see the top of this page).
|
|
207
297
|
- iOS sent the cookie with the manifest, `sw.js` and icon fetches, but Chrome fetches manifests without credentials:
|
data/docs/sessions.md
CHANGED
|
@@ -239,6 +239,8 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
|
|
|
239
239
|
|
|
240
240
|
A session is named by its first message until you give it a title: `/title Coordinator` (in the REPL, an attached terminal, the web composer or `chi send -m "/title Coordinator" ID`; a parent agent may name its delegates the same way). The title is saved in the session file (`title`), whitespace collapsed and cut at 80 characters, and runs mid-turn too, at once. `/title` alone shows it (`title: none (shows the first message: hey chi …)` without one); `/title --clear` drops it, and the first message names the session again. A session whose first line was the command itself (`chi send --new -m "/title X"`) takes its preview from its first real message.
|
|
241
241
|
|
|
242
|
+
Without a title, the web names a session by its recap's first sentence once it has one (see [Session recap](cli.md#session-recap); its subject phrase stripped), else by its first message: on its card (which then leaves out its recap line, the same sentence), the families and chain rows, the top bar and the browser tab, the delete confirm and the search; `chi sessions list` already reads title, else recap, else the first message. The notifications keep the first message. A participant's page is unchanged (the title or the review's subjects).
|
|
243
|
+
|
|
242
244
|
The title names the session wherever its first message did: `chi sessions list` (plain, `--live`, tsv and json's `desc`, before the recap; json has `title` too), `chi sessions delete`'s line, the `list_sessions` tool, `chi broadcast`, the desktop helper, a parent's children (the coordinator bundle's `/children` card, `ctx.sessions.children`; `ctx.sessions.read` too), and in the web the cards, the families and chain rows, the top bar and the browser tab, the notifications and the search; the terminal's status row too (the REPL's and an attached one's, first, see [Status Line](cli.md#status-line)). In the web, `✎` (Rename this session) sits next to the session's title, on its active strip card or after the top bar's title: a small input over the title, prefilled with it; Enter or a click elsewhere sends `/title TEXT` when it changed (empty: `/title --clear`), Esc closes it unsent, and so does the title leaving the screen (All sessions opened, the card swiped out of a phone's strip). A session a chi REPL owns has no `✎`, nor one that shows no title yet (`/title` still works from the composer). A continue's new link keeps the title (see [Session chains](#session-chains)). An older `chi web` (or any chi from before the field) that saves a session drops its title: restart `chi web` after upgrading.
|
|
243
245
|
|
|
244
246
|
## Session chains
|
|
@@ -255,7 +257,7 @@ A session can **continue** an earlier one: "the next run of the same routine" (a
|
|
|
255
257
|
- **If the continue fails part-way:** a new link that fails to start gives its moved delegates back before it is deleted (each gets a note saying so; its worker is stopped first, if one started, and a worker that won't stop is reported, the link left), with any report one of them rang it with meanwhile; the previous link, back in the lists, is woken for reports waiting. A continue that dies in the middle leaves a `move.json` in the new link's folder: the new link's next worker finishes that move (only the delegates it names, and without a second note to one already told) as it starts. Until the new link's own worker holds it, a ring from a moved delegate doesn't start another (a `starting` marker in its folder, which that worker removes; a stale one, over 60 s old or from a process that is gone, is ignored). An open delegate below an archived delegate refuses the continue (`under archived …`): unarchive that one, or archive this one.
|
|
256
258
|
- **Refused too:** a link that is continued already (naming the link that continues it; continue that one, or `last:<id>`), a previous link whose folder is gone, and one whose model's host can't run it when no default model is configured to fall back to. A refusal starts nothing and archives nothing; a new link that fails to start puts back what was archived. Every refusal that can be known up front (the archive's ones included) comes before the recap wait. One continue of a session runs at a time (a lock in its folder; other chains don't wait), so a double click finds the first one's link and is refused, while a `last:<id>` continue that waited follows the chain to its new end.
|
|
257
259
|
- **Web:** `POST /api/sessions` takes `continues: "<id, prefix or last:id>"`, with `idle: true` (a new link waiting for its first message, previewed by the previous link's, or `preview`) or a `prompt`, and `model` (or `model_name`) for the new link's model, but not `dir`, `llm_context`, `thinking`, `settings`, `toolkit`, `tools`, `no_tools` or `parent_answers` (400 `invalid_continues`: the previous link's are taken). 201 is the new session's summary, `continues` set, with `moved`: the ids of the delegates that moved to it (`[]` for none), and `model_warning`: the default-model fallback's warning, then any about the model id, joined by `; ` (`null` for none), and `opener`: the previous link's first typed message, in full, when that link continues one itself (`null` otherwise: a chain's first link's first message is its task); 404 `not_found`; 400 `ambiguous_id` for a prefix of several sessions, `invalid_model` for a given model whose host config.yml doesn't have (or a fallback with no default model); 409 `continued` with `next_id` (the link that continues it already, which the page opens instead), `open_children` with `ids` (open delegates that can't move), `folder_gone`, `busy`, `scratch` or `owned_by_tui`. Every session summary carries `continues`.
|
|
258
|
-
- **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft written in this session stays instead; one carried over from another session or the start page gives way to the opener, and the toast's **Restore draft** brings it back): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a chi REPL owns, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `open in a chi REPL; close it there`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its id, its full start (`2026-10-10 14:32`) and its preview in the tooltip; a neighbour that started on the open link's own day shows its time instead (`← 09:12`, `14:32 →`). A link made by `continue →` inherits the previous link's preview (and its title, if it has one) when it is created, so its card keeps its chain's title whatever opener it is sent, and its top bar and browser tab show its card's title. A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ #N` chip (`#N of M` when later links aren't listed; its tooltip `Session chain: link N of M`) opens a popover of the other links, newest first (`#N`, its date, with its time (`2026-10-17 14:32`) when another link of the chain started the same day, its own title when it differs from the card's (a link inherits its chain's, so mostly it doesn't), else its recap's first sentence, else its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or "include archived" is on: every link is a card of its own then, its chip `#N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
|
|
260
|
+
- **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft written in this session stays instead; one carried over from another session or the start page gives way to the opener, and the toast's **Restore draft** brings it back): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a chi REPL owns, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `open in a chi REPL; close it there`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its id, its full start (`2026-10-10 14:32`) and its preview in the tooltip; a neighbour that started on the open link's own day shows its time instead (`← 09:12`, `14:32 →`). A link made by `continue →` inherits the previous link's preview (and its title, if it has one) when it is created, so its card keeps its chain's title whatever opener it is sent, and its top bar and browser tab show its card's title. A link with a recap of its own reads its recap's first sentence instead (a link without one reads its chain's name, and a first link's recap names its chain). A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ #N` chip (`#N of M` when later links aren't listed; its tooltip `Session chain: link N of M`) opens a popover of the other links, newest first (`#N`, its date, with its time (`2026-10-17 14:32`) when another link of the chain started the same day, its own title when it differs from the card's (a link inherits its chain's, so mostly it doesn't), else its recap's first sentence, else its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or "include archived" is on: every link is a card of its own then, its chip `#N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
|
|
259
261
|
- **CLI:** `chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT]` (see *Starting a session*).
|
|
260
262
|
- **Which link:** an id, a unique prefix, or `last:<id or prefix>`, the latest link of the chain that session is in. It walks the links that still exist: a deleted link in the middle (`chi sessions delete`, the retention sweep of an unarchived one) ends the walk before it, so `last:` of an earlier link then names that earlier link, and continuing it starts a second line beside the links after the gap; name a link after the gap instead. A trigger names the chain this way: `chi send` to an archived link would bring it back (a human's input), not continue the chain.
|
|
261
263
|
|