samagotchi 0.108.0 → 0.110.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -1
  3. data/bin/chi +5 -1
  4. data/docs/architecture.md +7 -2
  5. data/docs/cli.md +40 -1
  6. data/docs/configuration.md +177 -2
  7. data/docs/internals/engine.md +19 -4
  8. data/docs/internals/participants.md +28 -8
  9. data/docs/internals/prompt-caching.md +5 -0
  10. data/docs/internals/remote-sessions.md +6 -2
  11. data/docs/plugins.md +1 -1
  12. data/docs/sessions.md +3 -1
  13. data/docs/sub-agent.md +1 -1
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  15. data/lib/samagotchi/bridge.rb +25 -0
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/config.rb +4 -0
  18. data/lib/samagotchi/context_note.rb +7 -3
  19. data/lib/samagotchi/engine.rb +20 -9
  20. data/lib/samagotchi/idle_recap.rb +1 -1
  21. data/lib/samagotchi/idle_reminders.rb +1 -1
  22. data/lib/samagotchi/kernel_loop.rb +12 -7
  23. data/lib/samagotchi/llm/chat_loop.rb +9 -2
  24. data/lib/samagotchi/message_time.rb +104 -0
  25. data/lib/samagotchi/proxy_trace.rb +45 -0
  26. data/lib/samagotchi/reminder_queue.rb +20 -3
  27. data/lib/samagotchi/reply_wait/poll.rb +39 -1
  28. data/lib/samagotchi/run_options.rb +4 -1
  29. data/lib/samagotchi/session_access/contract.rb +1 -1
  30. data/lib/samagotchi/session_access/local.rb +52 -10
  31. data/lib/samagotchi/session_inbox.rb +7 -3
  32. data/lib/samagotchi/steer.rb +5 -3
  33. data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
  34. data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
  35. data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
  36. data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
  37. data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
  38. data/lib/samagotchi/terminal_ui.rb +40 -5
  39. data/lib/samagotchi/tool_activity.rb +11 -0
  40. data/lib/samagotchi/turn_checkpoint.rb +6 -1
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +246 -32
  43. data/lib/samagotchi/web/grants.rb +6 -3
  44. data/lib/samagotchi/web/info_probe.rb +5 -1
  45. data/lib/samagotchi/web/link_codes.rb +146 -0
  46. data/lib/samagotchi/web/participant_access.rb +9 -2
  47. data/lib/samagotchi/web/participant_review.rb +1 -1
  48. data/lib/samagotchi/web/participant_view.rb +12 -5
  49. data/lib/samagotchi/web/participants_admin.rb +13 -6
  50. data/lib/samagotchi/web/proxy_gate.rb +174 -0
  51. data/lib/samagotchi/web/public/app.js +35 -3
  52. data/lib/samagotchi/web/public/auth_lost.js +76 -0
  53. data/lib/samagotchi/web/public/bubbles.js +29 -7
  54. data/lib/samagotchi/web/public/data.js +29 -5
  55. data/lib/samagotchi/web/public/format.js +72 -6
  56. data/lib/samagotchi/web/public/index.html +2 -0
  57. data/lib/samagotchi/web/public/participant.html +2 -0
  58. data/lib/samagotchi/web/public/participant.js +47 -10
  59. data/lib/samagotchi/web/public/participant_model.js +25 -2
  60. data/lib/samagotchi/web/public/stream_handlers.js +32 -4
  61. data/lib/samagotchi/web/public/time_gaps.js +98 -0
  62. data/lib/samagotchi/web/public/token_fragment.js +57 -0
  63. data/lib/samagotchi/web/public/tokens.css +2 -2
  64. data/lib/samagotchi/web/public/turn_events.js +3 -1
  65. data/lib/samagotchi/web/public/turn_html.js +15 -8
  66. data/lib/samagotchi/web/public/turn_view.js +8 -3
  67. data/lib/samagotchi/web/public_url.rb +87 -0
  68. data/lib/samagotchi/web/running.rb +39 -4
  69. data/lib/samagotchi/web/server.rb +138 -22
  70. data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
  71. data/lib/samagotchi/web/tunnel.rb +133 -0
  72. data/lib/samagotchi/worker_sidecar.rb +4 -3
  73. metadata +12 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3f63eb88d80157a6b77f733031284c1a4e376528356f8d099ffe356b4cd7e6c1
4
- data.tar.gz: eab92f98b12468b5c882e5ee80524fdf151a1b479fd232ea54f8318bf952c2e3
3
+ metadata.gz: '097197f78e75225202f6e84b40f7449f063416402e3ac1820015a824c82fc64b'
4
+ data.tar.gz: 7ac596f01db65fc81761acccfade8c99c5543b963539083652cbb3022923ae13
5
5
  SHA512:
6
- metadata.gz: c5df88fe266c32153072be45890e6c12f54bcab0492d367cabc0f0066e6dec41bfc8d54e800f08c610979c3ec66131f7082dbc8691ea3854498ce603f553ffec
7
- data.tar.gz: e324e458bc5801eec9bc0f170ab5b839fbc4633ca33ce026566cac121deed90b53149a1052a9ec4607f67a7e3d284b4201c08309b5e8eeb1181bd1ff3ec65e31
6
+ metadata.gz: 3b5aca203f09584369fe954da86514837eabd3addf2e3bc91f3f74ae1202f726b4170c99c1b591a999ffb8c23da7ce3f51d2f746ecc81a84fabef376c238b7fb
7
+ data.tar.gz: d4df1f1ba87e37f81af6440c55c1aa3d0749c7abc734a53d9ec7356a35e9a513684495feb550138032f67c0e493cdacc97ce69ea29407fa407b1a07d7b22a20d
data/CHANGELOG.md CHANGED
@@ -8,6 +8,69 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.110.0] - 2026-10-24
12
+
13
+ ### Added
14
+
15
+ - Experimental: `chi web --tunnel tailscale`: chi web on your phone anywhere on your tailnet over https (Tailscale
16
+ Serve on this machine's `ts.net` name, never Funnel), with your Tailscale login as the owner (or
17
+ `web.proxy.owner`), for this run only (nothing written to config.yml). It checks Tailscale, the certificate and
18
+ what Serve already has on port 443 before it changes anything, adopts a serve left by an earlier run, refuses
19
+ anything else on 443, prints the owner link's QR code, warns when Tailnet Lock is off, and turns the serve off on
20
+ exit (only while 443 still serves exactly what it set). The proxied start line (`web.public_url`) also shows its
21
+ link's QR code at a terminal now. See docs/configuration.md (Tailscale Serve).
22
+ - Sessions record when each message happened: prompts, lines merged into a turn, reminders, notes and the model's
23
+ replies carry `at` in the session file (never sent to the model); plugins see it in `ctx.messages`.
24
+ - Reminders tell the model the local time they came due (`[SYSTEM: REMINDERS DUE 2026-10-24 14:32 local]`), so it
25
+ can reason about how long ago something happened.
26
+ - Terminal (attached and the REPL): a dim `── 14:32 · 47 min later ──` divider before what comes after 10 minutes
27
+ or more of quiet, a `── last activity 14:32 · 47 min ago ──` line when attaching after such a gap, and the recap's
28
+ time in its head (`recap 14:32>`). See docs/cli.md (Time gaps).
29
+ - Web: a dim `── 14:32 · 47 min later ──` line in the history where 10 minutes or more of quiet passed between
30
+ turns, notes or reminders (live and after a reload, the participant's review page too), each prompt, answer and
31
+ note's full time on hover, and the recap's time in its label (`recap · 14:32`).
32
+
33
+ ### Fixed
34
+
35
+ - `chi send --wait` with no message on a running turn that has no turn id (a session's first prompt, a reminder
36
+ turn), and a `chi send --wait -m` whose message merged into the running turn, end `canceled` (`worker_stopped`)
37
+ when that turn's worker dies and the next worker settles it, instead of waiting until `--timeout`.
38
+ - Attached TUI: joining an older session (one with no saved record of the call) shows a call the guardrails denied
39
+ as `blocked`, not `error`.
40
+
41
+ Update with `chi update` (the system bundle 0.110.0), then restart `chi web`.
42
+
43
+ ## [0.109.0] - 2026-10-24
44
+
45
+ ### Added
46
+
47
+ - Experimental: `chi web` behind an auth proxy (oauth2-proxy, nginx `auth_request`, Caddy `forward_auth`, Cloudflare
48
+ Access, Tailscale Serve, or a plain TLS proxy). `web.public_url` (an https origin) makes every request need the
49
+ access token, this machine's too; `web.proxy.identity_header` + `web.proxy.owner` also check the signed-in email
50
+ the proxy sends (owner = that identity AND the token; a repeated, comma, empty or underscore-twin header is
51
+ refused). Through the proxy the cookie is `__Host-chi_token` (Secure), a `?token=` page isn't traded (the paste box's
52
+ redirect is absolute from `web.public_url`), links (the start line, the web's and `chi share`'s invites) are `https://…/#code=…` with a
53
+ one-time code, never a token, and on this machine a request takes the token as `Authorization: Bearer` only. See
54
+ docs/configuration.md (Behind an auth proxy).
55
+ - Experimental, with `web.public_url`: a link's `#code=` is traded for the cookie by the page (`POST /api/token`;
56
+ a page that already has a session asks first), and the 401 page's paste box posts the token, never puts it in the
57
+ URL; the page notices a lost sign-in (the proxy's session or chi's token ended:
58
+ a 401, a redirect to the sign-in page, the session list's stream giving up) and reloads once, then says "Signed out"
59
+ with Reload instead of going quietly stale. The page's API calls ask for JSON (`Accept: application/json`).
60
+
61
+ ### Changed
62
+
63
+ - chi web's HTML answers can't be framed by another page (`X-Frame-Options: DENY`, `frame-ancestors 'none'`), and a
64
+ frame/embed/object load from another origin is refused (403 `framed`).
65
+ - A session's Bridge refuses any request that came through a proxy (`X-Forwarded-*`, `Forwarded`, `X-Real-IP`,
66
+ `X-Auth-Request-*` and the like, underscore twins too: 403 `proxied`): an auth proxy that forwards every local port
67
+ and adds such a header no longer reaches a worker's unauthenticated Bridge (a forward that adds none still does).
68
+ chi's own clients never send these headers. A worker's `bridge.json` is now 0600.
69
+ - `chi share`'s LAN link names the LAN address the running chi web reports only if it is one of this machine's own
70
+ addresses; else the link names 127.0.0.1 (whatever answers on chi web's port may be another user's listener).
71
+
72
+ Update with `chi update` (the system bundle 0.109.0), then restart `chi web`.
73
+
11
74
  ## [0.108.0] - 2026-10-24
12
75
 
13
76
  ### Added
@@ -4165,7 +4228,9 @@ and long-lived sessions.
4165
4228
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
4166
4229
  hotkey panel that send selected text or the clipboard to your sessions.
4167
4230
 
4168
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.108.0...HEAD
4231
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.110.0...HEAD
4232
+ [0.110.0]: https://github.com/dm1try/samagotchi/compare/v0.109.0...v0.110.0
4233
+ [0.109.0]: https://github.com/dm1try/samagotchi/compare/v0.108.0...v0.109.0
4169
4234
  [0.108.0]: https://github.com/dm1try/samagotchi/compare/v0.107.0...v0.108.0
4170
4235
  [0.107.0]: https://github.com/dm1try/samagotchi/compare/v0.106.0...v0.107.0
4171
4236
  [0.106.0]: https://github.com/dm1try/samagotchi/compare/v0.105.0...v0.106.0
data/bin/chi CHANGED
@@ -120,6 +120,10 @@ if options[:new_token] && !web
120
120
  warn "Error: --new-token goes with chi web (chi web --new-token)"
121
121
  exit 1
122
122
  end
123
+ if options[:tunnel] && !web
124
+ warn "Error: --tunnel goes with chi web (chi web --tunnel tailscale)"
125
+ exit 2
126
+ end
123
127
  if options[:sets] && web
124
128
  warn "Error: --set is a new session's (chi --set, chi send --new --set), not chi web's"
125
129
  exit 2
@@ -208,7 +212,7 @@ if options[:web]
208
212
  markdown = Samagotchi::Config.get("web.markdown") if markdown.nil?
209
213
  view = Samagotchi::Config.get("web.view")
210
214
  exit Samagotchi::Web::Server.launch(port: port, scope: options[:scope] || "project", open_browser: options[:web_open],
211
- markdown: markdown, view: view, new_token: options[:new_token],
215
+ markdown: markdown, view: view, new_token: options[:new_token], tunnel: options[:tunnel],
212
216
  working_set: Samagotchi::Config.get("web.working_set"),
213
217
  annotate_presets: Samagotchi::Config.get("web.annotate_presets"),
214
218
  editor: Samagotchi::Config.get("web.editor"))
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)). 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. `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
@@ -387,7 +387,11 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
387
387
  - No browser: only chi's own clients talk to a Bridge. A request with an `Origin` header, a `Sec-Fetch-Site`
388
388
  other than `none`, or a `Host` that isn't loopback (a DNS-rebound page) is `403 cross_origin`
389
389
  (`Bridge#browser_request?`, before any route); there is no CORS and no `OPTIONS` route. The browser reaches a
390
- worker only through `chi web`.
390
+ worker only through `chi web`. A request carrying any trace a proxy adds (`ProxyTrace`: `X-Forwarded-*`,
391
+ `Forwarded`, `X-Real-IP`, `X-Auth-Request-*`, `Cf-*`…, underscore twins too) is `403 proxied`
392
+ (`Bridge#proxied_request?`): an auth proxy that forwards every local port and adds such a header doesn't reach a
393
+ worker. A heuristic: a forward that adds none still does, until Bridges have a secret (shared-sessions P6).
394
+ `bridge.json` is 0600.
391
395
 
392
396
  The metrics in `/state`, `/snapshot`, `/stats` and the SSE `snapshot`/`reset` frames
393
397
  (`SessionMetrics#snapshot`) carry the session's totals and only the recent timing records: the
@@ -696,6 +700,7 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
696
700
  | 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)). |
697
701
  | 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)). |
698
702
  | 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)). |
703
+ | 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)). |
699
704
  | 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)). |
700
705
  | 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'. |
701
706
  | Images / vision | `image_store.rb` (`ImageStore`), `vision_support.rb`, `vision_context.rb` | Stored prompt images and whether a model takes them. |
data/docs/cli.md CHANGED
@@ -13,6 +13,7 @@
13
13
  - A session id can be shortened to any unique prefix (like git): `chi --attach 2ea8`. `--resume`, `--attach`, `sessions stop`, `sessions archive` and `sessions delete` take one; an ambiguous prefix lists the sessions it matches.
14
14
  - `chi web [--port 4567] [--open] [--scope=all]` — start the Web UI (single localhost port session control plane) on this git project's sessions (`--scope=all`, or a folder in no repo: every session); if a chi web already runs on the port, print (with `--open`, open) its page for this folder and exit. Something else on the port (an older chi web too) exits 1 with "port N is in use"
15
15
  - `chi web --web-host lan` — the Web UI on your home network too, for your phone: a link with an access token and its QR code (see [chi web on your phone](#chi-web-on-your-phone)); `chi web --new-token` replaces the token
16
+ - `chi web --tunnel tailscale` — the Web UI over https on your tailnet, for your phone anywhere: Tailscale Serve (never Funnel) on this machine's `<machine>.<tailnet>.ts.net` name for this run, the owner your Tailscale login, turned off on exit (see [Tailscale Serve](configuration.md#tailscale-serve-your-tailnet)); `--tunnel` without `web` exits 2
16
17
  - `chi web --web-markdown` — opt in to sanitized Markdown rendering for completed assistant messages, the stage's asking text and a resolved question card's reply
17
18
  - `chi web --web-view turn` — draw turns with the turn view (each turn as one block of steps, the running one at the bottom of the history) instead of the default stage view (the running turn pinned above the composer); `?view=stage|turn` on the page URL overrides it (see [Web views](#web-views))
18
19
  - `chi web --no-web-working-set` — turns the stage view's working set off (on by default; `--web-working-set` turns a config's `web.working_set: false` back on): what each turn brought into the model's context, in the stage and under each finished turn; `?working_set=1|0` on the page URL overrides it (see [The working set](#the-working-set))
@@ -1012,6 +1013,16 @@ steps saved before that have none, so they show no thinking. An `edit` or
1012
1013
  made (up to 120 lines or 8 KB), live and after a reload (see
1013
1014
  [Guardrails](guardrails.md#ask) for the diff an approval shows first).
1014
1015
 
1016
+ **When things happened**, in both views and on a participant's review page: where 10 minutes or
1017
+ more of quiet passed between the end of one activity and the start of the next (a turn, a context
1018
+ note, a reminder), the history has one dim centred line, `── 14:32 · 47 min later ──`; the date is
1019
+ added when the day changed or the turn wasn't today (`── Sat 24 Oct 09:10 · 13 h later ──`).
1020
+ Nothing marks active work: a long turn followed at once by the next has no line. Hovering a prompt,
1021
+ an answer, a note or a reminder shows its full local time, and the recap's label says when it was
1022
+ written (`recap · 14:32`). The times are the history entries' `at` (see
1023
+ [sessions.md](sessions.md)); a session saved before them takes its turns' times from their timing
1024
+ records, and shows no hover times.
1025
+
1015
1026
  ### The working set
1016
1027
 
1017
1028
  The stage view shows what a turn brought into the model's context: the files
@@ -1164,6 +1175,9 @@ access token", paste the token there (the part of the link after
1164
1175
  office guest network), and run `chi web --new-token` if a link leaks.
1165
1176
  - On http the browser has no notifications: the bell is hidden on the phone,
1166
1177
  and the tab title still counts what needs you.
1178
+ - Off the home Wi-Fi: `chi web --tunnel tailscale` gives chi web an https
1179
+ name on your tailnet (Tailscale Serve) for its run and prints its link's QR
1180
+ code; see [Tailscale Serve](configuration.md#tailscale-serve-your-tailnet).
1167
1181
 
1168
1182
  ### Remote sessions
1169
1183
 
@@ -1332,7 +1346,10 @@ chi share revoke --all # every link to every session
1332
1346
  - `chi --remote studio share …` invites on studio: the link names studio's LAN address. Locally, the link names
1333
1347
  this machine's running chi web, on the port it listens on (it records it in `$XDG_STATE_HOME/samagotchi/web.json`;
1334
1348
  `web.port` when none answers there); without one on the LAN, chi says the link opens on this machine only (as
1335
- the participant there too: a participant's link never opens as the owner).
1349
+ the participant there too: a participant's link never opens as the owner). With `web.public_url` the link is
1350
+ `https://<public url>/#code=…`; with `chi web --tunnel tailscale` too (its `ts.net` name, which web.json records
1351
+ while that chi web answers on its port: a chi web on another port takes web.json over, and the link falls back to
1352
+ 127.0.0.1). A tunnel link opens only on a device on your tailnet; the grant still decides.
1336
1353
  - The owner's own token and the `chi web --new-token` rotation are separate: rotating it ends no participant's link.
1337
1354
  - **In the attached terminal.** A participant's message reads `Alex ▸ review> is step 3 right?`, the name in that
1338
1355
  participant's own colour (queued, running, and the last exchange when you join); yours stay as they were. A line
@@ -1433,6 +1450,28 @@ working on, what came of it and what is still open, not a turn-by-turn log.
1433
1450
  - It uses the session's own model unless `recap:` names one;
1434
1451
  `recap: false` turns it off. See configuration.md.
1435
1452
 
1453
+ ## Time gaps (terminal)
1454
+
1455
+ Both terminals (attached and the REPL) show when things happened without a
1456
+ time on every line:
1457
+
1458
+ - A dim divider marks a quiet gap of 10 minutes or more, before what comes
1459
+ next: `── 14:32 · 47 min later ──`, the date added when the day changed
1460
+ (`── Sat 24 Oct 09:10 · 13 h later ──`). The gap runs from the end of the
1461
+ last activity (a turn's end, a reminder, a context note, a recap) to the
1462
+ start of the next (a prompt, yours above its line or another UI's, a
1463
+ reminder turn, a note, a recap), so nothing is marked during a turn or
1464
+ between turns run back to back. A command (`/stats`, `!cmd`) is no
1465
+ activity.
1466
+ - Attaching to a session whose last activity was 10 minutes ago or more
1467
+ says when, after the join block: `── last activity 14:32 · 47 min ago ──`
1468
+ (from the newest timed message, else the saved recap's time). With
1469
+ `--resume` the REPL marks the gap before the first prompt instead.
1470
+ - A recap names the local time it was written: `recap 14:32>`, `recap 14:32
1471
+ (before the last 2 turns)>`.
1472
+ - Only live activity and the join: neither terminal replays the history, so
1473
+ older gaps aren't marked. `--non-interactive` prints none.
1474
+
1436
1475
  ## Tool Activity Log
1437
1476
 
1438
1477
  Samagotchi now prints a concise, human-friendly tool activity log in normal
@@ -1591,11 +1591,183 @@ web:
1591
1591
  - Only a viewer on this machine opens the editor: a loopback request, or this machine on its own LAN address
1592
1592
  (`web.host: lan`, the LAN URL or its QR code opened here). A phone gets the targets that need no editor (an
1593
1593
  answer's ref shows its row in the turn; see the refs in [sessions.md](sessions.md)); a row's title stays plain
1594
- text there. An SSH tunnel or a local reverse proxy (`tailscale serve`) looks like loopback, so a remote viewer
1595
- behind one gets an editor target that can't open.
1594
+ text there. An SSH tunnel or a local reverse proxy (`tailscale serve`) looks like loopback unless `web.public_url`
1595
+ is set (then a request through it is never this machine's viewer), so without it a remote viewer behind one gets an
1596
+ editor target that can't open.
1596
1597
  - `GET /api/sessions/:id` (and its `?tail=1`) carries `capabilities.editor` (true for such a viewer) and
1597
1598
  `editor_url`, the template (null otherwise).
1598
1599
 
1600
+ ## Behind an auth proxy
1601
+
1602
+ Experimental. `chi web` binds 127.0.0.1 only; to use it from elsewhere over https, put it behind a proxy that does TLS
1603
+ and, usually, a sign-in (oauth2-proxy, nginx `auth_request`, Caddy `forward_auth`, Cloudflare Access, Tailscale
1604
+ Serve; a work pod forwarding `https://<port>.<pod>.<domain>` through oauth2-proxy) and tell chi web its public URL:
1605
+
1606
+ ```yaml
1607
+ web:
1608
+ public_url: https://4567.pod.example.com # the https origin people open: no path
1609
+ proxy:
1610
+ identity_header: X-Forwarded-Email # optional: the header the proxy puts the signed-in email in
1611
+ owner: me@example.com # optional, needs identity_header: you, as that header says it
1612
+ ```
1613
+
1614
+ What changes once `web.public_url` is set:
1615
+
1616
+ - **Every request needs the access token**, this machine's too (`$XDG_STATE_HOME/samagotchi/web-token`, made at
1617
+ start; `chi web --new-token` replaces it). A proxy that adds no header and rewrites Host (nginx's `proxy_pass` with
1618
+ its defaults) would otherwise look like this machine. On this machine the page opens through the proxy too: a
1619
+ request that didn't come through it takes the token only as `Authorization: Bearer` (no cookie: a cookie for
1620
+ 127.0.0.1 would go to every server on any local port; a phone on the LAN, with `web.host: lan`, keeps its LAN link
1621
+ and cookie). chi's own probe of the port (a second `chi web`, `chi share`,
1622
+ `chi update`) sends no token; chi web answers it the few fields it checks. Links and the start line come from this
1623
+ machine's config, never from whatever answers on the port.
1624
+ - **A request through the proxy** (it carries `X-Forwarded-For`, `Forwarded`, `X-Real-IP`, a known identity header,
1625
+ or the public Host) is the owner only with the token **and**, when `web.proxy.identity_header` is set, that header
1626
+ being exactly `web.proxy.owner` (any case). A repeated, comma-separated or empty value, or the header sent under a
1627
+ second spelling (`X_Forwarded_Email` next to `X-Forwarded-Email`), is refused (401, the reason in the answer's
1628
+ detail). With `identity_header` unset the token alone decides: that works behind any proxy. `owner` without
1629
+ `identity_header` stops `chi web` from starting.
1630
+ - Through the proxy the cookie is `__Host-chi_token` (Secure, Path=/, no Domain: other host names under the domain,
1631
+ such as a pod's other ports `https://<port>.<pod>…`, don't get it; the LAN's `chi_token` is separate). Cookies aren't
1632
+ scoped by port: another server on the **same host name** and a different port does get it, so give chi web a host
1633
+ name of its own, or port 443. Through the proxy a `?token=` page isn't traded (it would ride the sign-in redirect,
1634
+ and a crafted link could swap your session): a link's one-time code and the 401 page's paste box do it, the paste
1635
+ box's redirect going to the public URL (never to a client-sent `X-Forwarded-Host`). The page accepts `POST`s from the public `https://` origin only (a sibling port on the same
1636
+ domain, or `http://`, is refused). No page of chi web's may be framed by another (`X-Frame-Options: DENY`,
1637
+ `frame-ancestors 'none'`, in every mode).
1638
+ - Links carry a **one-time code** in the fragment, never a token: `chi web` prints `Proxied: https://…/#code=chc_…`
1639
+ (the owner's: once, within 10 minutes; `chi web` again prints a new one), and a participant's link (`chi share`,
1640
+ the Invite dialog) is `https://<public url>/#code=chc_…&s=ID` (once, until the grant expires). A fragment isn't sent
1641
+ to the proxy, but it outlives the sign-in's redirects: the sign-in page sits at that URL (its scripts can read it)
1642
+ and it stays in the browser's history, which is why it holds a code that works once. The page trades the code for
1643
+ the cookie (`POST /api/token`) and drops it from the URL; a page that already has a session asks first, since the
1644
+ trade replaces it. A code presented with the wrong identity is used up and gives nothing, so whoever already holds
1645
+ a link can spend it (`chi web` again, or a new invite, makes another). The 401 page's paste box posts the token in
1646
+ the request's body, never in the URL.
1647
+ - When the proxy's sign-in or chi's token ends, the page says so: it reloads once (the proxy's sign-in brings you
1648
+ back), and within a minute of that shows "Signed out … Reload" instead of a list that quietly stopped updating. Its
1649
+ API calls ask for JSON, so oauth2-proxy answers an ended sign-in with 401 instead of a redirect.
1650
+ - A session's Bridge (its worker's own port, unauthenticated) refuses a request that carries a proxy header such as
1651
+ `X-Forwarded-For` or `Forwarded` (403 `proxied`). That is a heuristic, not a lock: a forward that adds no such header
1652
+ (nginx's `proxy_pass` with its defaults, a plain TCP forward) still reaches a worker, as the owner.
1653
+ - `chi --remote` to a chi web behind a sign-in proxy isn't supported yet.
1654
+
1655
+ > **Warning.** Until Bridges get a secret of their own (planned), don't run chi web behind a proxy or tunnel that can
1656
+ > reach any local port, such as a pod forwarding every `https://<port>.<pod>…`, unless it adds `X-Forwarded-For` or
1657
+ > `Forwarded` to every request; and never one that also lets other people through its sign-in.
1658
+
1659
+ Which header to trust: only one the proxy **sets** on every signed-in request **and replaces** when a client sends
1660
+ it. Check before setting `identity_header`: run an echo on a free port behind the same proxy (`python3 -c` with
1661
+ `http.server`, or any header-echo server), open it signed in, and send your own copies (`curl -H 'X-Forwarded-Email:
1662
+ evil@x' -H 'X_Forwarded_Email: evil@x' …` with your sign-in cookie): the header you trust must come back with your
1663
+ email only. chi warns once in its log when a proxied request carries a known identity header but not the configured
1664
+ one.
1665
+
1666
+ | Setup | `identity_header` | Notes |
1667
+ |---|---|---|
1668
+ | oauth2-proxy (`--pass-user-headers`) | `X-Forwarded-Email` | Replaced, spelling variants dropped; `X-Forwarded-For` always added. **Not** `X-Auth-Request-Email`: oauth2-proxy puts that on its *response*, a client's copy reaches chi untouched. Checked against oauth2-proxy 7.15.5. |
1669
+ | nginx `auth_request` + oauth2-proxy (`--set-xauthrequest`) | `X-Auth-Request-Email` | Only as nginx sets it from the auth reply (`auth_request_set $email $upstream_http_x_auth_request_email; proxy_set_header X-Auth-Request-Email $email;`), which replaces a client's copy; nginx drops underscore spellings by default. Checked with nginx 1.27. |
1670
+ | Caddy `forward_auth` (`copy_headers X-Auth-Request-Email`) | the copied header | Replaced from the auth reply; Caddy passes an underscore spelling through, which chi refuses. Checked with Caddy 2.8. |
1671
+ | Cloudflare Access | `Cf-Access-Authenticated-User-Email` | Not checked yet: verify with an echo. chi trusts the header only; it doesn't validate the `Cf-Access-Jwt-Assertion` token. |
1672
+ | Tailscale Serve | `Tailscale-User-Login` | Replaced, not appended (`Tailscale-User-Name`, `X-Forwarded-For`, `X-Forwarded-Host` too); `X-Forwarded-For` = the requesting device's tailnet IP, Host = the public name. Tailnet only. Checked with Tailscale 1.104.2 (macOS). Not checked: Funnel and tagged-device requests; never use Funnel. See [Tailscale Serve](#tailscale-serve-your-tailnet). |
1673
+ | Authelia, Authentik (behind nginx, Caddy, Traefik) | `Remote-Email` (Authelia), `X-Authentik-Email` (Authentik) | Not checked yet; only as the front proxy copies it from the auth reply (the nginx and Caddy rows). |
1674
+ | A TLS proxy with no sign-in (nginx, Caddy, an SSH tunnel) | leave unset | The token alone decides. |
1675
+
1676
+ Keep the state dir (`web-token`, the grants) out of any folder others can read, such as a team's shared folder.
1677
+
1678
+ ### Tailscale Serve (your tailnet)
1679
+
1680
+ chi web on your phone away from the home Wi-Fi: [Tailscale](https://tailscale.com) Serve gives this Mac an https
1681
+ name on your tailnet, `https://<machine>.<tailnet>.ts.net`, with a real certificate, and tells chi web who is asking
1682
+ (your Tailscale login, next to chi's token). Only devices on your tailnet reach it: with Tailscale off on the phone the
1683
+ name doesn't load. By hand, on the Mac that runs chi web (or steps 1–2, then
1684
+ [`chi web --tunnel tailscale`](#chi-web---tunnel-tailscale) does 3–5 for one run):
1685
+
1686
+ 1. Install Tailscale on the Mac and the phone and sign both in with the same login. **Rename the machines first**
1687
+ (admin console → Machines): HTTPS certificates go into the public Certificate Transparency log, so the
1688
+ `<machine>.<tailnet>.ts.net` name is public once a certificate is issued, and a later rename puts a new name there.
1689
+ 2. Turn on HTTPS certificates (admin console → DNS; they need MagicDNS on).
1690
+ 3. Serve chi web's port on the tailnet, never on the internet:
1691
+
1692
+ ```sh
1693
+ tailscale serve --bg 4567
1694
+ ```
1695
+
1696
+ On macOS the app's CLI is `/Applications/Tailscale.app/Contents/MacOS/Tailscale` unless you installed `tailscale`
1697
+ on your PATH. `tailscale serve status` shows what it serves; `tailscale serve --https=443 off` turns it off. Turn
1698
+ it off when you stop using it: while it is on, whatever listens on 127.0.0.1:4567 is reachable from your tailnet.
1699
+ 4. Tell chi web its public name and whose login is the owner (config.yml; `chi web` reads it at start):
1700
+
1701
+ ```yaml
1702
+ web:
1703
+ public_url: https://chi-host.example-tailnet.ts.net
1704
+ proxy:
1705
+ identity_header: Tailscale-User-Login
1706
+ owner: owner@example.com # your Tailscale login
1707
+ ```
1708
+
1709
+ Your login: `tailscale whois <the phone's 100.x IP>` (the User block), or `tailscale status --json`
1710
+ (`User["<Self.UserID>"].LoginName`), or start without `owner` and read it off the 401 page's detail ("owner token
1711
+ with identity …"). A login made with Sign in with Apple is a private-relay address (`…@privaterelay.appleid.com`):
1712
+ it is stable, use it as it is. Every device signed in with that login (the phone, a laptop, an iPad) is the owner,
1713
+ each with the token too.
1714
+ 5. `chi web` prints `Proxied: https://chi-host.example-tailnet.ts.net/#code=chc_…`; open that link on the phone (copy,
1715
+ AirDrop or type it). The code works once, within 10 minutes (`chi web` again prints a new one); the phone then
1716
+ keeps the `__Host-chi_token` cookie.
1717
+
1718
+ #### `chi web --tunnel tailscale`
1719
+
1720
+ Steps 3–5 in one command, after steps 1–2 (Tailscale signed in, HTTPS certificates on):
1721
+
1722
+ ```sh
1723
+ chi web --tunnel tailscale
1724
+ ```
1725
+
1726
+ ```
1727
+ Chi Web on http://127.0.0.1:4567/ (public: …)
1728
+ Proxied: https://chi-host.example-tailnet.ts.net/#code=chc_… ← opens chi web as you once, within 10 minutes (…)
1729
+ (owner owner@example.com by Tailscale-User-Login, and the token; …)
1730
+ via Tailscale Serve (tailnet only), owner owner@example.com (this machine's Tailscale login)
1731
+ Tailnet Lock is off: a device Tailscale's servers add to your tailnet could reach this page; tailscale lock init turns it on (…)
1732
+ <the link's QR code>
1733
+ ```
1734
+
1735
+ - It checks before it changes anything: Tailscale is connected, this machine has its `ts.net` name and a certificate
1736
+ for it, and what Serve already has on port 443. Nothing there: it runs
1737
+ `tailscale serve --bg --https=443 http://127.0.0.1:<port>`. Exactly that one handler for this port (a leftover of an
1738
+ earlier run killed hard, or step 3 done by hand): it adopts it. Anything else on 443 (another port, other paths, TCP
1739
+ forwarding, Funnel) is refused and left alone. Serve's other ports are never touched.
1740
+ - The owner is `web.proxy.owner` if config.yml has it, else this machine's own Tailscale login (a tagged machine has
1741
+ none: set `web.proxy.owner`). `public_url` and `identity_header` are the tunnel's for this run, never written to
1742
+ config.yml: a `web.public_url` or `web.proxy.identity_header` that says otherwise (in config.yml or the environment)
1743
+ is refused, so a config from step 4 with this machine's name works as it is.
1744
+ - On exit (Ctrl-C, a kill, closing the terminal) it runs `tailscale serve --https=443 off`, but only while 443 still
1745
+ serves exactly what it set; if you changed 443 meanwhile it leaves it and says so. Killed hard (`kill -9`, a power
1746
+ loss) the serve stays on: Serve answers 502 for the name while nothing listens on the port, and whatever later
1747
+ listens on 127.0.0.1:<port> is reachable from your tailnet; the next `chi web --tunnel tailscale` adopts it and
1748
+ turns it off on exit, a plain `chi web` doesn't look.
1749
+ - A chi web already on the port is refused ("stop that one first"): `--tunnel` starts its own.
1750
+ - The `tailscale` it runs: `SAMAGOTCHI_TAILSCALE_CLI` if set, else `tailscale` on PATH, else the macOS app's
1751
+ `/Applications/Tailscale.app/Contents/MacOS/Tailscale`.
1752
+ - Tailnet Lock off is a warning line, never a refusal.
1753
+
1754
+ - **Never Funnel** (`tailscale funnel`): it puts the name on the public internet. This recipe was checked on the
1755
+ tailnet only; Funnel requests weren't.
1756
+ - Recommended: **Tailnet Lock** (`tailscale lock init`, see Tailscale's docs): new devices then need a signature from a
1757
+ device you trust before they join, not only Tailscale's coordination server's say-so. chi web works either way.
1758
+ - What Serve sends (checked with Tailscale 1.104.2 on macOS): it **replaces** a client's `Tailscale-User-Login`,
1759
+ `Tailscale-User-Name`, `X-Forwarded-For` and `X-Forwarded-Host` (never appends); `X-Forwarded-For` is the
1760
+ requesting device's tailnet IP, Host the public name, and chi web's peer is 127.0.0.1. A process on this Mac can
1761
+ still reach 127.0.0.1:4567 directly with headers of its own: there the token decides, as with any proxy.
1762
+ - While chi web isn't running Serve answers 502 for the name (checked). Not checked yet: requests from a tagged
1763
+ device (expect no user login, so refused with `identity_header` set), and whether the serve stays on across a
1764
+ reboot.
1765
+ - A participant's link (`chi share`, the Invite dialog) is a `https://<machine>.<tailnet>.ts.net/…` link too: it opens
1766
+ only on a device on your tailnet (or one you shared this machine with in Tailscale); the grant still decides.
1767
+ - Without Serve, a plain tailnet IP (`web.host: 100.x.y.z`) works like the LAN (see
1768
+ [chi web on your phone](cli.md#chi-web-on-your-phone)): token only, plain http in the browser (WireGuard encrypts
1769
+ the path) so no `Secure` cookie and no notifications, and no identity.
1770
+
1599
1771
  ## All settings
1600
1772
 
1601
1773
  Every setting below takes the three forms described in
@@ -1723,6 +1895,9 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
1723
1895
  | `web.participants.expires` | `7d` | | How long a participant's link lasts unless `chi share --expires` says otherwise: `Nh` or `Nd`, 1h to 30d. See [participants](internals/participants.md). |
1724
1896
  | `web.participants.cap` | `2.0` | | What a participant's turns, and the side calls they cause, may cost you in dollars unless `chi share --cap` says otherwise ($0 to $1000); at the cap their next comment is refused (429 `cap_reached`) and you get a notice. `chi share cap ID P DOLLARS` changes one grant's. |
1725
1897
  | `web.participants.queue` | `3` | | How many of a participant's turns may wait behind their running one (0 to 20); one more is refused (429 `too_many_turns`). |
1898
+ | `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
+ | `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
+ | `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`. |
1726
1901
  | `history.file` | state dir | | Prompt history path, shared by the TUI and the web composer. |
1727
1902
  | `no_interrupt` | `false` | `--no-interrupt` | Raise the step limit of a turn to 1000 (or `turn.max_iterations`, when larger); a top-level key. |
1728
1903
  | `no_default_input` | `false` | `--no-default-input` | Don't pre-fill `default.input`; a top-level key. |
@@ -25,12 +25,14 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
25
25
  │ # TurnState#begin!(controller:, sink:) (a new CancellationController when none is given),
26
26
  │ # a recap in flight invalidated, GuardrailWiring#begin_turn(origin)
27
27
  │ # Returns a Turn {session, prompt (nil on a continue), continue, on_event, controller,
28
- │ # origin, started_at, id (the caller's id:, else a new uuid), resumed, resume_from}
28
+ │ # origin, started_at, id (the caller's id:, else a new uuid), resumed, resume_from,
29
+ │ # wall (MessageTime.now)}
29
30
  │
30
31
  ├── Client.swap_probe_cancel(turn.controller) # a Stop cuts this turn's /props probes
31
32
  │
32
33
  ├── prepare_turn(turn, images)
33
- │ # Emits :turn_started {session_id, prompt, turn_id, continue: true (only on a continue),
34
+ │ # Emits :turn_started {session_id, prompt, turn_id, at (the turn's wall time, Turn#wall: read once,
35
+ │ # the user message's and the reminders' stamp too), continue: true (only on a continue),
34
36
  │ # resumed: true (only on a step-limit Continue: resumed: given),
35
37
  │ # images (only when there are any), cut_after (the model's cut wait, s)}
36
38
  │ # refresh_profile! (may ask the server's /props once)
@@ -491,8 +493,8 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
491
493
 
492
494
  | Event | When | Payload |
493
495
  |---|---|---|
494
- | `:turn_started` | `prepare_turn` | `{session_id, prompt, turn_id, continue?, resumed?, images?, cut_after}` (`resumed`: a step-limit Continue) |
495
- | `:reminder_injected` | due reminders went into the turn | `{reminders}` |
496
+ | `:turn_started` | `prepare_turn` | `{session_id, prompt, turn_id, at, continue?, resumed?, images?, cut_after}` (`resumed`: a step-limit Continue; `at`: the turn's wall time, its prompt's stamp) |
497
+ | `:reminder_injected` | due reminders went into the turn | `{reminders, at}` (`at`: the turn's wall time, the reminder message's stamp) |
496
498
  | `:context_status` | every request (unless `context.status` is off), the current estimate before it; and outside any turn after `/model` (or when a session is loaded on another model than its last turn ran on), to the observers only (`Engine#announce_switched_context!`) | `{iteration, status, usage, bucket, source}`; the switch's form has no `iteration`: `{usage, bucket, context_window_tokens, context_window_source}`, with `usage.source: "switch"`, and `usage` nil when the new window doesn't resolve (internals/context-telemetry.md) |
497
499
  | `:generation_started` | a request begins | `{iteration, context_window_tokens, context_window_source, price, model_ref}` (`price`: the turn's `ModelPrice#to_h`, nil without one; `model_ref`: the turn's model, asked `host:model`, added by the Engine's stream handler from the model `#generate` resolved after the `before_turn` hooks, so a hook's switch is the one named; native adds `profile`, `profile_source`) |
498
500
  | `:generation_chunk` | each streamed chunk | `{iteration, content, text, thinking, payload, tool_call?}` (SSE clients get the Bridge's projection instead: `{iteration, text, thinking, tool_call?, counts?}`, architecture.md) |
@@ -655,6 +657,19 @@ logged). Both are display only: `AnswerDisplay::DISPLAY_ONLY` lists `display`, `
655
657
  after_generation), while `KernelLoop#sanitize_history` and `ChatLoop#plain` copy them so the stored conversation keeps
656
658
  them across turns. `Web::App#messages_for_display` sends `display_refs` re-validated (`AnswerRefs.for_display`).
657
659
 
660
+ History stamps: user prompts, merged input and steers, reminders, context and task notes and the model's replies carry
661
+ `at` (`MessageTime::KEY`, `message_time.rb`: a local-offset ISO time with milliseconds), when the entry happened. A
662
+ turn reads the wall clock once (`Turn#wall`, `MessageTime.now`): `:turn_started`'s `at`, its prompt's and its
663
+ reminders' stamps; `Steer::Merge#messages` stamps a merge at the boundary, `ContextNote.message` a note with its
664
+ `created_at` (else now), `KernelLoop#iterate` / `#cancelled_result` and the chat loop's `Run#with_thinking` /
665
+ `Run#canceled` each reply. Tool results, `!cmd` messages and TurnNote notes carry none. Like `display`, `at` must
666
+ survive the two rebuilds: both model branches of `KernelLoop#sanitize_history` and `ChatLoop#plain` copy it (an
667
+ allow-list that drops a key loses it for good on the next turn; `spec/message_time_history_spec.rb` checks both
668
+ loops). It is display-only and never reaches the wire (prompt-caching.md, rule 7), but it is not in
669
+ `AnswerDisplay::DISPLAY_ONLY`: hooks and `ctx.messages` see it. `:recap_ready` carries the saved recap's
670
+ `created_at` (`Engine#emit_recap(created_at:)`, from `IdleRecap#collect`). The UIs' gap dividers come from
671
+ `MessageTime.gap_line` (10 min, `GAP_SECONDS`).
672
+
658
673
  `:question_asked` (`QuestionAsked`, `question_asked.rb`) fires from the two Engine seams where a question of the
659
674
  model's opens, never from `QuestionDesk`: `Engine#request_question` (the kernel's `question_handler`, after
660
675
  `AskUserQuestion.validate`; not with `interface == :non_interactive`) and the guardrail wiring's `ask:` lambda
@@ -7,9 +7,11 @@ limited and capped; and what they **see** (P4a): their page and its reads, all t
7
7
  experimental. An agent participant waits on its own turns' replies through the API. The code and docs say
8
8
  "participants" and "review", never "shared": `session.shared` already means "attached by default".
9
9
 
10
- Participants are a **LAN** feature for now (`web.host: lan`; a public URL, `web.public_url`, is planned, not a
11
- config key yet): a loopback peer with no token is
12
- the owner, and a reverse proxy on the same machine would make every visitor loopback. A participant's token is the
10
+ Participants reach chi web on the **LAN** (`web.host: lan`), or **behind an auth proxy** with `web.public_url`
11
+ (experimental; docs/configuration.md, "Behind an auth proxy"): there every request needs a token, this machine's too,
12
+ and invite links are `https://<public url>/#code=chc_…&s=ID`: a one-time code (`Web::LinkCodes`, until the grant
13
+ expires; the grant's token sealed under the code, so the store keeps no token) that the page trades for the cookie. Without either, a loopback peer with no token is the
14
+ owner, and a reverse proxy on the same machine would make every visitor loopback. A participant's token is the
13
15
  participant's wherever it comes from: their link opened on this machine (or on a chi web not on the LAN, whose link
14
16
  works on this machine only) is the participant, never the owner.
15
17
 
@@ -47,11 +49,24 @@ participant** and **one role**, until it expires or is revoked.
47
49
  refused with one message naming the file (`Grants::Corrupt`: `chi share` exits 1, chi web answers 500 with it);
48
50
  an archive or delete still goes ahead. Fix the file, or move it aside to end every link.
49
51
  - Rotating the owner token (`chi web --new-token`) never touches grants; `chi share revoke --all` ends them all.
52
+ - **Bound grants** (`identity`, an email in lower case; nil for any, and absent from the store then): a bound grant
53
+ is honoured only on a request through the proxy (`web.public_url`) whose identity header carries that email; on the
54
+ LAN or this machine it is refused (401). The gate checks it (`participant_principal(identity:, proxied:)`);
55
+ issuing one (`chi share --identity`, the Invite dialog's email) is shared-sessions P6.
50
56
 
51
57
  ## The gate
52
58
 
53
59
  `App#principal_for` resolves every request to a `Web::Principal`:
54
60
 
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`,
64
+ `X-Real-IP`, a known identity header, the configured one, or the public Host) is `#proxied_principal`'s: the owner
65
+ only with the owner's token **and**, when `web.proxy.identity_header` is set, that header being one clean value
66
+ (not repeated, no comma, not empty, under one name with no underscore: the raw header names) equal to
67
+ `web.proxy.owner`; a participant with a live grant (a bound one only for its identity); else 401, its reason in
68
+ the detail (only the requester's own identity). Unproxied, every request needs a token too (no "loopback is the
69
+ owner"): rule 3 doesn't apply.
55
70
  1. The owner's token (Bearer, then the cookie; `?token=` on the page's trade): the **owner**. Checked first, so the
56
71
  owner's requests never touch the grants store (measured: no change to an owner route's time).
57
72
  2. A `chp_` token (`Grants.token?`) whose grant is active: a **participant** (`Principal.new(kind: :participant,
@@ -63,7 +78,9 @@ participant** and **one role**, until it expires or is revoked.
63
78
  owner." / "This review link expired: ask the session's owner for a new one."), any other **401
64
79
  `unauthorized`** as before.
65
80
 
66
- The `?token=` trade sets the `chi_token` cookie to the token that was presented, the owner's or the participant's
81
+ The `?token=` trade (and `POST /api/token`, with a link's one-time `{code}` or the 401 page's pasted `{token}`; with
82
+ `web.public_url` only through the proxy, as a direct request takes Bearer only) sets the `chi_token` cookie (`__Host-chi_token`, Secure, on a proxied request) to the token that was
83
+ presented, the owner's or the participant's
67
84
  (one at a time per browser: a participant link opened in the owner's browser replaces the owner's cookie there, and
68
85
  makes that browser the participant, on loopback too). A grant's token is traded on any peer; the owner's only where
69
86
  the gate asks for it. On a peer the gate doesn't ask, the 401 for a grant's cookie that no longer works clears the
@@ -189,12 +206,13 @@ made by `chi share revoke` in another process; the page then reads a 401 and sho
189
206
  D5 (a): prompts and replies with their authors, one-line tool rows, turn boundaries, the queue and waiting states.
190
207
  Not tool output or arguments, thinking, approvals' and questions' content, memories (text or names), context notes,
191
208
  plugin steers, automatic and delegate input, cards, command output, the working set (the user decided: not), cost,
192
- 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,
193
211
  `Web::ParticipantView` (`lib/samagotchi/web/participant_view.rb`), whose every answer is a new hash built from named
194
212
  fields: a field added to an event later reaches nobody until it is named there.
195
213
 
196
214
  - **Events** (`.event`, the stream). `EVENTS` names the types a participant gets, each rebuilt by its own method:
197
- `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:
198
216
  `ClientId.human?`, else `automatic`; `continue`), the turn's end (`turn_completed`, `turn_canceled`, `turn_failed`:
199
217
  type and turn id), `generation_chunk` (its visible `text` only; a thinking-only chunk is dropped),
200
218
  `generation_retrying` (`restarted`), `tool_call_started` (the tool's name, and for a file tool the path relative to
@@ -426,8 +444,10 @@ participant TUI in v1.
426
444
 
427
445
  ## Invariants
428
446
 
429
- - The owner is the owner's token, or a loopback peer (or a chi web not on the LAN) with no participant token; nothing
430
- else. A participant token never widens to the owner, from any peer.
447
+ - The owner is the owner's token, or a loopback peer (or a chi web not on the LAN, and without `web.public_url`) with
448
+ no participant token; nothing else. Through a proxy the owner is the token and, with an identity header
449
+ configured, that identity: never the identity alone, never a loopback peer. A participant token never widens to the
450
+ owner, from any peer.
431
451
  - A participant reaches one session, through the `:participant` rows only; another session is a 404.
432
452
  - No token is written anywhere: the grants store has digests, the sidecar and the audit trail have neither.
433
453
  - Owner sessions are unchanged: no sidecar, no audit file, no key in the session JSON until a grant exists.
@@ -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
 
@@ -189,7 +189,10 @@ with a "Machine-local" comment; the rest branch on `@access.remote?` (`SendComma
189
189
  - **The token is the owner.** In LAN mode (`web.host: lan`) every non-loopback request needs the `Web::Token` access
190
190
  token, as the `chi_token` cookie or `Authorization: Bearer` (`App#token_gate`). This runs over plain http, which is
191
191
  fine on a home LAN, the same as the phone page. A loopback chi web without LAN mode needs no token
192
- (`Remotes::Entry#token` is nil, and no header is sent).
192
+ (`Remotes::Entry#token` is nil, and no header is sent). Behind an auth proxy (`web.public_url`, experimental) every
193
+ request needs it, loopback too, and a proxied owner also the configured identity header
194
+ ([configuration.md: Behind an auth proxy](../configuration.md#behind-an-auth-proxy), `Web::ProxyGate`); `chi
195
+ --remote` through such a proxy isn't supported yet (the CLI can't pass its sign-in, and streams need TLS).
193
196
  - **Every request has a principal.** `App#principal_for` resolves who a request is, as a `Web::Principal`
194
197
  (`web/principal.rb`): `Principal::OWNER` for a peer the gate doesn't ask (no LAN mode, or a loopback peer) and for
195
198
  the access token; a participant for a live grant's `chp_` token; nil (a 401) for anyone else (`token_revoked` /
@@ -205,7 +208,8 @@ with a "Machine-local" comment; the rest branch on `@access.remote?` (`SendComma
205
208
  - **The token at rest.** `remotes.yml` is written through `AtomicFile.write(perm: 0o600)`, its read-modify-write (`Remotes.put` / `remove`) under `remotes.yml.lock`. `Entry#inspect` / `#to_s`
206
209
  redact the token. `chi remote ls` never prints it (JSON shows `token: true|false`). `HTTPTransport#inspect` and
207
210
  `Remote#inspect` show only the label.
208
- - **The Host check.** `local_host_header?` accepts only the loopback names and the exact LAN IP. `http://studio.local`
211
+ - **The Host check.** `local_host_header?` accepts only the loopback names and the exact LAN IP (and, with
212
+ `web.public_url`, its host exactly, port included). `http://studio.local`
209
213
  gets a 403 `forbidden`, which the client words as "use the LAN IP". The check guards against DNS rebinding.
210
214
  `cross_site?` lets CLI clients through because they send no `Origin` or `Sec-Fetch-Site`.
211
215
  - **`client_id` only tightens.** `handle_question_answer` forwards `client_id` only when it is `ClientId::CLI_ANSWER`