samagotchi 0.107.0 → 0.109.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +69 -1
- data/docs/architecture.md +10 -4
- data/docs/configuration.md +81 -0
- data/docs/internals/engine.md +17 -6
- data/docs/internals/participants.md +28 -8
- data/docs/internals/remote-sessions.md +6 -2
- data/docs/internals/working-set.md +2 -1
- data/docs/memory.md +20 -0
- data/docs/plugins.md +9 -2
- data/docs/releasing.md +11 -6
- data/docs/sessions.md +3 -3
- data/docs/sub-agent.md +1 -1
- data/docs/testing.md +5 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -2
- data/lib/samagotchi/bridge.rb +28 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +51 -9
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/bundles/work-cap/manifest.yml +2 -2
- data/lib/samagotchi/bundles/work-cap/plugin.rb +50 -14
- data/lib/samagotchi/child_reports.rb +2 -1
- data/lib/samagotchi/cli/parent_wait.rb +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/context_sources.rb +1 -6
- data/lib/samagotchi/continue_offer.rb +8 -6
- data/lib/samagotchi/engine.rb +36 -25
- data/lib/samagotchi/guardrail_wiring.rb +9 -6
- data/lib/samagotchi/idle_scheduler.rb +17 -2
- data/lib/samagotchi/last_turn.rb +7 -0
- data/lib/samagotchi/log_subscriber.rb +2 -1
- data/lib/samagotchi/memory_orphans.rb +74 -0
- data/lib/samagotchi/memory_paths.rb +4 -4
- data/lib/samagotchi/parent_report.rb +11 -3
- data/lib/samagotchi/proxy_trace.rb +45 -0
- data/lib/samagotchi/reply_wait.rb +7 -2
- data/lib/samagotchi/send_command/busy.rb +5 -2
- data/lib/samagotchi/send_command/wait_target.rb +16 -11
- data/lib/samagotchi/session_access/local.rb +46 -10
- data/lib/samagotchi/session_inbox.rb +12 -8
- data/lib/samagotchi/session_metrics.rb +7 -6
- data/lib/samagotchi/session_participants.rb +3 -1
- data/lib/samagotchi/steer.rb +11 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -26
- data/lib/samagotchi/terminal_ui/formatting.rb +2 -2
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +5 -1
- data/lib/samagotchi/tools/memory.rb +16 -9
- data/lib/samagotchi/turn_origin.rb +68 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +233 -26
- data/lib/samagotchi/web/grants.rb +8 -4
- data/lib/samagotchi/web/info_probe.rb +5 -1
- data/lib/samagotchi/web/link_codes.rb +146 -0
- data/lib/samagotchi/web/participant_access.rb +15 -118
- data/lib/samagotchi/web/participant_review.rb +1 -1
- data/lib/samagotchi/web/participant_view.rb +7 -13
- data/lib/samagotchi/web/participants_admin.rb +136 -0
- data/lib/samagotchi/web/proxy_gate.rb +174 -0
- data/lib/samagotchi/web/public/app.js +24 -0
- data/lib/samagotchi/web/public/auth_lost.js +76 -0
- data/lib/samagotchi/web/public/data.js +29 -5
- data/lib/samagotchi/web/public/participant.js +19 -5
- data/lib/samagotchi/web/public/participant_model.js +5 -0
- data/lib/samagotchi/web/public/participant_names.js +2 -2
- data/lib/samagotchi/web/public/participants_panel.js +2 -2
- data/lib/samagotchi/web/public/token_fragment.js +57 -0
- data/lib/samagotchi/web/public_url.rb +87 -0
- data/lib/samagotchi/web/server.rb +70 -10
- data/lib/samagotchi/worker.rb +30 -25
- data/lib/samagotchi/worker_inbound.rb +12 -9
- data/lib/samagotchi/worker_sidecar.rb +4 -3
- data/lib/samagotchi/working_set.rb +3 -2
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 296c5f9af0f1605fb4f2d92a82c7bcd0e435adcebded5e7c18638aeb1c651595
|
|
4
|
+
data.tar.gz: 57b18910dabdcc0b0239f9986d49ee369f6231dd5c182bc1b885812e6be4ecbb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 969c44f06a17ccceb15b7bcfa7253c7543da71142c3d535c697844622832b69e4a9002f85e1ec6f1226edfda9001b347a2c0df186b683d5b75a4249ab555ebb3
|
|
7
|
+
data.tar.gz: a1724dec2c5e7a76794a74b9ffca5d3f85d60f9529a85114cfba656a5958f6401aab48ec1264b4e49db4384db2ceee830dca992b5645caef43f988c9b13d9ca9
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,72 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.109.0] - 2026-10-24
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Experimental: `chi web` behind an auth proxy (oauth2-proxy, nginx `auth_request`, Caddy `forward_auth`, Cloudflare
|
|
16
|
+
Access, Tailscale Serve, or a plain TLS proxy). `web.public_url` (an https origin) makes every request need the
|
|
17
|
+
access token, this machine's too; `web.proxy.identity_header` + `web.proxy.owner` also check the signed-in email
|
|
18
|
+
the proxy sends (owner = that identity AND the token; a repeated, comma, empty or underscore-twin header is
|
|
19
|
+
refused). Through the proxy the cookie is `__Host-chi_token` (Secure), a `?token=` page isn't traded (the paste box's
|
|
20
|
+
redirect is absolute from `web.public_url`), links (the start line, the web's and `chi share`'s invites) are `https://…/#code=…` with a
|
|
21
|
+
one-time code, never a token, and on this machine a request takes the token as `Authorization: Bearer` only. See
|
|
22
|
+
docs/configuration.md (Behind an auth proxy).
|
|
23
|
+
- Experimental, with `web.public_url`: a link's `#code=` is traded for the cookie by the page (`POST /api/token`;
|
|
24
|
+
a page that already has a session asks first), and the 401 page's paste box posts the token, never puts it in the
|
|
25
|
+
URL; the page notices a lost sign-in (the proxy's session or chi's token ended:
|
|
26
|
+
a 401, a redirect to the sign-in page, the session list's stream giving up) and reloads once, then says "Signed out"
|
|
27
|
+
with Reload instead of going quietly stale. The page's API calls ask for JSON (`Accept: application/json`).
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- chi web's HTML answers can't be framed by another page (`X-Frame-Options: DENY`, `frame-ancestors 'none'`), and a
|
|
32
|
+
frame/embed/object load from another origin is refused (403 `framed`).
|
|
33
|
+
- A session's Bridge refuses any request that came through a proxy (`X-Forwarded-*`, `Forwarded`, `X-Real-IP`,
|
|
34
|
+
`X-Auth-Request-*` and the like, underscore twins too: 403 `proxied`): an auth proxy that forwards every local port
|
|
35
|
+
and adds such a header no longer reaches a worker's unauthenticated Bridge (a forward that adds none still does).
|
|
36
|
+
chi's own clients never send these headers. A worker's `bridge.json` is now 0600.
|
|
37
|
+
- `chi share`'s LAN link names the LAN address the running chi web reports only if it is one of this machine's own
|
|
38
|
+
addresses; else the link names 127.0.0.1 (whatever answers on chi web's port may be another user's listener).
|
|
39
|
+
|
|
40
|
+
Update with `chi update` (the system bundle 0.109.0), then restart `chi web`.
|
|
41
|
+
|
|
42
|
+
## [0.108.0] - 2026-10-24
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- A project's memories copied from another machine or path (their folder keyed by the old path's hash) are no
|
|
47
|
+
longer missing without a word: when this project's folder is missing or empty and one of the same name with
|
|
48
|
+
another hash exists, chi warns at session start (`memory>`) and under a project `memory_read` miss, naming both
|
|
49
|
+
folders and the `mv` that fixes it. See docs/memory.md (Moving memories to another machine).
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- `chi send --wait` and `chi answer` left with the turn still running (`--timeout`, Ctrl-C) offer the command that waits
|
|
54
|
+
again: `chi send --wait --turn T ID`, or `chi send --wait ID` when the turn's id isn't known (with the attach that
|
|
55
|
+
shows it); under `--remote NAME` both carry it.
|
|
56
|
+
- `chi send --wait` into a running turn is refused with what to do next: `--queue` runs the message after it,
|
|
57
|
+
`chi send --wait ID` waits for it, `chi --attach ID` shows it (under `--remote NAME` the attach, no longer "open it
|
|
58
|
+
in chi web").
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- `chi -p … --non-interactive` prints the load warnings (a guardrail or plugin that failed to load, the orphaned
|
|
63
|
+
project memory folder) on stderr as `<label>> …` lines; it used to drop them.
|
|
64
|
+
- cost-cap 0.2.3, work-cap 0.1.3: a session's state file that doesn't read (not JSON) no longer drops its `/cap` or
|
|
65
|
+
`/workcap` cap silently: it is logged and you get one notice naming the file and the cap used instead (the
|
|
66
|
+
config's), and the next `/cap`, `/workcap` or Continue that writes over it says so instead of replacing it silently.
|
|
67
|
+
- `chi send --wait -m` to an idle session (and `--new --wait`) waits for its message's own turn: a worker that dies
|
|
68
|
+
in that turn ends the wait `canceled` (`worker_stopped`) once the next worker settles it, instead of sitting to
|
|
69
|
+
the timeout; the `turn_id` it reports was already that turn's.
|
|
70
|
+
- A `delegate` follow-up's wait (or delegate report) to an idle child no longer takes the child's previous turn's
|
|
71
|
+
reply for its own when it looks just as the follow-up's own turn starts.
|
|
72
|
+
- A scheduled job that dies of an error outside Ruby's StandardError (a stack overflow, a job calling `exit`) no
|
|
73
|
+
longer cuts chi's shutdown short: the rest of it (plugin tasks, MCP servers) still stops.
|
|
74
|
+
|
|
75
|
+
Update with `chi update` (the system bundle 0.108.0, the cost-cap bundle 0.2.3, the work-cap bundle 0.1.3), then restart `chi web`.
|
|
76
|
+
|
|
11
77
|
## [0.107.0] - 2026-10-24
|
|
12
78
|
|
|
13
79
|
### Added
|
|
@@ -4130,7 +4196,9 @@ and long-lived sessions.
|
|
|
4130
4196
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
4131
4197
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
4132
4198
|
|
|
4133
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
4199
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.109.0...HEAD
|
|
4200
|
+
[0.109.0]: https://github.com/dm1try/samagotchi/compare/v0.108.0...v0.109.0
|
|
4201
|
+
[0.108.0]: https://github.com/dm1try/samagotchi/compare/v0.107.0...v0.108.0
|
|
4134
4202
|
[0.107.0]: https://github.com/dm1try/samagotchi/compare/v0.106.0...v0.107.0
|
|
4135
4203
|
[0.106.0]: https://github.com/dm1try/samagotchi/compare/v0.105.0...v0.106.0
|
|
4136
4204
|
[0.105.0]: https://github.com/dm1try/samagotchi/compare/v0.104.0...v0.105.0
|
data/docs/architecture.md
CHANGED
|
@@ -153,7 +153,7 @@ agent logic and can be used without any terminal rendering; the UI is a thin lay
|
|
|
153
153
|
| UI | `Samagotchi::TerminalUI` | Interactive REPL (Reline), rendering (ANSI, spinner, status line), REPL commands. Delegates all core work to an `Engine`. |
|
|
154
154
|
| Model loops and adapters | `KernelLoop`, `LLM::ChatLoop`, `Samagotchi::Client`, `LLM::OpenAIChat`, `LLM::AnthropicMessages`, `LLM::HTTP` | The model↔tool loops and the HTTP adapters they talk through (see "Model loops and adapters"). |
|
|
155
155
|
| Bridge (SSE/HTTP) | `Samagotchi::Bridge`, `SessionManager` | The **single live client transport**: an SSE read stream + HTTP POST turn/cancel/answer surface that attaches to a worker's existing `Engine` via `Engine#subscribe`. Every session worker starts it (bound `127.0.0.1`, no auth, localhost-only). |
|
|
156
|
-
| Web (Rack) | `Samagotchi::Web::App`, `SessionManager` | Single-port `127.0.0.1:4567` control plane via `rack`+`webrick` (serve `index.html` + `/api/*`). With `web.host: lan` (or one of this machine's IPv4 addresses) the server also listens on that address, where every request but this machine's needs the access token (`App#token_gate`: the `chi_token` cookie or `Authorization: Bearer`; [CLI](cli.md#chi-web-on-your-phone)). 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
|
|
@@ -201,7 +201,8 @@ These are the Engine's raw events. The worker's SSE projection omits `call` from
|
|
|
201
201
|
`:tool_call_started` and `result` from `:turn_completed`; the answer remains in
|
|
202
202
|
`turn_summary.output`.
|
|
203
203
|
|
|
204
|
-
A turn started with `origin:` carries it on each of these (and `:turn_failed`)
|
|
204
|
+
A turn started with `origin:` (a `TurnOrigin`, or its Hash) carries it on each of these (and `:turn_failed`), as
|
|
205
|
+
the Hash `{client_id:, enqueued_id:, author:}` with only the fields set (`TurnOrigin#to_h`).
|
|
205
206
|
|
|
206
207
|
Every event is a `Hash` with a `:type` symbol key; the sink must not raise (the Engine
|
|
207
208
|
rescues sink errors). A new UI (web, API) supplies its own `on_event` and
|
|
@@ -386,7 +387,11 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
|
|
|
386
387
|
- No browser: only chi's own clients talk to a Bridge. A request with an `Origin` header, a `Sec-Fetch-Site`
|
|
387
388
|
other than `none`, or a `Host` that isn't loopback (a DNS-rebound page) is `403 cross_origin`
|
|
388
389
|
(`Bridge#browser_request?`, before any route); there is no CORS and no `OPTIONS` route. The browser reaches a
|
|
389
|
-
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.
|
|
390
395
|
|
|
391
396
|
The metrics in `/state`, `/snapshot`, `/stats` and the SSE `snapshot`/`reset` frames
|
|
392
397
|
(`SessionMetrics#snapshot`) carry the session's totals and only the recent timing records: the
|
|
@@ -695,7 +700,8 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
|
|
|
695
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)). |
|
|
696
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)). |
|
|
697
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)). |
|
|
698
|
-
|
|
|
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` | Experimental: chi web behind an auth proxy. Every request needs the token; a proxied one's owner also needs the identity header (one clean value under one name); the `__Host-` cookie, an absolute `Location` from the public URL, https links with a one-time code in the fragment; the page's lost-sign-in banner. Bridges refuse proxy traces ([configuration.md](configuration.md#behind-an-auth-proxy)). |
|
|
704
|
+
| 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)). |
|
|
699
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'. |
|
|
700
706
|
| Images / vision | `image_store.rb` (`ImageStore`), `vision_support.rb`, `vision_context.rb` | Stored prompt images and whether a model takes them. |
|
|
701
707
|
| Toolkits | `toolkit.rb` (`Toolkit`), `toolkit_binding.rb` (`ToolkitBinding`, the Engine's seam), `toolkit_command.rb` (`/toolkit`), `tools/registry.rb` (`Registry#toolkit`, `#left_out_names`), `web/public/toolkit_chip.js` | Which registered tools a session's prompts declare: saved in the session, resolved at a turn's start (`Engine#apply_toolkit!`), named in the prompt's not-loaded line, a kit's prompt text after the appended files; `chi.toolkit` and `ctx.use_toolkit` for plugins ([configuration.md](configuration.md#toolkits)). |
|
data/docs/configuration.md
CHANGED
|
@@ -1596,6 +1596,84 @@ web:
|
|
|
1596
1596
|
- `GET /api/sessions/:id` (and its `?tail=1`) carries `capabilities.editor` (true for such a viewer) and
|
|
1597
1597
|
`editor_url`, the template (null otherwise).
|
|
1598
1598
|
|
|
1599
|
+
## Behind an auth proxy
|
|
1600
|
+
|
|
1601
|
+
Experimental. `chi web` binds 127.0.0.1 only; to use it from elsewhere over https, put it behind a proxy that does TLS
|
|
1602
|
+
and, usually, a sign-in (oauth2-proxy, nginx `auth_request`, Caddy `forward_auth`, Cloudflare Access, Tailscale
|
|
1603
|
+
Serve; a work pod forwarding `https://<port>.<pod>.<domain>` through oauth2-proxy) and tell chi web its public URL:
|
|
1604
|
+
|
|
1605
|
+
```yaml
|
|
1606
|
+
web:
|
|
1607
|
+
public_url: https://4567.pod.example.com # the https origin people open: no path
|
|
1608
|
+
proxy:
|
|
1609
|
+
identity_header: X-Forwarded-Email # optional: the header the proxy puts the signed-in email in
|
|
1610
|
+
owner: me@example.com # optional, needs identity_header: you, as that header says it
|
|
1611
|
+
```
|
|
1612
|
+
|
|
1613
|
+
What changes once `web.public_url` is set:
|
|
1614
|
+
|
|
1615
|
+
- **Every request needs the access token**, this machine's too (`$XDG_STATE_HOME/samagotchi/web-token`, made at
|
|
1616
|
+
start; `chi web --new-token` replaces it). A proxy that adds no header and rewrites Host (nginx's `proxy_pass` with
|
|
1617
|
+
its defaults) would otherwise look like this machine. On this machine the page opens through the proxy too: a
|
|
1618
|
+
request that didn't come through it takes the token only as `Authorization: Bearer` (no cookie: a cookie for
|
|
1619
|
+
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
|
|
1620
|
+
and cookie). chi's own probe of the port (a second `chi web`, `chi share`,
|
|
1621
|
+
`chi update`) sends no token; chi web answers it the few fields it checks. Links and the start line come from this
|
|
1622
|
+
machine's config, never from whatever answers on the port.
|
|
1623
|
+
- **A request through the proxy** (it carries `X-Forwarded-For`, `Forwarded`, `X-Real-IP`, a known identity header,
|
|
1624
|
+
or the public Host) is the owner only with the token **and**, when `web.proxy.identity_header` is set, that header
|
|
1625
|
+
being exactly `web.proxy.owner` (any case). A repeated, comma-separated or empty value, or the header sent under a
|
|
1626
|
+
second spelling (`X_Forwarded_Email` next to `X-Forwarded-Email`), is refused (401, the reason in the answer's
|
|
1627
|
+
detail). With `identity_header` unset the token alone decides: that works behind any proxy. `owner` without
|
|
1628
|
+
`identity_header` stops `chi web` from starting.
|
|
1629
|
+
- Through the proxy the cookie is `__Host-chi_token` (Secure, Path=/, no Domain: other host names under the domain,
|
|
1630
|
+
such as a pod's other ports `https://<port>.<pod>…`, don't get it; the LAN's `chi_token` is separate). Cookies aren't
|
|
1631
|
+
scoped by port: another server on the **same host name** and a different port does get it, so give chi web a host
|
|
1632
|
+
name of its own, or port 443. Through the proxy a `?token=` page isn't traded (it would ride the sign-in redirect,
|
|
1633
|
+
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
|
|
1634
|
+
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
|
|
1635
|
+
domain, or `http://`, is refused). No page of chi web's may be framed by another (`X-Frame-Options: DENY`,
|
|
1636
|
+
`frame-ancestors 'none'`, in every mode).
|
|
1637
|
+
- Links carry a **one-time code** in the fragment, never a token: `chi web` prints `Proxied: https://…/#code=chc_…`
|
|
1638
|
+
(the owner's: once, within 10 minutes; `chi web` again prints a new one), and a participant's link (`chi share`,
|
|
1639
|
+
the Invite dialog) is `https://<public url>/#code=chc_…&s=ID` (once, until the grant expires). A fragment isn't sent
|
|
1640
|
+
to the proxy, but it outlives the sign-in's redirects: the sign-in page sits at that URL (its scripts can read it)
|
|
1641
|
+
and it stays in the browser's history, which is why it holds a code that works once. The page trades the code for
|
|
1642
|
+
the cookie (`POST /api/token`) and drops it from the URL; a page that already has a session asks first, since the
|
|
1643
|
+
trade replaces it. A code presented with the wrong identity is used up and gives nothing, so whoever already holds
|
|
1644
|
+
a link can spend it (`chi web` again, or a new invite, makes another). The 401 page's paste box posts the token in
|
|
1645
|
+
the request's body, never in the URL.
|
|
1646
|
+
- 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
|
|
1647
|
+
back), and within a minute of that shows "Signed out … Reload" instead of a list that quietly stopped updating. Its
|
|
1648
|
+
API calls ask for JSON, so oauth2-proxy answers an ended sign-in with 401 instead of a redirect.
|
|
1649
|
+
- A session's Bridge (its worker's own port, unauthenticated) refuses a request that carries a proxy header such as
|
|
1650
|
+
`X-Forwarded-For` or `Forwarded` (403 `proxied`). That is a heuristic, not a lock: a forward that adds no such header
|
|
1651
|
+
(nginx's `proxy_pass` with its defaults, a plain TCP forward) still reaches a worker, as the owner.
|
|
1652
|
+
- `chi --remote` to a chi web behind a sign-in proxy isn't supported yet.
|
|
1653
|
+
|
|
1654
|
+
> **Warning.** Until Bridges get a secret of their own (planned), don't run chi web behind a proxy or tunnel that can
|
|
1655
|
+
> reach any local port, such as a pod forwarding every `https://<port>.<pod>…`, unless it adds `X-Forwarded-For` or
|
|
1656
|
+
> `Forwarded` to every request; and never one that also lets other people through its sign-in.
|
|
1657
|
+
|
|
1658
|
+
Which header to trust: only one the proxy **sets** on every signed-in request **and replaces** when a client sends
|
|
1659
|
+
it. Check before setting `identity_header`: run an echo on a free port behind the same proxy (`python3 -c` with
|
|
1660
|
+
`http.server`, or any header-echo server), open it signed in, and send your own copies (`curl -H 'X-Forwarded-Email:
|
|
1661
|
+
evil@x' -H 'X_Forwarded_Email: evil@x' …` with your sign-in cookie): the header you trust must come back with your
|
|
1662
|
+
email only. chi warns once in its log when a proxied request carries a known identity header but not the configured
|
|
1663
|
+
one.
|
|
1664
|
+
|
|
1665
|
+
| Setup | `identity_header` | Notes |
|
|
1666
|
+
|---|---|---|
|
|
1667
|
+
| 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. |
|
|
1668
|
+
| 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. |
|
|
1669
|
+
| 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. |
|
|
1670
|
+
| 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. |
|
|
1671
|
+
| Tailscale Serve | `Tailscale-User-Login` | Not checked yet: verify with an echo. Funnel (public) requests carry no identity: with the header set they are refused, leave it unset to let the token decide. |
|
|
1672
|
+
| 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). |
|
|
1673
|
+
| A TLS proxy with no sign-in (nginx, Caddy, an SSH tunnel) | leave unset | The token alone decides. |
|
|
1674
|
+
|
|
1675
|
+
Keep the state dir (`web-token`, the grants) out of any folder others can read, such as a team's shared folder.
|
|
1676
|
+
|
|
1599
1677
|
## All settings
|
|
1600
1678
|
|
|
1601
1679
|
Every setting below takes the three forms described in
|
|
@@ -1723,6 +1801,9 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
|
|
|
1723
1801
|
| `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
1802
|
| `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
1803
|
| `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`). |
|
|
1804
|
+
| `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). |
|
|
1805
|
+
| `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). |
|
|
1806
|
+
| `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
1807
|
| `history.file` | state dir | | Prompt history path, shared by the TUI and the web composer. |
|
|
1727
1808
|
| `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
1809
|
| `no_default_input` | `false` | `--no-default-input` | Don't pre-fill `default.input`; a top-level key. |
|
data/docs/internals/engine.md
CHANGED
|
@@ -424,7 +424,7 @@ The turn as the Engine sees it (the KernelLoop has its own `Turn` struct):
|
|
|
424
424
|
Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
|
|
425
425
|
:settings, :id, :limit, :resumed, :resume_from) do
|
|
426
426
|
def resumed? = resumed ? true : false # a step-limit Continue
|
|
427
|
-
def tag(event) = origin ? event.merge(origin: origin) : event # boundary events carry the origin
|
|
427
|
+
def tag(event) = origin ? event.merge(origin: origin.to_h) : event # boundary events carry the origin's wire Hash
|
|
428
428
|
def elapsed = ... # seconds since started_at
|
|
429
429
|
end
|
|
430
430
|
```
|
|
@@ -519,11 +519,22 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
|
|
|
519
519
|
| `:turn_failed` | an error | `{error_class, message, duration_ms, error_kind?, retryable?, host?, summary?}` |
|
|
520
520
|
| `:answer_display` | after the after_turn hooks (observers only, not the sink) | `{display, refs?}` (display a string, or nil; refs the count of the answer's refs, only when a hook attached some) |
|
|
521
521
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
522
|
+
A turn's origin is a `TurnOrigin` (`lib/samagotchi/turn_origin.rb`, a `Data.define` of `client_id`, `enqueued_id`
|
|
523
|
+
and `author`): `SessionInbox.read_input` makes it from an input file's own fields, the wakes, reports and reminders
|
|
524
|
+
make theirs, and `Engine#run_turn`, the Worker and `GuardrailWiring` pass it on. Its readers say what callers ask
|
|
525
|
+
(`#participant?`, `#participant_author`, `#participant_id`, `#source` = `ClientId.origin`, `#human?` =
|
|
526
|
+
`ClientId.human?`). At a boundary it is turned once: `#to_h` writes the wire Hash (only the fields set, as before),
|
|
527
|
+
`TurnOrigin.from` reads a Hash back (symbol or string keys; nil for none). In-process an origin is never nil:
|
|
528
|
+
`TurnOrigin.of` gives `TurnOrigin::NONE` (no sender: an owner's turn with no client) for nil or `{}`, and NONE is
|
|
529
|
+
`run_turn(origin:)`'s default (a Hash is still read as one). NONE has no wire form (`#empty?`; `#wire` is nil, or
|
|
530
|
+
`#to_h` for any other origin), so the events and the hooks' `context` carry no origin for it, as before.
|
|
531
|
+
`LastTurn#author` stays the session file's string; `LastTurn#participant_author` reads it as
|
|
532
|
+
`TurnOrigin#participant_author` does. The boundary events (`:turn_started`, `:turn_completed`, `:turn_canceled`, `:turn_failed`) also carry
|
|
533
|
+
`origin` when the turn was queued with one: `{client_id:, enqueued_id:}`, plus `author:` when someone other than
|
|
534
|
+
the session's owner wrote it (its input file's `author`, stamped by chi web from the request's `Web::Principal`).
|
|
535
|
+
An owner's turn has no `author` anywhere: not in its input file, its origin or the session. `GuardrailWiring#begin_turn`
|
|
536
|
+
gets the origin, and `#turn_author` reads its participant author for the gate's checks; the guardrails `Context`
|
|
537
|
+
(the hooks' `context`) keeps the wire Hash.
|
|
527
538
|
|
|
528
539
|
Where a participant's author (`"p:<id>"`; `Steer.participant_author?`: any author but none or `"owner"`) goes:
|
|
529
540
|
|
|
@@ -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
|
|
11
|
-
|
|
12
|
-
|
|
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,22 @@ 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`), a **proxied** request (a proxy trace: `X-Forwarded-*`, `Forwarded`,
|
|
62
|
+
`X-Real-IP`, a known identity header, the configured one, or the public Host) is `#proxied_principal`'s: the owner
|
|
63
|
+
only with the owner's token **and**, when `web.proxy.identity_header` is set, that header being one clean value
|
|
64
|
+
(not repeated, no comma, not empty, under one name with no underscore: the raw header names) equal to
|
|
65
|
+
`web.proxy.owner`; a participant with a live grant (a bound one only for its identity); else 401, its reason in
|
|
66
|
+
the detail (only the requester's own identity). Unproxied, every request needs a token too (no "loopback is the
|
|
67
|
+
owner"): rule 3 doesn't apply.
|
|
55
68
|
1. The owner's token (Bearer, then the cookie; `?token=` on the page's trade): the **owner**. Checked first, so the
|
|
56
69
|
owner's requests never touch the grants store (measured: no change to an owner route's time).
|
|
57
70
|
2. A `chp_` token (`Grants.token?`) whose grant is active: a **participant** (`Principal.new(kind: :participant,
|
|
@@ -63,7 +76,9 @@ participant** and **one role**, until it expires or is revoked.
|
|
|
63
76
|
owner." / "This review link expired: ask the session's owner for a new one."), any other **401
|
|
64
77
|
`unauthorized`** as before.
|
|
65
78
|
|
|
66
|
-
The `?token=` trade
|
|
79
|
+
The `?token=` trade (and `POST /api/token`, with a link's one-time `{code}` or the 401 page's pasted `{token}`; with
|
|
80
|
+
`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
|
|
81
|
+
presented, the owner's or the participant's
|
|
67
82
|
(one at a time per browser: a participant link opened in the owner's browser replaces the owner's cookie there, and
|
|
68
83
|
makes that browser the participant, on loopback too). A grant's token is traded on any peer; the owner's only where
|
|
69
84
|
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
|
|
@@ -135,8 +150,8 @@ lock), and its ack and `:turn_enqueued` say `queue`; the worker's drain takes no
|
|
|
135
150
|
turn (`Worker#pending_input_drain`, for a file the Bridge never saw), so each runs as the owner's own turn after it.
|
|
136
151
|
The web's delivery line says so (`participantTurn`: Enter queues, no cut), the TUI prints its queued note, `chi send`
|
|
137
152
|
says `(queued: runs after the current turn)` and its `--wait` waits for that turn (`WaitTarget.message`). The owner's
|
|
138
|
-
own turns steer as before. A step-limit Continue of a participant's turn (the owner answers it) continues as theirs: its origin
|
|
139
|
-
their author (`ContinueOffer#run_continue_turn`), so it runs inside the boundary and on their spend; once their link ended or their cap is reached, the owner's Continue
|
|
153
|
+
own turns steer as before. A step-limit Continue of a participant's turn (the owner answers it) continues as theirs: its origin (a `TurnOrigin`,
|
|
154
|
+
`#participant?`; see engine.md) keeps their author (`ContinueOffer#run_continue_turn`), so it runs inside the boundary and on their spend; once their link ended or their cap is reached, the owner's Continue
|
|
140
155
|
ends the offer as a Stop instead (`ContinueOffer#refuse_continue`: decision `refused`, a notice, `not_continued`), so
|
|
141
156
|
nothing waits on it. The step-limit Continue offer of the owner's turn is the
|
|
142
157
|
owner's to answer: while one stands, the worker **holds** a participant's input file (`Worker#run_input_files`,
|
|
@@ -306,6 +321,9 @@ the owner sees the text before it runs (docs/guardrails.md, the review boundary)
|
|
|
306
321
|
|
|
307
322
|
## The owner's routes
|
|
308
323
|
|
|
324
|
+
`Web::ParticipantsAdmin` (`web/participants_admin.rb`, mixed into `App` beside `ParticipantAccess`, the participant's own
|
|
325
|
+
gate and routes) answers these.
|
|
326
|
+
|
|
309
327
|
| Route | Body / params | Answer | Refusals |
|
|
310
328
|
|---|---|---|---|
|
|
311
329
|
| `GET /api/sessions/:id/participants` | | 200 `{participants: [grant], warning}`, every state; `warning`: the share warning for a session that isn't a review session (`ReviewStart.share_warning`, null for one), shown before the panel's Invite with Start a review of it | |
|
|
@@ -423,8 +441,10 @@ participant TUI in v1.
|
|
|
423
441
|
|
|
424
442
|
## Invariants
|
|
425
443
|
|
|
426
|
-
- The owner is the owner's token, or a loopback peer (or a chi web not on the LAN
|
|
427
|
-
else.
|
|
444
|
+
- 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
|
|
445
|
+
no participant token; nothing else. Through a proxy the owner is the token and, with an identity header
|
|
446
|
+
configured, that identity: never the identity alone, never a loopback peer. A participant token never widens to the
|
|
447
|
+
owner, from any peer.
|
|
428
448
|
- A participant reaches one session, through the `:participant` rows only; another session is a 404.
|
|
429
449
|
- No token is written anywhere: the grants store has digests, the sidecar and the audit trail have neither.
|
|
430
450
|
- Owner sessions are unchanged: no sidecar, no audit file, no key in the session JSON until a grant exists.
|
|
@@ -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
|
|
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`
|
|
@@ -52,7 +52,8 @@ is left out. So is a failed run (`LLMContextStale`'s rule: an `Error` after the
|
|
|
52
52
|
add op `edit`.
|
|
53
53
|
- `memory_read` gives one memory per name (`Tools::MemoryRead.parse_names`; an empty name, the index listing, gives
|
|
54
54
|
none). A partial read ("a,b" where b is missing: the found bodies, then `Error: memory not found: b`) keeps the
|
|
55
|
-
found names only, and its tokens are split evenly among them.
|
|
55
|
+
found names only, and its tokens are split evenly among them. The names come off that line alone
|
|
56
|
+
(`WorkingSet::MISSING`): an orphaned-folder hint may follow it on the next line.
|
|
56
57
|
- `memory_write` gives the memory `call[:path]`, op `write`. A `remove: true` write stays in the set.
|
|
57
58
|
- `web_fetch` gives the URL with its whitespace collapsed.
|
|
58
59
|
|
data/docs/memory.md
CHANGED
|
@@ -21,6 +21,26 @@ Tool behavior:
|
|
|
21
21
|
- `chi memory ls` and `chi memory rm NAME` are the user's side of the same (see [Listing and removing from the shell](#listing-and-removing-from-the-shell)).
|
|
22
22
|
- `write`/`edit` that change a `*.md` right in a scope dir refresh its managed `index.md` line (date, bytes; the description is kept), as `memory_write` does. A memory created with `write` gets a line with no description; any other `.md` written there gets a line too. Model overlays (`<name>.<key>.md` next to `<name>.md`), files in another project's folder and files changed by `execute` are left out.
|
|
23
23
|
|
|
24
|
+
## Moving memories to another machine
|
|
25
|
+
|
|
26
|
+
A project's folder is keyed by its root's absolute path (`<basename>_<8 hex of MD5(root)>`), so project
|
|
27
|
+
memories copied to a machine (or a path) where the repository lives elsewhere sit in a folder chi no
|
|
28
|
+
longer reads. Rename the folder to the new key, or move memories with a bundle zip (see
|
|
29
|
+
[What a bundle owns](#what-a-bundle-owns)).
|
|
30
|
+
|
|
31
|
+
Inside a git repository, when this project's folder is missing or holds no `*.md` while the projects
|
|
32
|
+
dir has a folder of the same basename with another hash, chi says so, naming both folders and the
|
|
33
|
+
`mv` that fixes it (a name match can't prove it's the same project, so nothing is renamed for you):
|
|
34
|
+
|
|
35
|
+
- at session start, a `memory>` load warning (a `:guardrail_warning` with label `memory`; the attached
|
|
36
|
+
TUI, the REPL, `--non-interactive` and the web show it; a UI that attaches later gets it from the
|
|
37
|
+
worker's cards), once per worker or REPL process, so a respawn or resume says it again;
|
|
38
|
+
- under a `memory_read` miss that looked in project scope (`scope: "project"` or none), and under the
|
|
39
|
+
project index read, on a `Hint:` line after `Error: memory not found: …`. A system-scope read never
|
|
40
|
+
gets it.
|
|
41
|
+
|
|
42
|
+
Neither enters the system prompt.
|
|
43
|
+
|
|
24
44
|
## Listing and removing from the shell
|
|
25
45
|
|
|
26
46
|
`chi memory ls [--scope project|system] [--format text|json]` lists the
|
data/docs/plugins.md
CHANGED
|
@@ -1708,7 +1708,10 @@ parent may Continue: nothing restricts it, as with the step limit. A background
|
|
|
1708
1708
|
its own card and doesn't wake its parent ([sessions.md](sessions.md#delegating)).
|
|
1709
1709
|
|
|
1710
1710
|
The state is one file per session, `<data_dir>/sessions/<session id>.json` (`{"cap": 3.0, "step": 3.0}`), removed
|
|
1711
|
-
with the session.
|
|
1711
|
+
with the session. A file that is there but doesn't read (not JSON, or not a mapping) is logged
|
|
1712
|
+
(`costcap_state_unreadable`) and gets one notice, `cost-cap: <path> doesn't read, its /cap cap is ignored; using
|
|
1713
|
+
default: ($5.00)`: the settings' cap is used, never a silent drop; the next `/cap` or Continue rewrites it, logged
|
|
1714
|
+
(`costcap_state_replaced`) and said (`cost-cap: <path> didn't read; replaced with the new cap`).
|
|
1712
1715
|
|
|
1713
1716
|
Its sibling for work instead of money, tool calls and active minutes across the session's turns, is
|
|
1714
1717
|
[the work-cap bundle](#the-work-cap-bundle): the cap a local model (no cost) can have.
|
|
@@ -1766,7 +1769,11 @@ bundles:
|
|
|
1766
1769
|
- `/workcap off` (or `/workcap 0`): no work cap for this session, over the config.
|
|
1767
1770
|
|
|
1768
1771
|
The state is one file per session, `<data_dir>/sessions/<session id>.json` (`{"tool_calls": {"cap": 800, "step":
|
|
1769
|
-
400}}`, a meter's `"cap": null` for off).
|
|
1772
|
+
400}}`, a meter's `"cap": null` for off). A file that is there but doesn't read (not JSON, or not a mapping) is
|
|
1773
|
+
logged (`workcap_state_unreadable`) and gets one notice, `work-cap: <path> doesn't read, its /workcap caps are
|
|
1774
|
+
ignored; using default.tool_calls: (400)`: the settings' caps are used, never a silent drop; the next `/workcap` or
|
|
1775
|
+
Continue rewrites it, logged (`workcap_state_replaced`) and said (`work-cap: <path> didn't read; replaced with the
|
|
1776
|
+
new cap`).
|
|
1770
1777
|
|
|
1771
1778
|
## The check-in bundle
|
|
1772
1779
|
|
data/docs/releasing.md
CHANGED
|
@@ -25,7 +25,8 @@ can run the whole release; the user approves the notes before the tag and the
|
|
|
25
25
|
marked bundle's `requires_chi` to `">= X.Y.Z"` and drops the marker;
|
|
26
26
|
`release:check` fails while a marker is left. chi itself ignores the key,
|
|
27
27
|
and `bundles:check` notes it (and fails a marker that isn't `true` or a
|
|
28
|
-
one-line string). `release:bump` touches no other `requires_chi
|
|
28
|
+
one-line string). `release:bump` touches no other `requires_chi` but a
|
|
29
|
+
profile's (next point);
|
|
29
30
|
- a new `ctx.*` / `chi.*` call a plugin may make gets its release's version in `Plugin::Api::SINCE` once
|
|
30
31
|
released (`chi bundle check`'s `requires-chi-floor` reads it); an unreleased one waits there as a comment;
|
|
31
32
|
- a new shipped bundle joins a profile (`includes:` in `core` or `dev`; a
|
|
@@ -33,9 +34,11 @@ can run the whole release; the user approves the notes before the tag and the
|
|
|
33
34
|
goes up: `chi update` then installs only the new bundle for those who
|
|
34
35
|
have the profile;
|
|
35
36
|
- a profile's `requires_chi` is its members' highest: a member's raised
|
|
36
|
-
floor
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
floor raises its profile's too, with the profile's version. `release:bump`
|
|
38
|
+
does both for a profile its `requires_chi_next` raises leave below a
|
|
39
|
+
member (a patch bump of the profile's version, once per release); a floor raised by hand
|
|
40
|
+
needs the profile raised by hand (`bundles:check` notes a profile below a
|
|
41
|
+
member);
|
|
39
42
|
- the release notes say "run `chi update`" and name the bundles whose
|
|
40
43
|
version moved (what changed in each), not per-bundle upgrade steps.
|
|
41
44
|
- Pre-1.0: config and commands may change in a minor version (0.2 → 0.3); a
|
|
@@ -67,7 +70,7 @@ as the GitHub release body.
|
|
|
67
70
|
| `rake lint` / `rake lint:fix` | RuboCop over the repo (must report no offenses; CI runs it) / its safe autocorrect (`rubocop -a`). |
|
|
68
71
|
| `rake gem:validate`, `gem:build`, `gem:install`, `gem:clean` | Check the gemspec; build the gem into `pkg/`; build and install it locally; remove the built gems. |
|
|
69
72
|
| `rake release:draft_changelog` | A draft Unreleased section from the commit subjects since the last tag, grouped. Stdout only. |
|
|
70
|
-
| `rake release:bump[X.Y.Z]` | VERSION, the system manifest, Gemfile.lock, CHANGELOG, and `requires_chi: ">= X.Y.Z"` for each bundle marked `requires_chi_next` (the marker dropped). No commit. |
|
|
73
|
+
| `rake release:bump[X.Y.Z]` | VERSION, the system manifest, Gemfile.lock, CHANGELOG, and `requires_chi: ">= X.Y.Z"` for each bundle marked `requires_chi_next` (the marker dropped), then a profile left below a member's `requires_chi` raised to it, with a patch bump of its version (none when it moved since the last tag) and named in the CHANGELOG's "Update with `chi update`" line. No commit. |
|
|
71
74
|
| `rake release:check` | Clean tracked tree, VERSION == system bundle, a CHANGELOG section, no `requires_chi_next` left, `bundles:check`, `rake lint` (RuboCop) + `npm run lint` (the web frontend's ESLint), rspec (both roots: `spec` and the bundles' own `spec/`) + `rake bundles:specs` + npm test, `gem build`, then a clean install into a temp GEM_HOME that runs `chi --version`, `chi self` and `chi bundle list` with a temp HOME. Needs the network (gem dependencies). |
|
|
72
75
|
| `rake release:notes[X.Y.Z]` | Prints the CHANGELOG section (default: the current VERSION). |
|
|
73
76
|
|
|
@@ -85,7 +88,9 @@ The agent does each step and stops where the user has to say yes.
|
|
|
85
88
|
3. **The user approves the notes and the version.** Show them the section.
|
|
86
89
|
4. **Bump.** `bundle exec rake "release:bump[X.Y.Z]"`, review `git diff`: it
|
|
87
90
|
names the bundles whose `requires_chi` it raised (their `requires_chi_next`
|
|
88
|
-
markers are gone)
|
|
91
|
+
markers are gone) and the profiles it raised with them (a patch bump of
|
|
92
|
+
each one's version, none for a profile already bumped since the last tag),
|
|
93
|
+
which it names in the CHANGELOG's "Update with `chi update`" line.
|
|
89
94
|
5. **Check.** Commit first (the check wants a clean tree), then run it:
|
|
90
95
|
`git commit -am "Release X.Y.Z"` and `bundle exec rake release:check`.
|
|
91
96
|
Fix anything it finds in new commits and run it again.
|
data/docs/sessions.md
CHANGED
|
@@ -29,7 +29,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
|
|
|
29
29
|
|
|
30
30
|
**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
31
|
|
|
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`, `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.
|
|
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.
|
|
33
33
|
|
|
34
34
|
**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
35
|
|
|
@@ -223,8 +223,8 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
|
|
|
223
223
|
(`SAMAGOTCHI_THINKING_LEVEL=low chi send --new …`) only reaches the worker this call spawns: when the session's
|
|
224
224
|
worker is woken later (by `chi web`, a delivery) without that env, the level is gone. Use `--thinking`.
|
|
225
225
|
- `--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; wait
|
|
227
|
-
- 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 --attach ID`). There is no default timeout.
|
|
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).
|
|
227
|
+
- 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
228
|
- `--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
229
|
- `--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).
|
|
230
230
|
- `--set KEY=VALUE` (with `--new`, repeatable) gives the new session a setting of its own, as `chi --set` does (see *Session settings* below): a core key a session may set (`--set recap.enabled=false`) or a bundle's `BUNDLE.KEY` (`chi send --new --set cost-cap.cap=3 -m "…"` starts it with a $3 cap). A key no session sets (`guardrails.mode`), a bundle or key the installed bundles don't offer, or a value of another type than the key takes (`recap.enabled=maybe`, `cost-cap.cap=five`) is a usage error (exit 2), as is `--set` without `--new`.
|
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 `--queue`
|
|
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 |
|
|
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
|
|
data/docs/testing.md
CHANGED
|
@@ -223,7 +223,11 @@ docs or source as text (its `RULES`: add one when a full run catches what the na
|
|
|
223
223
|
file's layer (any `terminal_ui/*.rb` → `spec/terminal_ui_golden_spec.rb`; `turn_flow.rb` and `turn_note.rb`, whose
|
|
224
224
|
words the REPL prints, pick it by a rule), then the other name matches (a spec named after several changed files first, e.g. `engine_toolkit_spec.rb` for `engine.rb` and
|
|
225
225
|
`toolkit.rb`; then one beside the file, so `plugin/context.rb` picks `spec/samagotchi/plugin/*context*` before
|
|
226
|
-
`spec/guardrails/context_spec.rb`), then the specs that name a
|
|
226
|
+
`spec/guardrails/context_spec.rb`), then the specs that name a method the change touches (the innermost `def` around a
|
|
227
|
+
changed line, read with Prism; one `git grep` for up to 12 names; names under 4 characters, `initialize`, `to_s`,
|
|
228
|
+
`call`… and `Object`'s or `Enumerable`'s methods skip), then the specs that name a changed file's class. The method tier
|
|
229
|
+
needs Prism 1.0 or later: Ruby 3.4+ ships it, a 3.3 install has it only beside rubocop or minitest 6; below that it is
|
|
230
|
+
skipped, which `--why` says. A method named in too many specs to list (a hub) gets a line under `--why` only. The default range is the merge-base with main (or origin/main) plus uncommitted and untracked files.
|
|
227
231
|
The Ruby specs go to stdout; the node tests to run (`npm test` targets), "web changed: also run npm run e2e" (with `npm run lint` too when a frontend module changed) and a
|
|
228
232
|
hint for a hub class (named in too many specs to list) go to stderr, and so do the `timing:` hints, from the lines a
|
|
229
233
|
changed spec adds (`git diff -U0` against the base; an untracked spec, all its lines): a `Thread.new`, `.start`,
|