samagotchi 0.110.0 → 0.111.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 +24 -1
- data/docs/architecture.md +8 -5
- data/docs/cli.md +3 -0
- data/docs/configuration.md +43 -0
- data/docs/internals/engine.md +39 -10
- data/docs/internals/participants.md +1 -1
- data/docs/internals/remote-sessions.md +3 -3
- data/docs/internals/web-push.md +200 -0
- data/docs/internals/working-set.md +7 -6
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/engine.rb +146 -490
- data/lib/samagotchi/engine_snapshots.rb +223 -0
- data/lib/samagotchi/event_fanout.rb +236 -0
- data/lib/samagotchi/model_binding.rb +138 -0
- data/lib/samagotchi/send_command/wait_target.rb +10 -3
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session.rb +12 -3
- data/lib/samagotchi/session_access/remote.rb +2 -1
- data/lib/samagotchi/session_access.rb +7 -3
- data/lib/samagotchi/session_manager.rb +5 -0
- data/lib/samagotchi/turn_endings.rb +137 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +27 -1377
- data/lib/samagotchi/web/grants.rb +3 -12
- data/lib/samagotchi/web/message_display.rb +274 -0
- data/lib/samagotchi/web/private_files.rb +31 -0
- data/lib/samagotchi/web/public/app.js +30 -6
- data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
- data/lib/samagotchi/web/public/index.html +4 -0
- data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
- data/lib/samagotchi/web/public/notifications.js +137 -3
- data/lib/samagotchi/web/public/notify.js +1 -1
- data/lib/samagotchi/web/public/push.js +95 -0
- data/lib/samagotchi/web/public/stage_view.js +8 -0
- data/lib/samagotchi/web/public/sw.js +101 -0
- data/lib/samagotchi/web/public/turn_events.js +22 -0
- data/lib/samagotchi/web/push/attention.rb +105 -0
- data/lib/samagotchi/web/push/client.rb +103 -0
- data/lib/samagotchi/web/push/crypto.rb +126 -0
- data/lib/samagotchi/web/push/notifier.rb +230 -0
- data/lib/samagotchi/web/push/store.rb +285 -0
- data/lib/samagotchi/web/push.rb +73 -0
- data/lib/samagotchi/web/push_routes.rb +99 -0
- data/lib/samagotchi/web/server.rb +26 -2
- data/lib/samagotchi/web/session_control.rb +441 -0
- data/lib/samagotchi/web/session_create.rb +384 -0
- data/lib/samagotchi/web/session_show.rb +411 -0
- data/lib/samagotchi/worker.rb +3 -2
- metadata +25 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c3030ebe01072de864d730b27cfc709368d48948260f07b3e37fa83f8ba4b87a
|
|
4
|
+
data.tar.gz: 38f33d3c633d5467c8200817c2a12fc5fd0e287cf87cbd5233f0486326280eed
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: af19fcb8e29f712f43098d68d466bb8e68bdfc3b87177d7a2f99e73a2b609a535322ab181cacae03d7cf00e774a2f28933ff5b3ba30446d3e4112724f213bf6f
|
|
7
|
+
data.tar.gz: 72fb51ec5f3d216a36e27a040643517104fc8988f8010b6bdc70ac4f3b2bd2db281a4cc629a54b75e88dfbe46b78d02014bfc65142f39e40e6952ab13c919f7f
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,28 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.111.0] - 2026-10-24
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- chi web: push notifications to a phone's Home Screen app (approvals and questions) over an https public URL
|
|
16
|
+
(`chi web --tunnel tailscale`): add chi web to the Home Screen, tap the bell. An answered question's notification goes
|
|
17
|
+
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
|
|
18
|
+
docs/configuration.md (Phone notifications).
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- `chi send --new --wait` now knows the id of the turn the new session's first prompt runs, so a `--timeout` or
|
|
23
|
+
Ctrl-C there says `still running: chi send --wait --turn <T> <ID>` instead of the vaguer "waits again", and
|
|
24
|
+
`chi send --wait --turn <T> <ID>` gets that turn's reply (`--format json` carries its `turn_id`). chi web's
|
|
25
|
+
create answers the first turn's id as `first_turn_id`, so the same holds for `chi --remote … send --new --wait`.
|
|
26
|
+
- Web: a session's stream dropping mid-turn (a phone briefly offline) no longer shows "canceled (worker exited)"
|
|
27
|
+
while the worker runs on; the page re-reads first and shows it only when no worker took the turn. A re-read that
|
|
28
|
+
fails (the network still down) is retried with a backoff, and a stage that lets go of a turn clears its status row
|
|
29
|
+
and chip, so it no longer reads "thinking · step 1" for a turn it stopped following.
|
|
30
|
+
|
|
31
|
+
Update with `chi update` (the system bundle 0.111.0), then restart `chi web`.
|
|
32
|
+
|
|
11
33
|
## [0.110.0] - 2026-10-24
|
|
12
34
|
|
|
13
35
|
### Added
|
|
@@ -4228,7 +4250,8 @@ and long-lived sessions.
|
|
|
4228
4250
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
4229
4251
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
4230
4252
|
|
|
4231
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
4253
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.111.0...HEAD
|
|
4254
|
+
[0.111.0]: https://github.com/dm1try/samagotchi/compare/v0.110.0...v0.111.0
|
|
4232
4255
|
[0.110.0]: https://github.com/dm1try/samagotchi/compare/v0.109.0...v0.110.0
|
|
4233
4256
|
[0.109.0]: https://github.com/dm1try/samagotchi/compare/v0.108.0...v0.109.0
|
|
4234
4257
|
[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
|
|
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`
|
|
614
|
-
|
|
615
|
-
`
|
|
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
|
|
data/docs/configuration.md
CHANGED
|
@@ -1768,6 +1768,47 @@ 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).
|
|
1804
|
+
- `web.push.enabled: false` turns it off; `web.push.contact` (a `mailto:` address or an `https://` URL; default:
|
|
1805
|
+
the public URL) is the contact the push services see in chi web's key. A value that is neither stops `chi web`
|
|
1806
|
+
(Apple refuses it); a `[chi web] the push service refused chi's VAPID token` line means the contact or the key was
|
|
1807
|
+
refused, and pushes stop until chi web restarts.
|
|
1808
|
+
- The app keeps its own cookie (iOS copies Safari's when it is added, 400 days): `chi web --new-token` logs it out
|
|
1809
|
+
(it shows the "needs chi web's access token" page; paste the token there). Pushes keep coming meanwhile.
|
|
1810
|
+
- chi web not running: no pushes (Serve answers 502; a tap couldn't open the page anyway).
|
|
1811
|
+
|
|
1771
1812
|
## All settings
|
|
1772
1813
|
|
|
1773
1814
|
Every setting below takes the three forms described in
|
|
@@ -1898,6 +1939,8 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
|
|
|
1898
1939
|
| `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
1940
|
| `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
1941
|
| `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`. |
|
|
1942
|
+
| `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). |
|
|
1943
|
+
| `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
1944
|
| `history.file` | state dir | | Prompt history path, shared by the TUI and the web composer. |
|
|
1902
1945
|
| `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
1946
|
| `no_default_input` | `false` | `--no-default-input` | Don't pre-fill `default.input`; a top-level key. |
|
data/docs/internals/engine.md
CHANGED
|
@@ -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, @
|
|
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` (
|
|
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`
|
|
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
|
-
@
|
|
588
|
-
@
|
|
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
|
|
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,
|
|
@@ -166,12 +166,12 @@ with a "Machine-local" comment; the rest branch on `@access.remote?` (`SendComma
|
|
|
166
166
|
| Check | Local (in the command) | Under `--remote` |
|
|
167
167
|
|---|---|---|
|
|
168
168
|
| 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 |
|
|
169
|
+
| 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
170
|
| `--parent-answers` looser than config.yml (`Guardrails::ParentPolicy.capped_note`) | client | server: the create's `parent_answers_warning` |
|
|
171
171
|
| 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
172
|
| What a parent may pick, for the report (`ParentReport.strategy_for`) | client | server: the wait route's `parent_strategy` |
|
|
173
173
|
| `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` |
|
|
174
|
+
| `--continues`' previous link and model (`SendCommand#continued_model`) | client | server: `handle_continue` (`Web::SessionCreate`) |
|
|
175
175
|
| `--toolkit`, `--tool`, `--set` (`Toolkit.from_flags`, `SessionConfig.from_flags`) | client | server: sent as `SetupWords` → 400 `invalid_*` → exit 2 |
|
|
176
176
|
| `--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
177
|
| `sessions list`'s default project (`ProjectScope.root_for(Dir.pwd)`) | client | every project's; `--scope=project` needs `--cwd` (a folder there) |
|
|
@@ -212,7 +212,7 @@ with a "Machine-local" comment; the rest branch on `@access.remote?` (`SendComma
|
|
|
212
212
|
`web.public_url`, its host exactly, port included). `http://studio.local`
|
|
213
213
|
gets a 403 `forbidden`, which the client words as "use the LAN IP". The check guards against DNS rebinding.
|
|
214
214
|
`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`
|
|
215
|
+
- **`client_id` only tightens.** `handle_question_answer` (`Web::SessionControl`) forwards `client_id` only when it is `ClientId::CLI_ANSWER`
|
|
216
216
|
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
217
|
create and turn, a `cli:send` / `cli:answer` id adds checks (`CLI_SEND_IDS`) and never removes any.
|
|
218
218
|
- **A remote attach's `client_id`** (`tui:<pid>@<host>`, P4-D6) is forwarded as given on `/turn` and `/command`, as
|
|
@@ -0,0 +1,200 @@
|
|
|
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. Every write is a read-change-write under
|
|
61
|
+
`store.lock` (chi web's request threads and the sender thread both write). A `subscriptions.json` that doesn't read
|
|
62
|
+
lists no device and is never written over. `Store#check_permissions!` uses `Web::PrivateFiles.check!`, the check the
|
|
63
|
+
grants use, worded with what the folder holds; `Server.launch` runs it (`Push.preflight!`, with the contact and the
|
|
64
|
+
key) before any tunnel goes up, and a refusal is one `Error:` line, exit 1.
|
|
65
|
+
|
|
66
|
+
Each subscription keeps the public origin it was made on; the Notifier sends only to this run's. A renamed machine or
|
|
67
|
+
tailnet leaves the old ones stale (never sent to: a tap would open an origin that no longer serves chi).
|
|
68
|
+
|
|
69
|
+
## The client (`web/push/client.rb`)
|
|
70
|
+
|
|
71
|
+
`Client#deliver(sub, payload, ttl:, urgency:)` encrypts the payload's JSON, POSTs it over TLS (open 5 s, read 10 s,
|
|
72
|
+
no redirects, no retry) with `Content-Encoding: aes128gcm`, `TTL`, `Urgency` and the VAPID header, and answers a
|
|
73
|
+
`Client::Result`: `:ok` (200/201/202), `:gone` (404/410: the device dropped it), `:bad_jwt` (403 with
|
|
74
|
+
`BadJwtToken`), `:failed` (anything else; a connection error as its class name or `timeout`, never its message). The
|
|
75
|
+
transport is injectable: `spec/web/push/client_spec.rb` runs a fake push service (WEBrick, plain http through the
|
|
76
|
+
injected transport) that decrypts each body with the device's key and checks the headers and the JWT.
|
|
77
|
+
|
|
78
|
+
## The payload and the service worker
|
|
79
|
+
|
|
80
|
+
The service worker is the only reader of the payload (JSON, at most `Notifier::OPEN_LIMIT`, 3000 bytes, with
|
|
81
|
+
`open`; one aes128gcm record leaves 3993 bytes of plaintext under Web Push's 4096: 86 header, 1 delimiter, 16 tag):
|
|
82
|
+
|
|
83
|
+
- `{"v":1,"kind":"show","tag":KEY,"title":…,"body":…,"url":"/#/s/<id>","badge":N,"open":[KEY…]}`: TTL 12 h,
|
|
84
|
+
`Urgency: high`. `open` only while the whole list fits the limit; the test push (`chi:test`) has neither `open`
|
|
85
|
+
nor `badge`.
|
|
86
|
+
|
|
87
|
+
`badge` and `open` are the keys still open in the Notifier's summaries (`Attention.open_keys`: a pending approval
|
|
88
|
+
or question, a hook's too, not relayed), so an approval pushed by an earlier chi web run still counts after a
|
|
89
|
+
restart. `public/sw.js` is a classic script (module service workers on iOS are unverified) at scope `/`. Every push
|
|
90
|
+
event ends in `showNotification`: a payload that isn't a readable v1 show (no data, bad JSON, another `v`, a stale
|
|
91
|
+
`close` from an older chi web, any other kind) shows a fallback (`chi` / "open chi", tag `chi:other`, url `/`), and a
|
|
92
|
+
show iOS refuses is shown once more without its icon and data. A show calls `showNotification(title, {body, tag,
|
|
93
|
+
icon, data: {url}})` first; then, when `open` is an array, closes every `getNotifications()` whose tag is a
|
|
94
|
+
question's `"<sid>:<qid>"` (never `chi:*`), isn't the new one and isn't in `open` (after the show: closing before it
|
|
95
|
+
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`
|
|
96
|
+
skips waiting, `activate` claims the clients. No fetch handler and no caching: chi web has no offline mode, and a
|
|
97
|
+
fetch handler would change how the page loads. `spec/web/public/sw.test.js` runs it in a `vm` with a fake worker
|
|
98
|
+
scope.
|
|
99
|
+
|
|
100
|
+
The manifest, `sw.js` and the icons are `App::GATE_FREE_PATHS` (exact paths, still after the Host, cross-site and
|
|
101
|
+
frame checks): Chrome fetches a manifest without credentials, and none of them holds a secret. The icons are
|
|
102
|
+
rendered from `favicon.svg` by `script/render-icons` (Playwright's Chromium), full bleed and opaque (iOS fills
|
|
103
|
+
transparency with black).
|
|
104
|
+
|
|
105
|
+
## The Notifier (`web/push/notifier.rb`) and its rules (`web/push/attention.rb`)
|
|
106
|
+
|
|
107
|
+
`Push::Attention` is the server's port of `web/public/notify.js` for what P1 pushes, so a push and the tab's
|
|
108
|
+
notification ring for the same thing under the same key:
|
|
109
|
+
|
|
110
|
+
- `Attention.for(prev, next)`: a new `pending_question` id that isn't `relayed_to` (absent when nil: the summary
|
|
111
|
+
compacts it), whose **reason** (notify.js `questionReason`: `approval`, `continue`, anything else incl. `hook` is
|
|
112
|
+
a question) is approval or question. Filtering on the reason, never on `kind == "question"`, keeps hook questions.
|
|
113
|
+
A delegated child's (`parent_id`) approval or hook question holds `RELAY_HOLD` 3.5 s (notify.js `RELAY_HOLD_MS`):
|
|
114
|
+
its parent may relay it. The key is `"<sid>:<qid>"`.
|
|
115
|
+
- `Attention.closed_keys(prev, next)`: the question answered, replaced, newly relayed, or the session gone.
|
|
116
|
+
- `Attention.text(summary, reason)`: notify.js `attentionText`'s words: the session's title, first preview or last
|
|
117
|
+
prompt clipped to 61, and "needs approval" / "needs an answer". Never the question or the command.
|
|
118
|
+
|
|
119
|
+
`Notifier#call(event)` is a `SessionHub` sink. **The hub calls it under its Monitor**, from the tick thread and also
|
|
120
|
+
from chi web's request threads (`SessionHub#touch`, from the stop/archive/delete routes), so it only enqueues: a push
|
|
121
|
+
is a 0.5 s https call. `emit` already rescues a raising sink. The sink keeps the last summary per session and turns
|
|
122
|
+
each `session` / `session_gone` event into a `Show` (with its text) or a `Close` (it was answered anywhere) on a
|
|
123
|
+
Queue.
|
|
124
|
+
|
|
125
|
+
**Seeding**: `Notifier#start(hub)` subscribes with `snapshot: true` and takes the summaries as the seed, so what is
|
|
126
|
+
open already never pushes. The hub's projection is empty before its first scan, so `Server.start` runs `hub.scan`
|
|
127
|
+
once after the `chi` line's subscribe and before `notifier.start(hub)`, then `hub.start`. Events that reach the sink
|
|
128
|
+
before the seed is in (they are newer than the snapshot) are applied after it.
|
|
129
|
+
|
|
130
|
+
The sender thread holds each show (`HOLD` 3 s, or the event's own), then pushes it only while its key is still in
|
|
131
|
+
`Attention.open_keys` of the summaries (read under the sink's mutex at flush time), to every subscription on this
|
|
132
|
+
run's origin, with that list as `open` and its size as `badge`. A close only drops a show still in its hold: it
|
|
133
|
+
pushes nothing. Results: `:ok` stamps the device's
|
|
134
|
+
`last_ok_at`; `:gone` removes it; `:failed` keeps its code in `last_error` and logs `push_failed`; `:bad_jwt` logs
|
|
135
|
+
one `push_bad_jwt` warning (on chi web's terminal too) and stops all pushes until chi web restarts. Log lines carry
|
|
136
|
+
`device:` (the short id), `code:`, `ms:`, `kind:`: never the endpoint, keys or text. `#stop` drops pending holds and
|
|
137
|
+
joins with a timeout; `Server.start`'s ensure calls it. Specs drive `#pump` with a moved clock
|
|
138
|
+
(`start(hub, sender: false)`), and one example runs the real thread with a client that blocks.
|
|
139
|
+
|
|
140
|
+
## The routes (`web/push_routes.rb`)
|
|
141
|
+
|
|
142
|
+
`:owner` rows in `App::ROUTES` (so `participant_gate_spec` refuses each to a participant), handlers in
|
|
143
|
+
`Web::PushRoutes`; `@push` is the run's `Push::Runtime` (store, client, origin), nil when push is off (no https
|
|
144
|
+
public URL, or `web.push.enabled: false`):
|
|
145
|
+
|
|
146
|
+
- `GET /api/push[?endpoint=E]` → `{enabled, public_key, origin, this: {id, label}|null}`; off: `enabled: false` and
|
|
147
|
+
no key.
|
|
148
|
+
- `POST /api/push/subscriptions {subscription: {endpoint, keys}}` → 201 `{id, label}`; the label is chi's own
|
|
149
|
+
("iPhone · <date>", from the User-Agent's device class: iPhone, iPad, Mac, else Browser).
|
|
150
|
+
- `DELETE /api/push/subscriptions {endpoint}` → 204.
|
|
151
|
+
- `POST /api/push/test {endpoint?}` → a test push to that device or every one on this origin, answered per device
|
|
152
|
+
(`{devices: [{id, label, status, code, ms}]}`); the bell sends one after subscribing.
|
|
153
|
+
|
|
154
|
+
With push off the three writes answer 409 `push_off`.
|
|
155
|
+
|
|
156
|
+
## The page (`public/push.js`, `public/notifications.js`)
|
|
157
|
+
|
|
158
|
+
`push.js` holds the pure parts: `pushSupported({win, nav})` (a secure context, `serviceWorker`, `PushManager`, and a
|
|
159
|
+
standalone app: `matchMedia("(display-mode: standalone)")` or iOS's `navigator.standalone`), `urlBase64ToBytes`,
|
|
160
|
+
`subscribe({reg, key, post})` (drops a subscription made with another key), `unsubscribe({reg, del})`. In the Home
|
|
161
|
+
Screen app the bell (`notifications.js`) registers `/sw.js`, asks `GET /api/push` with its subscription's endpoint,
|
|
162
|
+
and is "push to this phone" when chi web pushes: on while chi web has this device. A tap asks permission from the tap
|
|
163
|
+
(iOS asks only from a user gesture), subscribes, posts it and sends a test push; a tap when on unsubscribes and
|
|
164
|
+
deletes. The tab's own OS notification is left to the push there; the title count stays. In a browser tab, or with
|
|
165
|
+
chi web not pushing, the bell is the in-tab one, unchanged (no duplicate desktop pushes).
|
|
166
|
+
|
|
167
|
+
**The device's tidy-up** (`syncDevice`, the Home Screen app with the bell on): closes the shown notifications whose
|
|
168
|
+
tag is a question's (`push.js isQuestionTag`, so `chi:test` stays) and isn't open in the page's `byId`
|
|
169
|
+
(`push.js openPushKeys`: `Attention.open_keys`'s rule, with notify.js's `questionReason`), and sets or clears the app
|
|
170
|
+
badge (`navigator.setAppBadge` / `clearAppBadge`); the bell turned off clears it. It runs once push is known, when
|
|
171
|
+
the page comes back in front, and after each hub frame while visible (so answering on the Mac with the app open
|
|
172
|
+
clears it at once), one at a time with one rerun. Never before the first snapshot (an empty `byId` would close
|
|
173
|
+
everything) and never on a project's page (`createNotifications({scoped})`, app.js passes `!!scope.dir`: its
|
|
174
|
+
`/api/events?dir=` leaves out the other projects' sessions).
|
|
175
|
+
|
|
176
|
+
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)
|
|
177
|
+
(or a swipe), and the icon badge may be too high until then; a show already handed to Apple just before its answer
|
|
178
|
+
can land after the page's tidy-up and stay until the next one. Unverified on iOS: `getNotifications()` and
|
|
179
|
+
`setAppBadge` from the page (a Window); the worker's cleanup still runs on the next push if they do nothing.
|
|
180
|
+
|
|
181
|
+
## iOS facts (the spike, 2026-10-24, on an iPhone)
|
|
182
|
+
|
|
183
|
+
- **Close by tag lookup** (the P1b phone check, 2026-10-24, iOS 26): inside a push handler, after `showNotification`,
|
|
184
|
+
the unfiltered `getNotifications()` lists the shown ones with their tags, but `close()` on one from that list left
|
|
185
|
+
it on the lock screen; one from `getNotifications({tag})` closed. So `sw.js`'s cleanup reads the tags from the list
|
|
186
|
+
and closes through the tag lookup. Not yet known whether that is a WebKit bug (unfiltered close never probed alone). **But** on a real
|
|
187
|
+
question pair (A shown, answered, B pushed ~6 s later, the new worker confirmed) A's banner still stayed: the tag
|
|
188
|
+
lookup closed a banner minutes old (the probe) but not a seconds-old one. So the worker's cleanup doesn't work on iOS
|
|
189
|
+
yet (cause unknown: timing, or the banner still presenting); the page's cleanup when the app opens does.
|
|
190
|
+
- **No replace by `tag`**: two notifications with one tag both stay, and closing same-tag ones inside the push
|
|
191
|
+
handler before `showNotification` doesn't remove the older one. A separate close push that only closed
|
|
192
|
+
`getNotifications({tag})` did, but it shows nothing (see the quota below), so the worker now closes after its show.
|
|
193
|
+
- **The service worker can't see the app in front** (`clients.matchAll` gave 0 windows with the app open) and a
|
|
194
|
+
banner shows anyway: "don't push while I'm looking" has to be decided server-side, from the pages' presence (P2).
|
|
195
|
+
- **Silent pushes**: the spike's 4 close pushes in a row didn't revoke the subscription, but that was luck or lazy
|
|
196
|
+
enforcement: the user check's 6 did (WebKit's quota is 3 pushes that show nothing; see the top of this page).
|
|
197
|
+
- iOS sent the cookie with the manifest, `sw.js` and icon fetches, but Chrome fetches manifests without credentials:
|
|
198
|
+
they are gate-free rather than relying on that.
|
|
199
|
+
- `navigator.setAppBadge(n)` from the service worker sets the icon's badge; a push mirrors to the Watch.
|
|
200
|
+
- The Home Screen app copies Safari's cookie when it is added and keeps its own jar after (400 days).
|
|
@@ -99,9 +99,9 @@ when it has outputs and none is `sent`. An edit-only item, a failed run's file o
|
|
|
99
99
|
## Delivery
|
|
100
100
|
|
|
101
101
|
**The event.** The Engine emits `{type: :working_set_updated, items:, at:}` (and `adds:` while the user's adds are
|
|
102
|
-
pending: [Steering: add and unadd](#steering-add-and-unadd)) (`Engine#emit_working_set`):
|
|
103
|
-
- after each `:generation_started`, in `build_stream_event_handler
|
|
104
|
-
(`Engine#live_conversation`, read on the turn's thread) under the turn's strategy
|
|
102
|
+
pending: [Steering: add and unadd](#steering-add-and-unadd)) (`Engine#emit_working_set`, `EventFanout`'s):
|
|
103
|
+
- after each `:generation_started`, in `build_stream_event_handler` (`EventFanout`), from the running turn's
|
|
104
|
+
conversation (`Engine#live_conversation`, read on the turn's thread) under the turn's strategy
|
|
105
105
|
(`@kernel.turn_settings.llm_context`). Both loops start a generation after `apply_llm_context!`, so the set is
|
|
106
106
|
what that request sends;
|
|
107
107
|
- once in `Engine#end_turn`, after `replace_session_messages` and before the end event, from the kept messages (the
|
|
@@ -122,7 +122,7 @@ smaller set reaches the page then, not at the next request. It goes out whatever
|
|
|
122
122
|
the Bridge (`ClientEvent.project`) passes it on unchanged, and the attached TUI ignores it.
|
|
123
123
|
|
|
124
124
|
**The route.** `GET /api/sessions/:id/working_set` (`Web::App#handle_working_set`) answers the snapshot's `to_h`
|
|
125
|
-
and `running`. With a live worker it builds from the worker's snapshot messages (as `handle_show` reads them, keys
|
|
125
|
+
and `running`. With a live worker it builds from the worker's snapshot messages (as `handle_show` (`Web::SessionShow`) reads them, keys
|
|
126
126
|
symbolized) and the worker's llm ctx chip. Without one it builds from the session file and its saved chip
|
|
127
127
|
(`SessionSetting::LLM_CONTEXT`). The folder is the session's `working_directory`. `running` is true when the
|
|
128
128
|
snapshot has a turn in progress (`current_turn`).
|
|
@@ -206,8 +206,9 @@ projection), on both loops. It costs one model step; a model may skip it (the no
|
|
|
206
206
|
- **The projection**: the pending adds are not items (nothing of them is sent), but the snapshot carries them:
|
|
207
207
|
`WorkingSet.pending_adds` reads the pending note (`WorkingSet.pending_note_index` / `pending_ops`, which
|
|
208
208
|
`WorkingSetSteer` delegates to) into `Snapshot#adds` (`WorkingSet::Add`: `kind`, `key`, `label`), and `to_h` adds
|
|
209
|
-
`adds:` while any is pending. `Engine#emit_working_set`
|
|
210
|
-
set and the next turn's first request (the note sent, no adds)
|
|
209
|
+
`adds:` while any is pending. `Engine#emit_working_set` (`EventFanout`, which keeps the last one emitted) compares
|
|
210
|
+
the items and the adds, so an add announces a new set and the next turn's first request (the note sent, no adds)
|
|
211
|
+
emits again, which clears the page's pending add.
|
|
211
212
|
A running turn's set has none (the note was sent when it started), so the Bridge snapshot's `working_set` and the
|
|
212
213
|
GET's `running` answer leave it out.
|
|
213
214
|
|
data/docs/sessions.md
CHANGED
|
@@ -225,7 +225,7 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
|
|
|
225
225
|
(`SAMAGOTCHI_THINKING_LEVEL=low chi send --new …`) only reaches the worker this call spawns: when the session's
|
|
226
226
|
worker is woken later (by `chi web`, a delivery) without that env, the level is gone. Use `--thinking`.
|
|
227
227
|
- `--new` prints `<full id> started` on stdout. With `--wait` that line (and the `sent` line for an existing session) goes to stderr, so stdout is exactly the answer: the reply of the turn the message started, in full. It is the worker's `output/` file, the same one `delegate` reads; a turn that ends with only tool calls and no text has none.
|
|
228
|
-
- `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; --queue runs the message after it, chi send --wait ID waits for it, chi --attach ID shows it`, exit 1): the message would merge into it, and its reply would come back as the answer. To an idle session (or with `--new`) the wait is for the message's own turn (the ack's `enqueued_id`; one merged into a turn that started at that moment gets that turn's reply). `--queue --wait` is the way in: the message runs as a turn of its own after the running one, and the wait is for that turn alone (the ack's `enqueued_id` names its reply file and its `last_turn`), so the running turn's reply and end, and a question it asks meanwhile, are not the answer (it keeps waiting through them; its `--timeout` still applies); a question its own turn asks is (exit 3). Two queued waits each get their own turn's answer. A session waiting for an answer is still refused. `--format json` adds `delivery`: `queue`, or `next_step` when the running turn ended before the message went in (it then ran at once, and the answer is still its own).
|
|
228
|
+
- `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; --queue runs the message after it, chi send --wait ID waits for it, chi --attach ID shows it`, exit 1): the message would merge into it, and its reply would come back as the answer. To an idle session (or with `--new`) the wait is for the message's own turn (the ack's `enqueued_id`, or, for a `--new` that starts with the message, the id its create gives that first turn — `first_turn_id` in chi web's 201, `turn_id` in `--format json`; one merged into a turn that started at that moment gets that turn's reply). `--queue --wait` is the way in: the message runs as a turn of its own after the running one, and the wait is for that turn alone (the ack's `enqueued_id` names its reply file and its `last_turn`), so the running turn's reply and end, and a question it asks meanwhile, are not the answer (it keeps waiting through them; its `--timeout` still applies); a question its own turn asks is (exit 3). Two queued waits each get their own turn's answer. A session waiting for an answer is still refused. `--format json` adds `delivery`: `queue`, or `next_step` when the running turn ended before the message went in (it then ran at once, and the answer is still its own).
|
|
229
229
|
- Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question, a guardrail approval, or the step-limit question: see [chi as a sub-agent](sub-agent.md#the-step-limit)), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi send --wait --turn T ID` for a turn whose id is known, else `chi send --wait ID waits again; chi --attach ID shows it`, as after a `--timeout`). There is no default timeout.
|
|
230
230
|
- `--format json` (with `--wait`) prints one JSON object on stdout however the wait ends, for a script or a parent agent (see [chi as a sub-agent](sub-agent.md)): `{"status":"answered","session_id":…,"text":…}`; `{"status":"question",…,"question":{"id","kind","header","text","options","multi_select","allow_freeform","approval"},"answer_with":"chi answer ID --question QID --option N"}`, where `kind` is `question` (the model's), `hook` or `approval` (with the tool, command, cwd, rule, reason and offered scopes); `{"status":"running",…,"detail":…}` (exit 4, and on Ctrl-C); and `failed`, `canceled`, `removed` (a `--queue --wait` message taken back before it ran), `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1); `failed` adds `error_kind` and `retryable`, `canceled` adds `cancel_reason` (`worker_stopped`: the turn waited for died with its worker and the next worker settled it) and `stopped_by`, when known. A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
|
|
231
231
|
- `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. With a turn running, that turn's reply (its `interrupted_turn` mark names it), not a turn's queued behind it; with the step-limit question standing, the reply of the turn its Continue runs; with an idle session whose last turn already ended, that ended turn is the answer: its reply, or its end when it left no reply — a turn that finished in the gap between waits is reported, not waited for. With none of those (or a worker whose `bridge.json` lacks `turn_ids`), the next reply of any turn. A running session is fine (that is the point), so after exit 3, 4 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
|