samagotchi 0.109.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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/bin/chi +5 -1
  4. data/docs/architecture.md +9 -6
  5. data/docs/cli.md +43 -1
  6. data/docs/configuration.md +140 -3
  7. data/docs/internals/engine.md +58 -14
  8. data/docs/internals/participants.md +7 -4
  9. data/docs/internals/prompt-caching.md +5 -0
  10. data/docs/internals/remote-sessions.md +3 -3
  11. data/docs/internals/web-push.md +200 -0
  12. data/docs/internals/working-set.md +7 -6
  13. data/docs/plugins.md +1 -1
  14. data/docs/sessions.md +4 -2
  15. data/docs/sub-agent.md +1 -1
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/config.rb +4 -0
  19. data/lib/samagotchi/context_note.rb +7 -3
  20. data/lib/samagotchi/engine.rb +166 -499
  21. data/lib/samagotchi/engine_snapshots.rb +223 -0
  22. data/lib/samagotchi/event_fanout.rb +236 -0
  23. data/lib/samagotchi/idle_recap.rb +1 -1
  24. data/lib/samagotchi/idle_reminders.rb +1 -1
  25. data/lib/samagotchi/kernel_loop.rb +12 -7
  26. data/lib/samagotchi/llm/chat_loop.rb +9 -2
  27. data/lib/samagotchi/message_time.rb +104 -0
  28. data/lib/samagotchi/model_binding.rb +138 -0
  29. data/lib/samagotchi/reminder_queue.rb +20 -3
  30. data/lib/samagotchi/reply_wait/poll.rb +39 -1
  31. data/lib/samagotchi/run_options.rb +4 -1
  32. data/lib/samagotchi/send_command/wait_target.rb +10 -3
  33. data/lib/samagotchi/send_command.rb +2 -1
  34. data/lib/samagotchi/session.rb +12 -3
  35. data/lib/samagotchi/session_access/contract.rb +1 -1
  36. data/lib/samagotchi/session_access/local.rb +11 -5
  37. data/lib/samagotchi/session_access/remote.rb +2 -1
  38. data/lib/samagotchi/session_access.rb +7 -3
  39. data/lib/samagotchi/session_inbox.rb +7 -3
  40. data/lib/samagotchi/session_manager.rb +5 -0
  41. data/lib/samagotchi/steer.rb +5 -3
  42. data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
  43. data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
  44. data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
  45. data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
  46. data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
  47. data/lib/samagotchi/terminal_ui.rb +40 -5
  48. data/lib/samagotchi/tool_activity.rb +11 -0
  49. data/lib/samagotchi/turn_checkpoint.rb +6 -1
  50. data/lib/samagotchi/turn_endings.rb +137 -0
  51. data/lib/samagotchi/version.rb +1 -1
  52. data/lib/samagotchi/web/app.rb +28 -1369
  53. data/lib/samagotchi/web/grants.rb +3 -12
  54. data/lib/samagotchi/web/message_display.rb +274 -0
  55. data/lib/samagotchi/web/participant_view.rb +12 -5
  56. data/lib/samagotchi/web/private_files.rb +31 -0
  57. data/lib/samagotchi/web/public/app.js +41 -9
  58. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  59. data/lib/samagotchi/web/public/bubbles.js +29 -7
  60. data/lib/samagotchi/web/public/format.js +72 -6
  61. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  62. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  63. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  64. data/lib/samagotchi/web/public/index.html +6 -0
  65. data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
  66. data/lib/samagotchi/web/public/notifications.js +137 -3
  67. data/lib/samagotchi/web/public/notify.js +1 -1
  68. data/lib/samagotchi/web/public/participant.html +2 -0
  69. data/lib/samagotchi/web/public/participant.js +30 -7
  70. data/lib/samagotchi/web/public/participant_model.js +25 -2
  71. data/lib/samagotchi/web/public/push.js +95 -0
  72. data/lib/samagotchi/web/public/stage_view.js +8 -0
  73. data/lib/samagotchi/web/public/stream_handlers.js +32 -4
  74. data/lib/samagotchi/web/public/sw.js +101 -0
  75. data/lib/samagotchi/web/public/time_gaps.js +98 -0
  76. data/lib/samagotchi/web/public/tokens.css +2 -2
  77. data/lib/samagotchi/web/public/turn_events.js +25 -1
  78. data/lib/samagotchi/web/public/turn_html.js +15 -8
  79. data/lib/samagotchi/web/public/turn_view.js +8 -3
  80. data/lib/samagotchi/web/push/attention.rb +105 -0
  81. data/lib/samagotchi/web/push/client.rb +103 -0
  82. data/lib/samagotchi/web/push/crypto.rb +126 -0
  83. data/lib/samagotchi/web/push/notifier.rb +230 -0
  84. data/lib/samagotchi/web/push/store.rb +285 -0
  85. data/lib/samagotchi/web/push.rb +73 -0
  86. data/lib/samagotchi/web/push_routes.rb +99 -0
  87. data/lib/samagotchi/web/running.rb +39 -4
  88. data/lib/samagotchi/web/server.rb +112 -32
  89. data/lib/samagotchi/web/session_control.rb +441 -0
  90. data/lib/samagotchi/web/session_create.rb +384 -0
  91. data/lib/samagotchi/web/session_show.rb +411 -0
  92. data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
  93. data/lib/samagotchi/web/tunnel.rb +133 -0
  94. data/lib/samagotchi/worker.rb +3 -2
  95. metadata +30 -1
@@ -58,7 +58,9 @@ participant** and **one role**, until it expires or is revoked.
58
58
 
59
59
  `App#principal_for` resolves every request to a `Web::Principal`:
60
60
 
61
- 0. With `web.public_url` (`Web::ProxyGate`), a **proxied** request (a proxy trace: `X-Forwarded-*`, `Forwarded`,
61
+ 0. With `web.public_url` (`Web::ProxyGate`; `chi web --tunnel tailscale` sets the same for its run, never in config:
62
+ public_url = the machine's `ts.net` name, identity_header `Tailscale-User-Login`, owner `web.proxy.owner` else this
63
+ machine's Tailscale login, `Web::Tunnel::Tailscale`), a **proxied** request (a proxy trace: `X-Forwarded-*`, `Forwarded`,
62
64
  `X-Real-IP`, a known identity header, the configured one, or the public Host) is `#proxied_principal`'s: the owner
63
65
  only with the owner's token **and**, when `web.proxy.identity_header` is set, that header being one clean value
64
66
  (not repeated, no comma, not empty, under one name with no underscore: the raw header names) equal to
@@ -204,12 +206,13 @@ made by `chi share revoke` in another process; the page then reads a 401 and sho
204
206
  D5 (a): prompts and replies with their authors, one-line tool rows, turn boundaries, the queue and waiting states.
205
207
  Not tool output or arguments, thinking, approvals' and questions' content, memories (text or names), context notes,
206
208
  plugin steers, automatic and delegate input, cards, command output, the working set (the user decided: not), cost,
207
- timing, host paths or the owner's prompt history. Every read a participant reaches goes through one module,
209
+ timing (durations, records: only the wall time `at` of each prompt, reply and turn start, for the page's gap lines
210
+ and hover times, D6 of the session-time-gaps plan), host paths or the owner's prompt history. Every read a participant reaches goes through one module,
208
211
  `Web::ParticipantView` (`lib/samagotchi/web/participant_view.rb`), whose every answer is a new hash built from named
209
212
  fields: a field added to an event later reaches nobody until it is named there.
210
213
 
211
214
  - **Events** (`.event`, the stream). `EVENTS` names the types a participant gets, each rebuilt by its own method:
212
- `turn_started` (turn id, the author and name, `mine` for their own, the prompt only when a person sent the turn:
215
+ `turn_started` (turn id, its wall time `at`, the author and name, `mine` for their own, the prompt only when a person sent the turn:
213
216
  `ClientId.human?`, else `automatic`; `continue`), the turn's end (`turn_completed`, `turn_canceled`, `turn_failed`:
214
217
  type and turn id), `generation_chunk` (its visible `text` only; a thinking-only chunk is dropped),
215
218
  `generation_retrying` (`restarted`), `tool_call_started` (the tool's name, and for a file tool the path relative to
@@ -231,7 +234,7 @@ fields: a field added to an event later reaches nobody until it is named there.
231
234
  and never after a key word whose value is still to come, `Redact::OPEN_VALUE`; past 1600 with no such space,
232
235
  the line's last unbroken run and the rest of that line go out as one `[redacted]`). The owner's stream never
233
236
  goes through it (a byte pipe).
234
- - **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
235
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;
236
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
237
240
  Continue) never show. A turn no person sent (its record's `automatic`, `SessionMetrics`: a task's or a plugin's,
@@ -219,6 +219,11 @@ is prefilled once. The native and the chat loop use the same plan.
219
219
  `CacheConfirm.needed?` asks only for `:full`, `:provider` and `:history`, when the effective level changes and
220
220
  the session has a model reply. A new setting with a cost (a `/model` across templates, `/llm-context` turning
221
221
  forget on) adds its classifier and goes through the same step.
222
+ 7. **No wall-clock time rendered at wire time.** A history entry's `at` (`MessageTime::KEY`, when it happened) is
223
+ display-only: no wire format reads it, and a guard spec checks that `ChatLoop#wire_messages` and
224
+ `Prompt.format_with_images` give the same bytes with and without it (`spec/message_time_history_spec.rb`). A
225
+ time the model should read is baked into the message's content when the message is made (a context note's
226
+ frame, the reminders' header), so a replay sends the same bytes whatever the time zone or DST.
222
227
 
223
228
  ## Server-side settings worth knowing
224
229
 
@@ -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`, from the running turn's conversation
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` compares the items and the adds, so an add announces a new
210
- set and the next turn's first request (the note sent, no adds) emits again, which clears the page's pending add.
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/plugins.md CHANGED
@@ -756,7 +756,7 @@ full label, `plugin.rb (bundle my-bundle)`.
756
756
  | `ctx.settings` | the bundle's settings, frozen: config.yml's `bundles: <bundle>:` with the session's own `--set` values laid over it ([Per-session settings](#per-session-settings)); the config alone until the session is bound (in `register` and `chi.ctx`) |
757
757
  | `ctx.data_dir` | `$XDG_STATE_HOME/samagotchi/plugins/<bundle>/`, created on first use. A session's own state goes in `sessions/<id>.json` or `sessions/<id>/` there: chi removes it when the session is deleted, discarded or pruned |
758
758
  | `ctx.log` | `ctx.log.info(:event, key: value)`: debug-log records tagged `plugins`, with `bundle=<bundle>` |
759
- | `ctx.messages` | the conversation, as a frozen copy, without the system prompt. While a turn runs, a session worker's (attached, web) adds that turn so far: its prompt, the model's text and the lines merged into it (no tool calls or thinking); the REPL's is the conversation before that turn |
759
+ | `ctx.messages` | the conversation, as a frozen copy, without the system prompt. While a turn runs, a session worker's (attached, web) adds that turn so far: its prompt, the model's text and the lines merged into it (no tool calls or thinking); the REPL's is the conversation before that turn. An entry may carry `at`, when it happened (docs/sessions.md, Message times) |
760
760
  | `ctx.messages_partial?` | whether `ctx.messages` leaves out a running turn (the REPL mid-turn), so a plugin can say what its answer is about |
761
761
  | `ctx.prompt_files` | the files whose text is in the session's system prompt now: the AGENTS.md chain (the work tree top down to the cwd) and the appended files (`append_system_prompt:`, `--append-system-prompt`); absolute paths, in prompt order, `[]` before the first build |
762
762
  | `ctx.notify(text, level: :info, fallback_for: nil)` | one line to the user, labelled by the bundle (`my-bundle> …`). Every UI shows it, during a turn (a tool, a hook) or between turns (a command). `fallback_for: :display` marks a line that repeats the answer's display (`event[:present]`), which a UI that renders the display leaves out (`fallback_for: :editor`: a file the viewer could open from a ref in its editor): see [Hooks](hooks.md#what-a-hook-can-do-the-runtime). The keyword needs chi 0.35.0 (it raises on an older chi) |
data/docs/sessions.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/sessions/<uuid>.json` (XDG-aware via `XDG_STATE_HOME`) plus a sidecar dir `<uuid>/` with `input/`/`notes/`/`output/`/`owner.lock`/`bridge.json` (`Session.session_dir`), and `tool_output/` once a long `execute` result was cut: its whole output, which the cut result names (`ToolOutputFiles`, the newest 50 kept; see [Tool output guardrails](internals/tool-guardrails.md#the-runners-cut-max_tool_output_chars)). Besides the messages and turn state, the JSON keeps `used_memory_names` (what the session read), `preloaded_memory_names` (`--memory`) and `muted_memory_names` (`--mute`), the last two written before the worker starts so a respawn builds the same prompt, `prompt_notes`, the model notes the system prompt carried at its last build (`[{name, scope, chars, digest}]`, see [Memory](memory.md#model-notes)), `parent_id`, the session that delegated this one (see *Delegating*; `null` otherwise), and `continues`, the session this one continues (the previous link of a chain, see *Session chains*; `null` otherwise); files from before those keys read as empty lists, no parent and no previous link. `model_name` is the resolved model ref (an alias applied, `host:id` when it names a host), so a resumed session keeps its model when an alias is retargeted; `model_typed` is the name it was given as when that differs (an alias), for the `models:` lookup after a resume (`null` otherwise).
4
4
 
5
+ **Message times:** an entry of `messages` may carry `at`, when it happened: a local-offset ISO time with milliseconds (`2026-10-24T14:32:05.120+02:00`). User prompts, lines merged into a turn and steers, reminders, context and task notes, and the model's replies have one (a note's is its `created_at`); tool results, `!cmd` messages and chi's own turn notes don't, nor does anything saved before chi stamped messages (no backfill). It is for the UIs only (the gap dividers, a bubble's time): it never goes to the model, so it changes nothing in the requests or their prompt cache. The one time the model reads is the reminders': due reminders go into a turn as a system message headed `[SYSTEM: REMINDERS DUE 2026-10-24 14:32 local]` (the turn's local time; `ReminderQueue#inject!`), written into the saved content then, so a later request sends the same text.
6
+
5
7
  **Input files:** a message for a session's worker waits in `input/<timestamp>.json` (`SessionInbox.write_input`: `prompt`, `client_id`, `enqueued_id`, and `images`, `no_interrupt` and `delivery` when they apply; `delivery` is written only for `"queue"`). While a turn runs, the worker merges them at each step boundary in name order, except a queued one or one with images: those wait for the turn's end and run one turn each, in name order, and a file after them still merges (`Worker#pending_input_drain`). A queue is sent only to a worker whose `bridge.json` names the `delivery` feature; an older one would steer it, so `SessionManager.deliver_turn` refuses it (409 `delivery_unavailable`), and any other 4xx the Bridge answers is a refusal too, never a fallback file (except a 413, a body over its size limit: that one still goes in as a file, its `delivery` kept). A session command recognized in a file runs without a turn; its outcome is kept at `input/settled/<enqueued_id>` (`completed`, `failed` for a command error, `canceled` with `cancel_reason` `busy` or `dropped` for one refused mid-turn or dropped as the worker left; `origin: command`) so `chi send --wait` ends instead of waiting for a turn that will not run, its detail saying `the command failed` or `the command was refused (busy)` rather than a turn's words. **Reply files:** a turn that ends with visible text leaves its reply in `output/<timestamp>.txt`, or `output/<timestamp>-<enqueued_id>.txt` when the turn ran an input file with an id (`SessionInbox.write_output`), and the session's `last_turn` keeps that `enqueued_id`; so a wait for one queued message (`chi send --queue --wait`, `ReplyWait`'s `turn:`) finds its own turn's reply or end, never another turn's. The names still sort by time, and every reader takes `*.txt`.
6
8
 
7
9
  **Removing a queued message:** a message queued for after the running turn (`chi send --queue`, the web's Alt+Enter, `/queue TEXT` in a terminal), or one with images (it waits for the turn's end too), can be taken back while it still waits: the worker's Bridge (`POST /session/:id/turn/dequeue {enqueued_id}`, the `unqueue` feature) deletes its input file and every UI drops it (`:turn_dequeued`). It is a race with the turn's end, decided by the same rename the worker uses to take the file: once a turn took it the answer is `409 already_started` (it runs), and an id the worker no longer knows is `404 unknown_enqueued_id`. A plain message sent without `--queue` is not removable (`409 not_queued`): a running turn's next step may take it at any moment. A wait for its turn (`chi send --queue --wait`, a delegate follow-up's wait) ends at once with `removed` (the tombstone `input/removed/<enqueued_id>` says so), and a `chi -p` run whose queued prompt went ends with exit 1. The `200` carries the removed `prompt`: the web and the attached terminal put it back into the composer to edit (not its images). The images a removed message named stay in the session's `images/`. Not removable: a queued session command (`/queue /model x`: it waits in the worker, not in `input/`), a message in a stopped worker's inbox (the next worker runs it), and the plain REPL's (`--no-shared`) own queue.
@@ -29,7 +31,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
29
31
 
30
32
  **A worker outlives whoever started it:** a spawned worker leaves its spawner's process tree (it is forked again, in a session of its own, and reparented to init), so closing the terminal of a `chi web`, or a tool tearing down a background `chi send --wait` and its children, no longer takes the worker and its turn with it.
31
33
 
32
- **A worker killed mid-turn:** while a turn runs, its worker keeps it on disk (`TurnCheckpoint`): the session file has the turn so far (the prompt from the turn's start, then the finished tool steps and lines merged into it) and `interrupted_turn: {id, started_at, at, iteration, enqueued_id}` (`enqueued_id`: the input the turn runs, so any process can tell which turn is running; a continue turn carries its offer turn's), written as the turn starts, at the loop's first iteration boundary and then at most every 10 s (`updated_at` moves with it). The turn's end saves the real messages without the mark. A worker that died without ending its turn (SIGKILL, a crash of the process) leaves the mark, and loses what came after its last written boundary: at most the last 10 s of steps, plus the step in flight. Each checkpoint write also saves the turn's metrics so far to `analytics.json` (tokens, speeds, timings, its finished tool calls), as the record a killed turn leaves: canceled, `cancellation_reason: worker_stopped`; the turn's own end writes over it. The next worker to start for the session (a `chi send`, `--resume`, `--attach`, the web), or a `--no-shared` REPL resuming it, settles the turn before anything else (`TurnCheckpoint.settle`, log `interrupted_turn_settled`): a note `the previous turn was cancelled (worker-stopped) after Ns` goes at the tail, the status goes idle and the mark goes; `last_turn` stays the previous turn's (a new one saved before the turn a `chi send --wait` woke the worker for would end that wait at once); the dead turn's end is recorded under its `enqueued_id` instead, in `<id>/input/settled/<enqueued_id>` (a `last_turn` record: canceled, `cancel_reason: worker_stopped`), which only a wait for that very turn reads (`chi send --queue --wait`, a `chi send --wait -m` to an idle session or `--new`, `chi send --wait ID --turn`, a continue's wait): it ends `canceled` with `cancel_reason: worker_stopped`, detail "its worker exited mid-turn", instead of timing out. A reply the turn wrote before its worker died is still its answer. The session shows the turn's prompt and steps with that note; the dead turn is not run again, and the model reads on its next turn that it was cut.
34
+ **A worker killed mid-turn:** while a turn runs, its worker keeps it on disk (`TurnCheckpoint`): the session file has the turn so far (the prompt from the turn's start, then the finished tool steps and lines merged into it) and `interrupted_turn: {id, started_at, at, iteration, enqueued_id}` (`enqueued_id`: the input the turn runs, so any process can tell which turn is running; a continue turn carries its offer turn's), written as the turn starts, at the loop's first iteration boundary and then at most every 10 s (`updated_at` moves with it). The turn's end saves the real messages without the mark. A worker that died without ending its turn (SIGKILL, a crash of the process) leaves the mark, and loses what came after its last written boundary: at most the last 10 s of steps, plus the step in flight. Each checkpoint write also saves the turn's metrics so far to `analytics.json` (tokens, speeds, timings, its finished tool calls), as the record a killed turn leaves: canceled, `cancellation_reason: worker_stopped`; the turn's own end writes over it. The next worker to start for the session (a `chi send`, `--resume`, `--attach`, the web), or a `--no-shared` REPL resuming it, settles the turn before anything else (`TurnCheckpoint.settle`, log `interrupted_turn_settled`): a note `the previous turn was cancelled (worker-stopped) after Ns` goes at the tail, the status goes idle and the mark goes; `last_turn` stays the previous turn's (a new one saved before the turn a `chi send --wait` woke the worker for would end that wait at once); the dead turn's end is recorded under its `enqueued_id` instead, in `<id>/input/settled/<enqueued_id>` (a `last_turn` record: canceled, `cancel_reason: worker_stopped`), which only a wait for that very turn reads (`chi send --queue --wait`, a `chi send --wait -m` to an idle session or `--new`, `chi send --wait ID --turn`, a continue's wait): it ends `canceled` with `cancel_reason: worker_stopped`, detail "its worker exited mid-turn", instead of timing out. A wait with no turn id that saw the turn running ends the same way when the settle comes (`chi send --wait ID` with no message on a turn with no id, such as a session's first prompt or a reminder turn; a message merged into the turn as a steer), unless an input still waits or was claimed since: an unmerged message runs as its own turn next, and that turn's end is the answer. A reply the turn wrote before its worker died is still its answer. The session shows the turn's prompt and steps with that note; the dead turn is not run again, and the model reads on its next turn that it was cut.
33
35
 
34
36
  **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`. A running turn is canceled over the Bridge first, with reason `session_stopped` (the turn's line ends "(session stopped)", its note says `cancelled (session-stopped)`, and a command it was running says "stopped as the session was stopped"; a bare signal to the worker makes it "stopped by a signal to chi"; an older worker takes the reason as `manual`); the TERM stops whatever still runs as a Stop with reason `signal` and saves it (a spawned worker traps TERM, HUP and INT: `WorkerSignals`; a turn not ended within 3 s, 0.5 s after a stop or a second signal, is cut and the session saved as it was), and a stopped worker still saves its kept turn, since the marker, not the saved status, says stopped. Any TERM, HUP or INT does the same to a worker outside a stop: it leaves (log `worker stop reason=signal`) and the next send wakes a new one, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's `llm ctx` chip, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s (the page's Stop shows the stop at once), or for the body's `wait` up to the same 10 s (`SessionManager::STOP_WAIT`): `chi --remote NAME sessions stop` sends it, so it waits as long as here. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
35
37
 
@@ -223,7 +225,7 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
223
225
  (`SAMAGOTCHI_THINKING_LEVEL=low chi send --new …`) only reaches the worker this call spawns: when the session's
224
226
  worker is woken later (by `chi web`, a delivery) without that env, the level is gone. Use `--thinking`.
225
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.
226
- - `--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).
227
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.
228
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.
229
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).
data/docs/sub-agent.md CHANGED
@@ -72,7 +72,7 @@ Exit codes, both commands:
72
72
  | 3 | `waiting_for_owner` | (a participant's wait, `chi --remote NAME send --wait` with their link's token) their review turn waits on the session's owner: a question or an approval it raised, or the owner's step-limit Continue; wait again |
73
73
  | 4 | `running` | `--timeout` passed; the turn goes on, wait again |
74
74
  | 1 | `refused` | (a participant's wait) their queued review turn was refused before it ran: `cancel_reason` `revoked` (their link was revoked or expired) or `cap_reached` (they spent their cap); no turn ran for it |
75
- | 1 | `failed`, `canceled`, `limit`, `removed`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened (`limit`: the turn ran out of steps and nobody can be asked, e.g. an older worker; `chi send ID -m '/continue yes'` continues it, except under `guardrails.parent_answers: hard`, where a parent's `/continue yes` is refused as its `chi answer --option Continue` is; `removed`: the `--queue` message waited for was taken back before it ran, no turn ran for it). `failed` adds `error_kind` (the provider error's kind: `credits`, `credits_held`, `rate_limited`, `server`, `auth`, `connection`, …) and `retryable` when known; `canceled` adds `cancel_reason` (`user`: a Stop in the web; `session_stopped`: a session stop, `chi sessions stop` or the web's; `worker_stopped`: the turn waited for (a `--queue` message's, or a message's to an idle session) died with its worker (detail "its worker exited mid-turn"), settled by the next one; `hook`; `ctrl_c`; `manual`) and `stopped_by` (the hook that stopped it, e.g. `loop-guard`) when known |
75
+ | 1 | `failed`, `canceled`, `limit`, `removed`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened (`limit`: the turn ran out of steps and nobody can be asked, e.g. an older worker; `chi send ID -m '/continue yes'` continues it, except under `guardrails.parent_answers: hard`, where a parent's `/continue yes` is refused as its `chi answer --option Continue` is; `removed`: the `--queue` message waited for was taken back before it ran, no turn ran for it). `failed` adds `error_kind` (the provider error's kind: `credits`, `credits_held`, `rate_limited`, `server`, `auth`, `connection`, …) and `retryable` when known; `canceled` adds `cancel_reason` (`user`: a Stop in the web; `session_stopped`: a session stop, `chi sessions stop` or the web's; `worker_stopped`: the turn waited for (a `--queue` message's, a message's to an idle session, the running turn a message merged into or a plain `--wait` saw running) died with its worker (detail "its worker exited mid-turn"), settled by the next one; `hook`; `ctrl_c`; `manual`) and `stopped_by` (the hook that stopped it, e.g. `loop-guard`) when known |
76
76
  | 2 | (no JSON) | usage error, or an option the question doesn't offer |
77
77
  | 130 | `running` | Ctrl-C; the turn goes on |
78
78
 
@@ -46,7 +46,7 @@ module Samagotchi
46
46
  end
47
47
 
48
48
  # @return [Hash, nil] a copy of the turn in progress: prompt, origin,
49
- # continue, ordered parts (generation / thinking / text / tool /
49
+ # continue, at (its wall time), ordered parts (generation / thinking / text / tool /
50
50
  # input / steer / reminder / notice), pending_question and the last event_seq folded
51
51
  # in. A finished tool part keeps the live row's action and duration_ms
52
52
  # too, a running one the time it has run so far (elapsed_ms), so a
@@ -275,6 +275,8 @@ module Samagotchi
275
275
  @turn = { prompt: event[:prompt], origin: event[:origin], continue: !!event[:continue],
276
276
  parts: [], pending_question: nil }
277
277
  @turn[:resumed] = true if event[:resumed]
278
+ # Its wall time (MessageTime): a joining page's gap divider.
279
+ @turn[:at] = event[:at] if event[:at]
278
280
  @tool_started_at = {}
279
281
  @working_set = nil
280
282
  @turn[:images] = event[:images] if event[:images]
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.109.0
3
+ version: 0.111.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -320,6 +320,10 @@ module Samagotchi
320
320
  Entry.new(key: "web.public_url", yaml_path: %w[web public_url], type: :string, default: nil, expose: %i[env config], lifecycle: :web_start),
321
321
  Entry.new(key: "web.proxy.identity_header", yaml_path: %w[web proxy identity_header], type: :string, default: nil, expose: %i[env config], lifecycle: :web_start),
322
322
  Entry.new(key: "web.proxy.owner", yaml_path: %w[web proxy owner], type: :string, default: nil, expose: %i[env config], lifecycle: :web_start),
323
+ # Phone notifications (Web::Push, docs/configuration.md "Phone notifications (Web Push)"): on with an https
324
+ # public URL once a device turned the bell on; contact is the VAPID sub (a mailto: or https URL; nil: the public origin).
325
+ Entry.new(key: "web.push.enabled", yaml_path: %w[web push enabled], type: :bool, default: true, expose: %i[env config], lifecycle: :web_start),
326
+ Entry.new(key: "web.push.contact", yaml_path: %w[web push contact], type: :string, default: nil, expose: %i[env config], lifecycle: :web_start),
323
327
 
324
328
  Entry.new(key: "no_interrupt", yaml_path: %w[no_interrupt], type: :bool, default: false, expose: %i[env config cli], lifecycle: :next_cli_run),
325
329
  Entry.new(key: "no_default_input", yaml_path: %w[no_default_input], type: :bool, default: false, expose: %i[env config cli], lifecycle: :next_cli_run),
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "time"
4
4
  require_relative "paths"
5
+ require_relative "message_time"
5
6
 
6
7
  module Samagotchi
7
8
  # A context note: background text pushed into a session from outside (the
@@ -13,7 +14,8 @@ module Samagotchi
13
14
  # The message keys a note carries besides role and content; everything
14
15
  # that copies messages field by field must keep them.
15
16
  # context_source: the attached context source a note is about
16
- # (ContextAbsorber), so the UIs can tie it to its chip.
17
+ # (ContextAbsorber), so the UIs can tie it to its chip. A note's wall
18
+ # time (MessageTime::KEY) is every entry's, kept beside these.
17
19
  KEYS = %i[kind note_id source from_session from_cwd context_source].freeze
18
20
  HEADER = "CONTEXT NOTE"
19
21
  # A finished background task's note (TaskFinishNote): a header of its own.
@@ -27,14 +29,16 @@ module Samagotchi
27
29
  # Takes SessionInbox.read_note's keys. +turn_start+ / +turn_id+: the
28
30
  # note starts a turn chi runs for it (a context wake), as a wake turn's
29
31
  # delegate report does (Steer.turn_prompt?). +header+: the frame's
30
- # (HEADERS), not kept as a key: the content carries it.
32
+ # (HEADERS), not kept as a key: the content carries it. Its wall time
33
+ # (MessageTime::KEY) is +created_at+'s, else now.
31
34
  # @return [Hash] the conversation message
32
35
  def message(note_id:, text:, source:, created_at: nil, from_session: nil, from_cwd: nil, context_source: nil,
33
36
  turn_start: nil, turn_id: nil, header: HEADER, **)
34
37
  { role: "system", kind: KIND, note_id: note_id, source: source, from_session: from_session,
35
38
  from_cwd: from_cwd, context_source: context_source, turn_start: (true if turn_start == true), turn_id: turn_id,
36
39
  content: frame(text, source: source, created_at: created_at, from_session: from_session,
37
- from_cwd: from_cwd, header: header) }.compact
40
+ from_cwd: from_cwd, header: header),
41
+ MessageTime::KEY => MessageTime.iso_of(created_at) || MessageTime.iso(MessageTime.now) }.compact
38
42
  end
39
43
 
40
44
  def frame(text, source:, created_at: nil, from_session: nil, from_cwd: nil, header: HEADER)