samagotchi 0.85.0 → 0.86.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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -1
  3. data/docs/architecture.md +34 -5
  4. data/docs/bundle-authoring.md +125 -0
  5. data/docs/cli.md +38 -3
  6. data/docs/configuration.md +6 -5
  7. data/docs/guardrails.md +63 -35
  8. data/docs/hooks.md +19 -3
  9. data/docs/internals/anthropic.md +2 -2
  10. data/docs/internals/bundles.md +11 -2
  11. data/docs/internals/context-telemetry.md +19 -1
  12. data/docs/internals/engine.md +1 -1
  13. data/docs/memory.md +1 -1
  14. data/docs/plugins.md +59 -2
  15. data/docs/releasing.md +1 -1
  16. data/docs/sessions.md +6 -6
  17. data/docs/sub-agent.md +35 -24
  18. data/docs/testing.md +7 -2
  19. data/lib/samagotchi/answer_command.rb +21 -25
  20. data/lib/samagotchi/bridge/client_event.rb +50 -0
  21. data/lib/samagotchi/bridge/sse_writer.rb +6 -2
  22. data/lib/samagotchi/bridge.rb +22 -11
  23. data/lib/samagotchi/bridge_client.rb +2 -2
  24. data/lib/samagotchi/bundle_command/check.rb +348 -0
  25. data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
  26. data/lib/samagotchi/bundle_command/test_run.rb +89 -0
  27. data/lib/samagotchi/bundle_command.rb +125 -2
  28. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
  31. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  32. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
  33. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  34. data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
  35. data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
  36. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  37. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  38. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  39. data/lib/samagotchi/client_id.rb +1 -1
  40. data/lib/samagotchi/config.rb +195 -115
  41. data/lib/samagotchi/context_status.rb +1 -1
  42. data/lib/samagotchi/continue_offer.rb +2 -1
  43. data/lib/samagotchi/engine.rb +4 -4
  44. data/lib/samagotchi/guardrail_wiring.rb +4 -0
  45. data/lib/samagotchi/guardrails/approval.rb +12 -2
  46. data/lib/samagotchi/guardrails/load_failures.rb +17 -3
  47. data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
  48. data/lib/samagotchi/guardrails/parent_policy.rb +258 -0
  49. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  50. data/lib/samagotchi/hook_routes.rb +5 -3
  51. data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
  52. data/lib/samagotchi/hooks/registry.rb +13 -6
  53. data/lib/samagotchi/hooks.rb +5 -0
  54. data/lib/samagotchi/kernel_loop.rb +5 -2
  55. data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
  56. data/lib/samagotchi/memory_bundle/shas.rb +127 -0
  57. data/lib/samagotchi/memory_bundle.rb +1 -0
  58. data/lib/samagotchi/parent_report.rb +87 -14
  59. data/lib/samagotchi/pending_question.rb +8 -4
  60. data/lib/samagotchi/plugin/api.rb +16 -0
  61. data/lib/samagotchi/plugin/context.rb +11 -5
  62. data/lib/samagotchi/plugin/loader.rb +5 -3
  63. data/lib/samagotchi/question_desk.rb +37 -24
  64. data/lib/samagotchi/queued_command.rb +5 -4
  65. data/lib/samagotchi/relay.rb +5 -1
  66. data/lib/samagotchi/relay_verifier.rb +4 -5
  67. data/lib/samagotchi/reply_wait.rb +14 -5
  68. data/lib/samagotchi/send_command.rb +34 -8
  69. data/lib/samagotchi/session_commands.rb +14 -3
  70. data/lib/samagotchi/terminal_ui/attach_launcher.rb +1 -1
  71. data/lib/samagotchi/terminal_ui/attached_loop.rb +13 -11
  72. data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
  73. data/lib/samagotchi/terminal_ui.rb +12 -9
  74. data/lib/samagotchi/testing/boundary.rb +0 -3
  75. data/lib/samagotchi/testing/bundle.rb +7 -0
  76. data/lib/samagotchi/testing/host.rb +6 -4
  77. data/lib/samagotchi/testing/install.rb +119 -0
  78. data/lib/samagotchi/testing/recorder.rb +13 -4
  79. data/lib/samagotchi/testing.rb +1 -0
  80. data/lib/samagotchi/tools/builtins.rb +2 -1
  81. data/lib/samagotchi/tools/delegate_wait.rb +6 -1
  82. data/lib/samagotchi/tools/task_runtime.rb +5 -3
  83. data/lib/samagotchi/turn_checkpoint.rb +64 -2
  84. data/lib/samagotchi/turn_flow.rb +10 -0
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +48 -10
  87. data/lib/samagotchi/web/public/chunk_router.js +8 -19
  88. data/lib/samagotchi/web/public/ctx.js +12 -26
  89. data/lib/samagotchi/web/public/stream_handlers.js +1 -1
  90. data/lib/samagotchi/web/server.rb +4 -0
  91. data/lib/samagotchi/worker.rb +19 -5
  92. data/lib/samagotchi/worker_inbound.rb +4 -4
  93. metadata +11 -2
  94. data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
data/docs/hooks.md CHANGED
@@ -89,9 +89,12 @@ class Watchful
89
89
  # A single-select question through the question flow (REPL, attached
90
90
  # TUI, web); returns {selected: [...], freeform:, selected_indices:}
91
91
  # or nil when there is no one to ask (--non-interactive), the
92
- # question was dismissed, or the options were not 2-8 strings.
92
+ # question was dismissed, or the options (or effects) were invalid.
93
+ # effects: says what an option does for a parent agent's answer (see
94
+ # below): Run lets one call through, so it is :permit.
93
95
  answer = event[:ask_user].call(question: "#{event[:call][:name]}: #{event[:params]}\nRun it?",
94
- options: ["Run", "Deny"], header: "my guard", allow_freeform: false)
96
+ options: ["Run", "Deny"], header: "my guard", allow_freeform: false,
97
+ effects: { "Run" => :permit, "Deny" => :deny })
95
98
  event[:guardrail].deny!("the user said no") unless answer&.dig(:selected)&.first == "Run"
96
99
  when :before_generation
97
100
  # Cancel the running turn: a warn notice with the reason, then the
@@ -112,6 +115,19 @@ class Watchful
112
115
  end
113
116
  ```
114
117
 
118
+ **Tag the options that spend or permit.** A question can be answered by a
119
+ parent agent (`chi answer`, a piped `chi --attach`), and
120
+ `guardrails.parent_answers` decides which options it may pick by their
121
+ `effects:` (label => effect): `:spend` (more time or money: a Continue),
122
+ `:permit` (one call), `:permit_wide` (more than one), `:deny` (a Stop or a
123
+ Deny); an option left out is a plain `:answer`, which a parent may always
124
+ pick. A label the options don't have, or an unknown effect, makes the call
125
+ nil (warned), like bad options. Text alone and a dismissal always pass for a
126
+ parent (the worker takes text alone even with `allow_freeform: false`), so
127
+ they must never spend or permit. See
128
+ [Guardrails](guardrails.md#answers-from-a-parent-agent). `effects:` needs a
129
+ chi newer than 0.85.0 (an older one raises on the keyword).
130
+
115
131
  `event[:hook]` is the label the notices carry: `known_names.rb (bundle
116
132
  known-names)` for a bundle hook, `audit.rb (config)` for a config hook,
117
133
  `turn hook` for one registered at runtime.
@@ -592,7 +608,7 @@ Notes:
592
608
  - Ordering: bundle hooks fire by `(priority, bundle_name, hook_name)` (lower priority first), then plain `config.yml` hooks in registration order.
593
609
  - Settings: a hook class with `initialize(settings = {})` gets the bundle's section of `config.yml` `bundles:` (see [Settings](#settings)).
594
610
  - A bundle can also ship a `plugin.rb` whose `chi.on(event)` blocks are bundle hooks too, next to commands and tools; see [Plugins](plugins.md). There `ctx.notify`, `ctx.steer` and the other `ctx` helpers act as this page's `event[:notify]`… do for the event the block runs for.
595
- - Shipped bundles (each in a profile: `core` or `dev`, see [Bundle profiles](memory.md#bundle-profiles-core-and-dev)): `chi bundle install guardrails` (rules, see [Guardrails](guardrails.md#the-guardrails-bundle)) and `chi bundle install known-names` (a hook, see [Guardrails](guardrails.md#the-known-names-bundle)), `chi bundle install source-links` (a hook: announces source refs, see [The source-links bundle](#the-source-links-bundle)), `chi bundle install btw` (a plugin: `/btw`, see [Plugins](plugins.md#the-btw-bundle)), `chi bundle install mcp` (a plugin: tools from MCP servers, see [Plugins](plugins.md#the-mcp-bundle)) `chi bundle install loop-guard` (a plugin: breaks tool-call loops, see [Plugins](plugins.md#the-loop-guard-bundle)), `chi bundle install check-in` (a plugin: checks on a long turn, see [Plugins](plugins.md#the-check-in-bundle)), `chi bundle install slow-commands` (a plugin: commands that ran past the execute limit are run as tasks next time, see [Plugins](plugins.md#the-slow-commands-bundle)), `chi bundle install agents-md` (a plugin: a nested AGENTS.md is steered into the conversation on the first touch under its directory, see [Plugins](plugins.md#the-agents-md-bundle)), `chi bundle install cost-cap` (a plugin: a per-session $ cap, `/cap`, see [Plugins](plugins.md#the-cost-cap-bundle)) and `chi bundle install skills` (a plugin: `/skill`, versions of skills, see [Plugins](plugins.md#the-skills-bundle)).
611
+ - Shipped bundles (each in a profile: `core` or `dev`, see [Bundle profiles](memory.md#bundle-profiles-core-and-dev)): `chi bundle install guardrails` (rules, see [Guardrails](guardrails.md#the-guardrails-bundle)) and `chi bundle install known-names` (a hook, see [Guardrails](guardrails.md#the-known-names-bundle)), `chi bundle install source-links` (a hook: announces source refs, see [The source-links bundle](#the-source-links-bundle)), `chi bundle install btw` (a plugin: `/btw`, see [Plugins](plugins.md#the-btw-bundle)), `chi bundle install mcp` (a plugin: tools from MCP servers, see [Plugins](plugins.md#the-mcp-bundle)) `chi bundle install loop-guard` (a plugin: breaks tool-call loops, see [Plugins](plugins.md#the-loop-guard-bundle)), `chi bundle install check-in` (a plugin: checks on a long turn, see [Plugins](plugins.md#the-check-in-bundle)), `chi bundle install slow-commands` (a plugin: commands that ran past the execute limit are run as tasks next time, see [Plugins](plugins.md#the-slow-commands-bundle)), `chi bundle install agents-md` (a plugin: a nested AGENTS.md is steered into the conversation on the first touch under its directory, see [Plugins](plugins.md#the-agents-md-bundle)), `chi bundle install cost-cap` (a plugin: a per-session $ cap, `/cap`, see [Plugins](plugins.md#the-cost-cap-bundle)), `chi bundle install ruby` (a plugin: an RSpec run with no examples at all, a hint, see [Plugins](plugins.md#the-ruby-bundle)) and `chi bundle install skills` (a plugin: `/skill`, versions of skills, see [Plugins](plugins.md#the-skills-bundle)).
596
612
  - Installing a bundle executes its hook code at `Engine` startup. Only install bundles you trust, as you would a gem. Hooks are **not** executed at install time (copy-only); they are `module_eval`'d at `Engine.new` inside per-bundle `Samagotchi::Bundles::<name>` namespaces (no top-level `require` collisions). Keep hook files side-effect-free at load time; do work in `#call` — top-level side effects (require, IO, `at_exit`, global assignment) run once per `Engine.new` (class redefinition is idempotent).
597
613
 
598
614
  Lifecycle:
@@ -97,8 +97,8 @@ index: `message_start` (served model, usage), `content_block_start` (text, think
97
97
  back); a `tool_use` input is an object; a thinking block always has a `signature` (`""` when none came).
98
98
  - `finish_reason` from `FINISH_REASONS`: `end_turn`/`stop_sequence` → `stop`, `max_tokens` and
99
99
  `model_context_window_exceeded` → `length`, `tool_use` → `tool_calls`, `refusal` → `refusal`; anything else as it is.
100
- - Usage in the OpenAI shape the rest of chi reads (`TokenUsage`, `SessionMetrics`, `ContextStatus`, the web's
101
- `ctx.js`): Messages counts `input_tokens` without the cache, so `prompt_tokens` is input + cache reads + cache
100
+ - Usage in the OpenAI shape the rest of chi reads (`TokenUsage`, `SessionMetrics`, `ContextStatus`, and the
101
+ Bridge's chunk `counts` the web's `ctx.js` reads): Messages counts `input_tokens` without the cache, so `prompt_tokens` is input + cache reads + cache
102
102
  writes, with `cached_tokens` and `cache_write_tokens` under `prompt_tokens_details`. Every field is optional and the
103
103
  later value wins; all zeros is no usage (`#usage_payload`: Fireworks' `message_start` carries only zeros).
104
104
 
@@ -22,7 +22,11 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
22
22
 
23
23
  | Piece | Role |
24
24
  |---|---|
25
- | `BundleCommand` (`lib/samagotchi/bundle_command.rb`) | `chi bundle install/upgrade/uninstall/status/diff/list/build/trash`; runs the Installer, prints its summary, runs the conflict agent step; refuses a source already installed under another name (`installed_under_another_name?`: a git URL bundle recorded under its repo's name before the manifest named it) |
25
+ | `BundleCommand` (`lib/samagotchi/bundle_command.rb`) | `chi bundle install/upgrade/uninstall/status/diff/list/build/trash/new/test/check`; runs the Installer, prints its summary, runs the conflict agent step; refuses a source already installed under another name (`installed_under_another_name?`: a git URL bundle recorded under its repo's name before the manifest named it) |
26
+ | `BundleCommand::Check` (bundle_command/check.rb) | `chi bundle check [PATH] [--fix] [--set K=V]`: a bundle being written, checked as an install takes it (docs/cli.md lists the codes); parses the manifest from `Shas.parseable` (a placeholder sha is a `sha` finding, not a YAML error), reads the user's installed bundles only for `clash`, and installs and loads through `Testing::Install` in a `Testing::Sandbox` with `force: true`, the log's echoes swallowed |
27
+ | `BundleCommand::Scaffold` (bundle_command/scaffold.rb) | `chi bundle new NAME`: the starting files (block-form `plugin:` entry, `requires_chi` at this chi, a kit test that passes as written); the manifest's shas are filled through `Shas.refresh` |
28
+ | `BundleCommand::TestRun` (bundle_command/test_run.rb) | `chi bundle test`: `RbConfig.ruby -I <chi's lib>` on `test/**/*_test.rb` (Minitest) or `spec/` (RSpec, as `rake bundles:specs`), under `Bundler.with_unbundled_env` when Bundler's runtime is loaded |
29
+ | `Shas` (shas.rb) | a manifest's `sha256:` lines: `.refresh(dir[, text])` rewrites them line by line (the layout kept; a `files:` entry it can't place raises `Shas::Error`), `.stale` → `[Stale(file, old, new)]` (`old` `""` for a placeholder, nil for a plugin / hook / script entry with no sha line), `.parseable` (a placeholder `sha256: sha256:` isn't YAML). Plain Ruby: `rake bundles:sha` / `bundles:check` (`script/release/release_tools.rb`) and `chi bundle check --fix` share it |
26
30
  | `SourceNormalizer` (source.rb) | source → local dir; a git/zip/tar extract is *owned* and removed after the install |
27
31
  | `SourceIdentity` (source.rb) | what a source is, for comparing two installs of one name: `shipped:<dir>` (any gem or checkout), `git:<url>` (no `#ref`, trailing `/` or `.git`), `path:<realpath>`; nil when it can't place one |
28
32
  | `PreparedSource` (source.rb) | one source normalized once (dir, owned, git commit): `BundleCommand` reads the bundle's name from its `manifest.yml` (the name for every kind of source, a git URL too) and hands it to the Installer (`prepared:`), so a source is cloned or extracted once per command |
@@ -47,7 +51,12 @@ and checks the recorded sha256 (and `requires_chi`) before loading.
47
51
  The checks live in `Hooks::BundleLoader.load` and `Plugin::Loader.load_bundle`; the loading itself is
48
52
  `BundleLoader.load_hook_file` (one hook file, into a given `namespace:`) and `Plugin::Loader.register_file` (one
49
53
  plugin file: build, `register`, `commit!`), which raise instead of logging: the bundle test kit
50
- (`Samagotchi::Testing::Bundle`) loads a bundle's source through them, unchecked.
54
+ (`Samagotchi::Testing::Bundle`) loads a bundle's source through them, unchecked. `Samagotchi::Testing::Install` is the
55
+ checked path in a sandbox: the Installer, then `ExtensionLoad` (bundle hooks, then `load_plugins`), returning the
56
+ Installer's warnings, this bundle's load failures and what it registered (`Install.registered`, also the parity
57
+ spec's shape). A load failure carries `LoadFailures::Failure#location` (the first backtrace frame inside the
58
+ bundle, or a SyntaxError's own line), which `Install` maps from the installed copy (`.bundles/<name>/plugin/`,
59
+ `hooks/`) back to the source dir.
51
60
 
52
61
  ## Installer#run, in order
53
62
 
@@ -30,7 +30,8 @@ Emission behavior:
30
30
  synthetic char-based estimate (`src=estimate`). The guidance text is dynamic
31
31
  and escalates with the bucket: healthy → proceed normally; moderate → prefer
32
32
  targeted/range reads; elevated → be concise, avoid large re-reads; critical →
33
- summarize aggressively and delegate broad work to subagents. With custom
33
+ write a short handoff note (state, next step) so the work can go on from it,
34
+ and delegate broad work to subagents. With custom
34
35
  `context.status_thresholds` the top bucket reads critical, the one below it
35
36
  elevated, the next moderate, and the first bucket (and the rest below) healthy.
36
37
 
@@ -85,6 +86,23 @@ Configuration:
85
86
 
86
87
  The `context.*` settings are read once, when the turn's `ContextStatus` is built, so a change counts from the next turn.
87
88
 
89
+ Per-chunk counts on the SSE stream: the Bridge sends each `generation_chunk` with chi's own `counts`
90
+ (`Bridge::ClientEvent`, from `ContextUsage.normalize` of the provider's payload) instead of the payload, and the
91
+ web's meter (`ctx.js#extractCtxPct`) counts their `total_tokens` against the window (their
92
+ `context_window_tokens` when the server reported one, else `generation_started`'s) or the budget when smaller, as
93
+ `ContextStatus` does. The rules:
94
+
95
+ - `ctx_pct` is not in `counts`: normalize computes it without the session's LLM context budget.
96
+ - Counts are cumulative for the request and appear only when the server reported any; a missing key means unknown,
97
+ never 0. Fireworks' all-zero `message_start` gets no usage (`AnthropicAssembly#usage_payload`), and other zeros
98
+ read as nil (`TokenUsage.first_positive`).
99
+ - `prompt_tokens` includes cached tokens: the Anthropic assembly adds the cache read and write in, and for llama.cpp
100
+ `TokenUsage.from_payload` reads `tokens_evaluated` (the whole prompt) before `timings.prompt_n` (the uncached part),
101
+ an order `spec/bridge/client_event_spec.rb` pins on a real fixture.
102
+ - `context_window_tokens` is there only when the payload reported a window (`n_ctx`, `context_window`); llama.cpp's
103
+ `/completion` chunks don't.
104
+ - A remote host that reports usage only in its last chunk moves the meter at the generation's end, as before.
105
+
88
106
  Context window lookup (`ContextWindow.resolve`, the first that answers wins; the event's `window_source` / the line's
89
107
  `window_src` names it):
90
108
 
@@ -478,7 +478,7 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
478
478
  | `:reminder_injected` | due reminders went into the turn | `{reminders}` |
479
479
  | `: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) |
480
480
  | `: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`) |
481
- | `:generation_chunk` | each streamed chunk | `{iteration, content, text, thinking, payload, tool_call?}` |
481
+ | `: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) |
482
482
  | `:generation_retrying` | the transport retries the request, or the chat loop asks again for a step whose stream dropped mid-answer (`restarted: true`: what the step streamed is void, and the web's live step, the TUI's activity line, the Bridge's TurnAccumulator and the stream hooks' watch start it over) | `{iteration, attempt, max_retries, next_delay, error_class, error_message, status, restarted?}` |
483
483
  | `:generation_completed` | a generation ended, or was cut | `{iteration, content_length, thinking_chars, served_model, requested_model, finish_reason, ...}`; a cut adds `stopped_by`, `stop_reason`; the Engine adds `speed`, `tokens`, `model_segments` |
484
484
  | `:generation_cancelled` | the turn was cancelled mid-loop | `{iteration, reason, stopped_by}` |
data/docs/memory.md CHANGED
@@ -317,7 +317,7 @@ profiles, meta bundles that install a set of them:
317
317
  | profile | bundles |
318
318
  |---|---|
319
319
  | `core` | loop-guard, check-in, guardrails, slow-commands, agents-md, cost-cap: the recommended safety set |
320
- | `dev` | known-names, mcp, btw, skills, source-links, github-pr, coordinator, claude-peer, codex-peer |
320
+ | `dev` | known-names, mcp, btw, skills, source-links, github-pr, coordinator, claude-peer, codex-peer, ruby |
321
321
 
322
322
  `chi bootstrap` installs `core` and, on a terminal, offers `dev`. An existing
323
323
  install gets neither by itself: `chi bundle install core` (or `dev`).
data/docs/plugins.md CHANGED
@@ -8,6 +8,9 @@ own.
8
8
  This is the first version of the plugin API. More of it comes later: see
9
9
  [Not yet](#not-yet).
10
10
 
11
+ Writing a bundle? Start with [bundle-authoring.md](bundle-authoring.md): the `chi bundle new` / `test` / `check`
12
+ loop, the event shapes, the kit, the pitfalls.
13
+
11
14
  ## A bundle with a plugin
12
15
 
13
16
  ```
@@ -742,7 +745,7 @@ full label, `plugin.rb (bundle my-bundle)`.
742
745
  | `ctx.prompt_files` | the files whose text is in the session's system prompt now: the AGENTS.md chain (the work tree top down to the cwd) and the appended files (`append_system_prompt:`, `--append-system-prompt`); absolute paths, in prompt order, `[]` before the first build |
743
746
  | `ctx.notify(text, level: :info, fallback_for: nil)` | one line to the user, labelled by the bundle (`my-bundle> …`). Every UI shows it, during a turn (a tool, a hook) or between turns (a command). `fallback_for: :display` marks a line that repeats the answer's display (`event[:present]`), which a UI that renders the display leaves out (`fallback_for: :editor`: a file the viewer could open from a ref in its editor): see [Hooks](hooks.md#what-a-hook-can-do-the-runtime). The keyword needs chi 0.35.0 (it raises on an older chi) |
744
747
  | `ctx.card(title:, body: "", actions: [], level: :info, id: nil)` | a card in every UI, returning its id: see [Cards](#cards) |
745
- | `ctx.ask_user(question:, options:, header: nil, allow_freeform: false)` | a single-select question through the question flow: `{selected:, freeform:, selected_indices:}`, or nil (no one to ask, cancelled, bad options) |
748
+ | `ctx.ask_user(question:, options:, header: nil, allow_freeform: false, effects: nil)` | a single-select question through the question flow: `{selected:, freeform:, selected_indices:}`, or nil (no one to ask, cancelled, bad options or effects). `effects:` tags the options a parent agent's answer is held to (label => `:spend`, `:permit`, `:permit_wide` or `:deny`; untagged = `:answer`): tag every option that spends or permits, and never let text alone or a dismissal do either ([hooks.md](hooks.md#what-a-hook-can-do-the-runtime)). It needs a chi newer than 0.85.0 |
746
749
  | `ctx.cancelled?` | whether the running turn was cancelled (a long tool should stop) |
747
750
  | `ctx.steer(text)` | put text into the running turn: its own user message at the loop's next boundary, shown as `my-bundle> nudged: …`. The model reads it with a header naming your bundle (`[Steer from the my-bundle plugin, mid-task. …]`); the session keeps the raw text. Returns true when queued, false with no turn running (it never starts one; that is `ctx.sessions`' send). Dropped (logged) if the model answers or the turn ends first. Safe from any thread: an anytime command, a hook (false from `:after_turn`), your own |
748
751
  | `ctx.stop_turn(reason)` | stop the running turn after a warn notice with the reason; true when it stopped one now. In a `:before_tool_call` block it also denies that call |
@@ -1157,6 +1160,7 @@ bundle.tool("my_tool", path: "a.txt") # → the text the mode
1157
1160
  verdict = bundle.tool_call({ name: "execute", content: "rm -rf /" })
1158
1161
  verdict.deny? # a real Guardrails::Verdict (votes: verdict.votes)
1159
1162
  bundle.notices.map(&:text) # also cards, steers, stops, generation_stops, questions
1163
+ bundle.questions.last.effects # an ask_user's tags as given: { "Continue" => :spend }
1160
1164
  bundle.answer({ selected: "yes" }) # the next ask_user's answer
1161
1165
  bundle.model_answers << "a summary" # the next ask_model's answer
1162
1166
  bundle.sessions.on(:children) { |all:| [] } # ctx.sessions, scripted and recorded
@@ -1205,6 +1209,16 @@ bundle.shutdown
1205
1209
  own sandbox, its bundles shut down after it (chi's shipped bundles keep theirs in `<bundle>/spec/`,
1206
1210
  docs/testing.md). **Minitest:** `require "samagotchi/testing/minitest"` and subclass
1207
1211
  `Samagotchi::Testing::Case` (the same helpers).
1212
+ - **Installed, not loaded from source:** `Samagotchi::Testing::Install.run(dir, settings: {})` (or
1213
+ `bundle.install`) installs the bundle into the sandbox as `chi bundle install` does (sha256 and `requires_chi`
1214
+ checked) and loads the installed copy with chi's own loaders. It returns `warnings` (the Installer's),
1215
+ `plugin_failures` / `hook_failures` (each with the `location` in your source, `plugin.rb:12`) and `registered`
1216
+ (commands, tools, hooks per event, inits, toolkits, tool and peer sources). A name chi ships raises
1217
+ `MemoryBundle::Installer::InstallError` unless `force: true`.
1218
+ - **From the command line:** `chi bundle test [PATH]` runs a bundle's `test/**/*_test.rb` (or `spec/`) on the
1219
+ Ruby and lib chi runs on, and `chi bundle check [PATH] [--fix]` runs `Install` (and the manifest, sha and
1220
+ `requires_chi` checks) in a sandbox ([cli.md](cli.md#writing-a-bundle-new-test-check)). `chi bundle new NAME`
1221
+ writes a test to start from.
1208
1222
 
1209
1223
  ## The btw bundle
1210
1224
 
@@ -1595,7 +1609,10 @@ bundles:
1595
1609
  - **At the cap** the turn pauses on a question (header "Cost cap"): `Cost cap reached: $3.04 spent of $3.00.
1596
1610
  Continue raises it to $6.00.` with **Continue** (another cap's worth: $3 → $6 → $9, as many steps as it takes to
1597
1611
  pass the spend, saved for the session) and **Stop** (the turn stops, `canceled` with `stopped_by: cost-cap`, and a
1598
- card shows the spend, reported, estimated and side). With no one to ask (`--non-interactive`) it stops.
1612
+ card shows the spend, reported, estimated and side). With no one to ask (`--non-interactive`) it stops. A parent
1613
+ agent answering it (`chi answer`) may Continue under `guardrails.parent_answers: soft` or `standard`, and only Stop
1614
+ under `hard` (Continue is tagged `:spend`; see [Guardrails](guardrails.md#answers-from-a-parent-agent)). `/cap N` is
1615
+ a command, not an answer: a parent that sends it (`chi send ID -m '/cap 10'`) raises the cap under any strategy.
1599
1616
  - **The cap of a session:** the one `/cap` or a Continue set for it, else its own `cap:` (`chi --set
1600
1617
  cost-cap.cap=3`, `chi send --new --set cost-cap.cap=3`; `--set cost-cap.cap=off`, `0` or a negative number for none), else `child:` for a
1601
1618
  delegate child, else `default:`. Delegates and continued links each have their own cap (a continued link keeps
@@ -2180,6 +2197,46 @@ skill install card) works the same.
2180
2197
  and `VERDICT OK` wakes chi once to close the round. Needs chi 0.67.0 (the closing
2181
2198
  wake: the chi released with this bundle).
2182
2199
 
2200
+ ## The ruby bundle
2201
+
2202
+ `chi bundle install ruby` (or the `dev` profile) installs the bundle shipped
2203
+ with chi (`lib/samagotchi/bundles/ruby/plugin.rb`): one `chi.on` hook
2204
+ (`:after_tool_call`), and no memory file.
2205
+
2206
+ An RSpec run that finds no examples prints `0 examples, 0 failures`, which
2207
+ reads green: the model takes the suite as passing and moves on with code no
2208
+ test ever loaded. It usually means a path that matched no file, a filter
2209
+ (`-e`, `--tag`) that matched no example, or a `.rspec --pattern` that hid
2210
+ the specs (a nested project's `.rspec` overrides one). The bundle notices
2211
+ that run and steers a hint into the running turn, which the user sees as
2212
+ `ruby> nudged: …`:
2213
+
2214
+ ```
2215
+ rspec ran 0 examples: check the path, the filter (-e/--tag) or the .rspec --pattern
2216
+ ```
2217
+
2218
+ It is a nudge for the model, not a denial: the turn goes on, and the model
2219
+ can say why the run was empty. It never runs anything.
2220
+
2221
+ - **The command.** Only an `execute` counts, and its command must run
2222
+ rspec: one of its simple commands (`&&`, `||`, `;`, `|`, a newline, so
2223
+ after a leading `cd x &&` too) starts with `rspec`, a path ending in
2224
+ `/rspec` (`bin/rspec`, `./bin/rspec`), or `parallel_rspec`, once a leading
2225
+ `X=…` assignment, `bundle exec` and `time` are left out. A command that
2226
+ only names rspec (`grep rspec README`, `echo "bundle exec rspec"`) is not
2227
+ a run.
2228
+ - **The output.** Every `<N> examples, <M> failures` line in the execute's
2229
+ output counts (RSpec's summary, with or without `, N pending`), and the
2230
+ hint comes when all of them are 0. `parallel_rspec` prints one per
2231
+ process, so a single process with examples leaves its 0 out of the total.
2232
+ - **No summary line, no hint.** A spec that fails to load, a path that
2233
+ doesn't exist, a command killed at its limit: the output already says what
2234
+ is wrong, and the hint would only repeat it.
2235
+
2236
+ Nothing is remembered, so the next 0-example run hints again: it can be
2237
+ the same mistake on another command, or a different one. A hint the model
2238
+ answers before joining the turn is dropped, as any steer is.
2239
+
2183
2240
  ## Shutdown
2184
2241
 
2185
2242
  When the REPL exits, or a session's worker exits (an idle exit, `/exit`, a
data/docs/releasing.md CHANGED
@@ -13,7 +13,7 @@ can run the whole release; the user approves the notes before the tag and the
13
13
  and `rake release:check` enforce it). The system bundle upgrades itself when
14
14
  chi starts.
15
15
  - **Every other shipped bundle** (btw, check-in, guardrails, known-names,
16
- loop-guard, mcp, skills, slow-commands, agents-md, cost-cap, source-links, github-pr, coordinator, claude-peer, codex-peer, and the profiles core and dev) has its own semver in its
16
+ loop-guard, mcp, skills, slow-commands, agents-md, cost-cap, source-links, github-pr, coordinator, claude-peer, codex-peer, ruby, and the profiles core and dev) has its own semver in its
17
17
  `manifest.yml` and, when it needs a newer chi, a `requires_chi:` line. They never upgrade by themselves: users run
18
18
  `chi update` (all of them, with the gem) or `chi bundle upgrade NAME`. So:
19
19
  - a change to a bundle's files bumps that bundle's `version:`
data/docs/sessions.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/sessions/<uuid>.json` (XDG-aware via `XDG_STATE_HOME`) plus a sidecar dir `<uuid>/` with `input/`/`notes/`/`output/`/`owner.lock`/`bridge.json` (`Session.session_dir`), and `tool_output/` once a long `execute` result was cut: its whole output, which the cut result names (`ToolOutputFiles`, the newest 50 kept; see [Tool output guardrails](internals/tool-guardrails.md#the-runners-cut-max_tool_output_chars)). Besides the messages and turn state, the JSON keeps `used_memory_names` (what the session read), `preloaded_memory_names` (`--memory`) and `muted_memory_names` (`--mute`), the last two written before the worker starts so a respawn builds the same prompt, `prompt_notes`, the model notes the system prompt carried at its last build (`[{name, scope, chars, digest}]`, see [Memory](memory.md#model-notes)), `parent_id`, the session that delegated this one (see *Delegating*; `null` otherwise), and `continues`, the session this one continues (the previous link of a chain, see *Session chains*; `null` otherwise); files from before those keys read as empty lists, no parent and no previous link. `model_name` is the resolved model ref (an alias applied, `host:id` when it names a host), so a resumed session keeps its model when an alias is retargeted; `model_typed` is the name it was given as when that differs (an alias), for the `models:` lookup after a resume (`null` otherwise).
4
4
 
5
- **Input files:** a message for a session's worker waits in `input/<timestamp>.json` (`SessionInbox.write_input`: `prompt`, `client_id`, `enqueued_id`, and `images`, `no_interrupt` and `delivery` when they apply; `delivery` is written only for `"queue"`). While a turn runs, the worker merges them at each step boundary in name order, except a queued one or one with images: those wait for the turn's end and run one turn each, in name order, and a file after them still merges (`Worker#pending_input_drain`). A queue is sent only to a worker whose `bridge.json` names the `delivery` feature; an older one would steer it, so `SessionManager.deliver_turn` refuses it (409 `delivery_unavailable`), and any other 4xx the Bridge answers is a refusal too, never a fallback file (except a 413, a body over its size limit: that one still goes in as a file, its `delivery` kept). **Reply files:** a turn that ends with visible text leaves its reply in `output/<timestamp>.txt`, or `output/<timestamp>-<enqueued_id>.txt` when the turn ran an input file with an id (`SessionInbox.write_output`), and the session's `last_turn` keeps that `enqueued_id`; so a wait for one queued message (`chi send --queue --wait`, `ReplyWait`'s `turn:`) finds its own turn's reply or end, never another turn's. The names still sort by time, and every reader takes `*.txt`.
5
+ **Input files:** a message for a session's worker waits in `input/<timestamp>.json` (`SessionInbox.write_input`: `prompt`, `client_id`, `enqueued_id`, and `images`, `no_interrupt` and `delivery` when they apply; `delivery` is written only for `"queue"`). While a turn runs, the worker merges them at each step boundary in name order, except a queued one or one with images: those wait for the turn's end and run one turn each, in name order, and a file after them still merges (`Worker#pending_input_drain`). A queue is sent only to a worker whose `bridge.json` names the `delivery` feature; an older one would steer it, so `SessionManager.deliver_turn` refuses it (409 `delivery_unavailable`), and any other 4xx the Bridge answers is a refusal too, never a fallback file (except a 413, a body over its size limit: that one still goes in as a file, its `delivery` kept). A session command recognized in a file runs without a turn; its completed outcome is kept at `input/settled/<enqueued_id>` so `chi send --wait` ends instead of waiting for a turn that will not run. **Reply files:** a turn that ends with visible text leaves its reply in `output/<timestamp>.txt`, or `output/<timestamp>-<enqueued_id>.txt` when the turn ran an input file with an id (`SessionInbox.write_output`), and the session's `last_turn` keeps that `enqueued_id`; so a wait for one queued message (`chi send --queue --wait`, `ReplyWait`'s `turn:`) finds its own turn's reply or end, never another turn's. The names still sort by time, and every reader takes `*.txt`.
6
6
 
7
7
  **Removing a queued message:** a message queued for after the running turn (`chi send --queue`, the web's Alt+Enter, `/queue TEXT` in a terminal), or one with images (it waits for the turn's end too), can be taken back while it still waits: the worker's Bridge (`POST /session/:id/turn/dequeue {enqueued_id}`, the `unqueue` feature) deletes its input file and every UI drops it (`:turn_dequeued`). It is a race with the turn's end, decided by the same rename the worker uses to take the file: once a turn took it the answer is `409 already_started` (it runs), and an id the worker no longer knows is `404 unknown_enqueued_id`. A plain message sent without `--queue` is not removable (`409 not_queued`): a running turn's next step may take it at any moment. A wait for its turn (`chi send --queue --wait`, a delegate follow-up's wait) ends at once with `removed` (the tombstone `input/removed/<enqueued_id>` says so), and a `chi -p` run whose queued prompt went ends with exit 1. The `200` carries the removed `prompt`: the web and the attached terminal put it back into the composer to edit (not its images). The images a removed message named stay in the session's `images/`. Not removable: a queued session command (`/queue /model x`: it waits in the worker, not in `input/`), a message in a stopped worker's inbox (the next worker runs it), and the plain REPL's (`--no-shared`) own queue.
8
8
 
@@ -27,7 +27,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
27
27
 
28
28
  **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.
29
29
 
30
- **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 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.
30
+ **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.
31
31
 
32
32
  **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. 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.
33
33
 
@@ -209,7 +209,7 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
209
209
  - `--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.
210
210
  - `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; wait or attach`, exit 1): the message would merge into it, and its reply would come back as the answer. `--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).
211
211
  - 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.
212
- - `--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` 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.
212
+ - `--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.
213
213
  - `--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).
214
214
  - `--set BUNDLE.KEY=VALUE` (with `--new`, repeatable) gives the new session a bundle setting of its own, as `chi --set` does (see *Bundle settings* below): `chi send --new --set cost-cap.cap=3 -m "…"` starts it with a $3 cap. A bundle or key the installed bundles don't offer is a usage error (exit 2), as is `--set` without `--new`.
215
215
  - `--continues ID` (with `--new`) starts the next link of that session's chain instead (see *Session chains*): in its folder, on its model (or the one `--model` names), LLM context, thinking level, toolkit and bundle settings, so `--dir`, `--llm-context*`, `--thinking`, `--toolkit`, `--tool`, `--no-tool` and `--set` are refused beside it; `ID` may be a prefix or `last:ID`, the chain's latest link. Without a message the new link starts idle; `--wait` then has nothing to wait for (a usage error). It prints `<full id> started (continues 3fa2c1d0)`, with `; 2 delegates moved` inside the parentheses when open delegates moved to it; a refusal (continued already, naming the next link; an open delegate that can't move; a turn running) is `chi send: refused: …`, exit 1, and a `--model` it can't start on is `chi send: could not start a session: …`, exit 1, nothing archived. A fallback to the default model (below) prints `chi send: warning: …`.
@@ -265,11 +265,11 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
265
265
  - `delegate_result(session:, timeout:)`: waits for a child's next reply: the one named, or the newest running child (`timeout` as for `delegate`: 0 checks once and returns at once). The reply comes back once; a later call waits for the next one. After `wait: false` it isn't needed: the report brings the reply.
266
266
  - A wait that timed out or was canceled still gets its delegate report: neither moves the parent's cursor, so the child's reply comes as a report when its turn ends. The result says so (`chi brings its reply here by itself as a delegate report …`; with `queue`, that it joins the next step or turn) and that `delegate_result` waits again only if the turn needs the reply now. A parent that gets no reports (`session.delegate_reports: off`, a `--no-shared` REPL, `--non-interactive`) is told `delegate_result <id> waits again` instead, and `wait: false`'s result says `delegate_result waits for its reply`.
267
267
  - What a wait returns, `session: <id>` then `status:` and the text, with the status words `chi send --wait --format json` uses: `answered` with the reply after a `---` line; `question` as soon as the child opens a question (the model's own, or a hook's), with the question's text, options and the `chi answer` command for it (nothing is answered for it: `chi answer`, attach or use the web, then wait again); `failed`, `canceled` or `no_answer` when the child's turn ended with nothing to say (its session shows what happened); `error` (its worker crashed) or `stopped` (`chi sessions stop`) at once; `worker_gone` when the child has had no worker for 15 seconds (it crashed without saying so, or idle-exited): `delegate` with `session:` starts it again; `running` on timeout, or when the parent's own turn is canceled (the child keeps running; the next turn can wait again). A reply saved before the child's worker idle-exited is returned at once.
268
- - **A child's step-limit question goes back to the parent model**, not to its user: `delegate_result` returns `status: question` with the `chi answer` commands for Continue and Stop, and says the model may instead send a narrower follow-up (`delegate session:`, which drops the question) or report back. A parent's `chi answer` follows `turn.parent_continue` (see [chi as a sub-agent](sub-agent.md#the-step-limit)).
269
- - **A child at its cost cap** (the [cost-cap bundle](plugins.md#the-cost-cap-bundle), the child's own cap: `child:` in its settings, or `/cap`) asks a `hook` question, "Cost cap": a parent waiting on it (`delegate`, `delegate_result`) gets it back as `status: question` with the `chi answer` commands, `--option Continue` or `--option Stop`. A **background** child (`wait: false`) paused at its cap does not wake its parent: delegate reports skip hook questions (they are the user's, on the child's card) and the approval relay carries approvals only, so the child waits on its card, with the web's bell. The parent sees it at its next `delegate_result`.
268
+ - **A child's step-limit question goes back to the parent model**, not to its user: `delegate_result` returns `status: question` with the `chi answer` commands for Continue and Stop, and says the model may instead send a narrower follow-up (`delegate session:`, which drops the question) or report back. A parent's `chi answer` follows `guardrails.parent_answers` (`hard`: Stop only; see [chi as a sub-agent](sub-agent.md#the-step-limit)).
269
+ - **A child at its cost cap** (the [cost-cap bundle](plugins.md#the-cost-cap-bundle), the child's own cap: `child:` in its settings, or `/cap`) asks a `hook` question, "Cost cap": a parent waiting on it (`delegate`, `delegate_result`) gets it back as `status: question` with the `chi answer` commands, `--option Continue` or `--option Stop` (under `guardrails.parent_answers: hard` a parent's Continue is refused: Stop only). A **background** child (`wait: false`) paused at its cap does not wake its parent: delegate reports skip hook questions (they are the user's, on the child's card) and the approval relay carries approvals only, so the child waits on its card, with the web's bell. The parent sees it at its next `delegate_result`.
270
270
  - **A child's approvals go to the parent's user** (the approval relay). While the parent waits (`delegate`, `delegate_result`), a guardrail approval the child opens reopens as the parent session's own approval: the same card in the parent's web page and attached terminal, with the same facts and diff, a line naming the delegate and its task (`delegate ab12 · fix the flaky spec…`, linked to its session) and the header `Approve delegate ab12's tool call?`. The options are the child's, by index; "for the session" says it is the delegate's session (the session scope keys on the child; repo and rule approvals are one shared store). The user answers once there; the parent's model never sees the approval, only one line per relayed approval in the tool's result, before the reply: `approval relayed to your user: execute: git push origin main → allowed once` (or `denied ("why")`, `dismissed (denied)`, `answered on the child`, `not delivered (the child's worker is gone)`, `refused by the child …`, `still open (your turn was stopped)`). The time the card is open doesn't count against `timeout`. While the parent waits on one child, its other running children's approvals are relayed too, oldest first, one card at a time (`+1 more delegate waiting`); each outcome comes in that child's next result.
271
271
  - The child's own card stays answerable (the first answer wins: answered there, the parent's card closes as "answered in ab12"). While relayed it says `Waiting for approval in parent ab12: answering here works too`; `chi sessions list` shows the child as `waiting (in parent ab12)` (`--format json`: `relayed_to`), and the web's bell rings for the parent's card, not the child's.
272
- - How the child trusts it: the parent's POST that says "answered" (Bridge `POST /session/<child>/relay`) carries no answer. The child asks its parent session's live Bridge (`POST /session/<parent>/relay/status`), which reports only what the parent's question flow took from a UI (`RelayDesk`, in memory only), and takes it only for this child, the question pending now, answered. A parent agent's answer on the relay card (a piped or marked `chi --attach PARENT`, `chi answer`) is held to `guardrails.parent_approvals` by the parent and again by the child.
272
+ - How the child trusts it: the parent's POST that says "answered" (Bridge `POST /session/<child>/relay`) carries no answer. The child asks its parent session's live Bridge (`POST /session/<parent>/relay/status`), which reports only what the parent's question flow took from a UI (`RelayDesk`, in memory only), and takes it only for this child, the question pending now, answered. A parent agent's answer on the relay card (a piped or marked `chi --attach PARENT`, `chi answer`) is held to `guardrails.parent_answers` by the parent and again by the child.
273
273
  - A Stop of the parent's turn closes its card; the child's approval stays open there. A child's worker that goes closes the card (`… worker is gone`); a parent's worker that goes clears the child's "waiting in parent" line within 2 s. A later `delegate_result` relays it again.
274
274
  - No relay where the parent can't host one (a `--no-shared` REPL, `--non-interactive`) or isn't waiting (`wait: false` until the next `delegate_result`): the approval waits on the child, with the web's bell, and the result's question block says to deny it or leave it for the user. The model's own questions and hooks' questions come back to the model as before. A grandchild's (a plugin's `ctx.sessions.fork` can make one) is relayed hop by hop: `delegate ab12 → cd34 asks:`.
275
275
  - **Delegate reports: a child's reply comes back by itself.** When a child ends a turn that was its parent's (its task, a follow-up, a continue the parent answered, a reminder: not a turn its user typed into it directly), it rings the parent: a small file in the parent's `children/` dir, written after the child's turn is saved. So does a question the parent's model decides (the child's own `ask_user_question`, the step-limit continue; not an approval or a hook's question, which the user answers on the child's card), the turn after such a question whoever starts it, and the child's worker crashing. The parent reads the child as `delegate_result` would and hands its model a **report**: the same text (`session:`, `status:`, the reply), under its own header (`[Delegate report, delivered by chi …; not your user's message. Tell your user what it found …]`), as an input message with `source: "delegate_report"`.
data/docs/sub-agent.md CHANGED
@@ -59,7 +59,7 @@ Exit codes, both commands:
59
59
  | 0 | `not_continued` | (`chi answer`) a Stop answered the step-limit question; the turn's work so far stays |
60
60
  | 3 | `question` | chi waits for an answer: `question`, `answer_with` |
61
61
  | 4 | `running` | `--timeout` passed; the turn goes on, wait again |
62
- | 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; `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; `hook`; `ctrl_c`; `manual`) and `stopped_by` (the hook that stopped it, e.g. `loop-guard`) when known |
62
+ | 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` turn waited for 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 |
63
63
  | 2 | (no JSON) | usage error, or an option the question doesn't offer |
64
64
  | 130 | `running` | Ctrl-C; the turn goes on |
65
65
 
@@ -98,10 +98,11 @@ question and starts a new turn. It can't be dismissed.
98
98
 
99
99
  A parent may Continue by default: Continue grants no permission (every tool
100
100
  call still meets the guardrails), it only spends time and tokens. With
101
- `turn.parent_continue: false` in chi's config.yml (config.yml only, no
101
+ `guardrails.parent_answers: hard` in chi's config.yml (config.yml only, no
102
102
  environment variable) a parent may only Stop: `chi answer` and the worker
103
- refuse a Continue (exit 1, `a parent agent may only stop this turn here`), and
104
- the user continues it from the web or the terminal.
103
+ refuse a Continue (exit 1, `a parent agent may only stop this turn here`), the
104
+ question block's command is the Stop, and the user continues it from the web or
105
+ the terminal. See [Guardrails](guardrails.md#answers-from-a-parent-agent).
105
106
 
106
107
  A chi parent's `delegate_result` gets a child's step-limit question back as a
107
108
  `question` with these commands, for its model to decide: continue it (the
@@ -122,23 +123,26 @@ sees only how each was answered. See
122
123
  parents (Claude Code, Codex, a script), and for a chi parent that can't host
123
124
  the relay (a `--no-shared` REPL, `--non-interactive`).
124
125
 
125
- An approval is a guardrail rule with `verdict: ask`: its user said "ask me". So
126
- a parent may deny it (`--option Deny --text "why"`, `--text` alone, or
127
- `--dismiss`) but not allow it: `chi answer` refuses an Allow with exit 1 and
128
- `allowing a tool call is up to the user: deny it (--option Deny --text WHY),
129
- and tell your user`. For an approval, the question block and `answer_with` give
130
- the deny command, not `--option N` or `chi --attach`. The block also offers to
131
- leave it open: tell your user it waits in chi web (the bell and badge show
132
- it there), and `chi send --wait --format json --turn <turn_id> ID` waits until
133
- they answer it. With
134
- `guardrails.parent_approvals: once` in chi's config.yml (it has no environment
135
- variable), "Allow once" goes through; the wider scopes never do,
136
- and neither does any allow on chi's own config, hooks or guardrail rules. See
137
- [Guardrails](guardrails.md#approvals-from-a-parent-agent).
126
+ An approval is a guardrail rule with `verdict: ask`: its user said "ask me". A
127
+ parent may always deny it (`--option Deny --text "why"`, `--text` alone, or
128
+ `--dismiss`). By default (`guardrails.parent_answers: soft` in chi's
129
+ config.yml, which has no environment variable) it may also give "Allow once";
130
+ the wider scopes never go, and neither does any allow on chi's own config,
131
+ hooks or guardrail rules: `chi answer` refuses those with exit 1 and a line
132
+ like `only Allow once (guardrails.parent_answers: soft) can be given here: deny
133
+ it (--option Deny --text WHY), and tell your user`. With `standard` or `hard`
134
+ no allow goes (`allowing a tool call is up to the user: …`). The question
135
+ block lists what the parent may give: the Allow once command when it may, and
136
+ the deny command (`answer_with` is the deny), never `chi --attach`. The block
137
+ also offers to leave it open: tell your user it waits in chi web (the bell and
138
+ badge show it there), and `chi send --wait --format json --turn <turn_id> ID`
139
+ waits until they answer it. A plugin's question names the options a parent may
140
+ pick when the strategy leaves some out (`allowed_options` in the JSON). See
141
+ [Guardrails](guardrails.md#answers-from-a-parent-agent).
138
142
 
139
143
  Start a child in the folder it should change (`chi send --new --dir PATH`):
140
144
  writes and mutating git outside its own repo ask (`write-outside-repo`,
141
- `git-outside-repo`), which a parent can only deny.
145
+ `git-outside-repo`), which a parent can at most allow once per call.
142
146
 
143
147
  An answer typed into `chi --attach` or the REPL counts as a parent's too when
144
148
  stdin isn't a terminal or an agent marker (`CLAUDECODE`, `AI_AGENT`,
@@ -208,12 +212,19 @@ Start a task: `chi send --new --wait --format json --timeout 500 -m "<task>"`
208
212
  JSON. If you won't answer, add `--dismiss` instead: chi does nothing it asked
209
213
  about, finishes with what it found, and the reply comes back (exit 0).
210
214
  - `question.kind: approval`: chi wants to run something its user's guardrails
211
- flagged, and allowing it is up to your user. Deny it (the `answer_with`
212
- command: `--option Deny --text "why"`), and tell your user what it wanted to
213
- run and why. Or leave it open: tell your user it waits in chi web (the
214
- session's short id), then `chi send --wait --format json --timeout 500 --turn <turn_id> <session_id>`
215
- waits until they answer it there. Never allow it yourself, through
216
- `chi answer`, a piped `chi --attach` or chi's web API.
215
+ flagged. What you may give follows chi's `guardrails.parent_answers`, and the
216
+ question says it: when its `allowed_options` (and the text block's `allow:`
217
+ line) list "Allow once" (`soft`, the default), you may allow that one call if
218
+ your task clearly needs it; never a wider scope (this session, a repo, a
219
+ rule), and never a call on chi's own config, which lists only Deny. Otherwise
220
+ (`standard`, `hard`, or anything wider) allowing it is up to your user: deny
221
+ it (the `answer_with` command: `--option Deny --text "why"`), and tell your
222
+ user what it wanted to run and why. Or leave it open: tell your user it waits
223
+ in chi web (the session's short id), then
224
+ `chi send --wait --format json --timeout 500 --turn <turn_id> <session_id>`
225
+ waits until they answer it there. Never give an answer the question doesn't
226
+ offer you by another route (a piped `chi --attach`, `chi send -m '/continue
227
+ yes'`, chi's web API).
217
228
  - `status: running` (exit 4): still working. Wait for that same turn again:
218
229
  `chi send --wait --format json --timeout 500 --turn <turn_id> <session_id>`.
219
230
  - `question.kind: continue`: chi ran out of steps before it answered. If it is
data/docs/testing.md CHANGED
@@ -47,7 +47,8 @@ Where a composer line goes is decided twice, in Ruby (`ComposerRoute`, both term
47
47
  (`composer.js#routeComposerLine`). `spec/fixtures/composer_routes.json` holds the cases both answer: rspec
48
48
  (`spec/composer_route_spec.rb`) and `npm test` (`spec/web/public/composer.test.js`) run every row. A change to
49
49
  either router adds its row there; a row where a UI differs on purpose (or a found divergence) carries a `web` or
50
- `repl` override and a `note`.
50
+ `repl` override and a `note`. The Ruby side runs each row with every core command (a new one fails its
51
+ `CORE_ROUTES` check until listed there); a row's names that core lacks stand for plugin commands.
51
52
 
52
53
  A spec that waits for something uses `wait_until(timeout:)`, or `wait_until!(what, timeout:)` where no check
53
54
  follows (stubs, helper threads: it raises "timed out after N s waiting for <what>") — never a loop with no deadline.
@@ -77,7 +78,11 @@ The kit's own specs, in `spec/samagotchi/testing/`, keep it and the bundles hone
77
78
  `Testing::Sessions` has `Plugin::Sessions`' methods and parameters, and each `ctx` helper lands in its recorder. A
78
79
  member added to the Host fails it until the kit has it.
79
80
  - `spec/samagotchi/testing/shipped_bundles_spec.rb`: each shipped bundle with a plugin or hooks registers the same
80
- commands, tools, hooks, inits, toolkits and sources loaded by the kit as installed and loaded by chi.
81
+ commands, tools, hooks, inits, toolkits and sources loaded by the kit as installed and loaded by chi
82
+ (`Testing::Install`).
83
+ - `spec/samagotchi/bundle_command/check_spec.rb`: `chi bundle check` passes every shipped bundle with no errors (one
84
+ example per bundle), so a shipped bundle that wouldn't install or load cleanly for a user fails here too. Run it
85
+ by hand on one with `ruby -Ilib bin/chi bundle check lib/samagotchi/bundles/NAME`.
81
86
  - `spec/samagotchi/testing/boundary_spec.rb`, the boundary lint: each bundle's Ruby (plugin, hooks, scripts, specs)
82
87
  reaches chi only through `Samagotchi::Testing::Boundary::ALLOWED` (the "Stable for bundles" list) and that
83
88
  bundle's `Boundary::EXCEPTIONS` entry, each with why. A new internal reference fails it, and so does an exception
@@ -6,8 +6,7 @@ require_relative "session_manager"
6
6
  require_relative "bridge_client"
7
7
  require_relative "reply_wait"
8
8
  require_relative "parent_report"
9
- require_relative "guardrails/parent_approvals"
10
- require_relative "guardrails/parent_continue"
9
+ require_relative "guardrails/parent_policy"
11
10
  require_relative "cli/command"
12
11
  require_relative "cli/flags"
13
12
  require_relative "cli/parent_wait"
@@ -19,10 +18,10 @@ module Samagotchi
19
18
  # how a parent agent running chi as a sub-agent answers; the web and
20
19
  # chi --attach answer the same question. See docs/sub-agent.md.
21
20
  #
22
- # An approval is the user's to allow: a parent may deny it, and allow it
23
- # only "once" when guardrails.parent_approvals (config.yml) says so. It is
24
- # checked here and again by the worker, which sees the answer marked as
25
- # chi answer's. That guards an honest but eager parent, not a boundary:
21
+ # What a parent may pick is Guardrails::ParentPolicy's: by each option's
22
+ # effect and this process's config.yml. It is checked here and again by
23
+ # the worker, which sees the answer marked as chi answer's. That guards
24
+ # an honest but eager parent, not a boundary:
26
25
  # the Bridge and the localhost web take answers from any local process.
27
26
  class AnswerCommand
28
27
  include CLI::Command
@@ -46,9 +45,11 @@ module Samagotchi
46
45
  about and finishes its reply
47
46
  --timeout S give up waiting after S seconds (exit 4)
48
47
  --format json one JSON object on stdout (see chi send --help)
49
- An approval (a guardrail's ask) can be denied here: deny it, and
50
- tell your user; allowing it is the user's. With
51
- guardrails.parent_approvals: once, "Allow once" is let through.
48
+ What a parent may pick follows guardrails.parent_answers in
49
+ chi's config.yml: soft (the default) lets "Allow once" and
50
+ Continue through, standard Continue but no allow, hard only a
51
+ deny, a Stop or a plain answer. A wider allow, and any allow on
52
+ chi's own config, is the user's: deny it, and tell your user.
52
53
  A step-limit question (kind continue): --option Continue [--text
53
54
  STEER] (the text joins the continued turn as a steer), or
54
55
  --option Stop [--text WHY] (exit 0, status not_continued); it
@@ -121,7 +122,7 @@ module Samagotchi
121
122
  return failure(not_dismissable(pending)) if options[:dismiss] && ParentReport.continue?(pending)
122
123
 
123
124
  selected = selection(pending, options) or return @selection_status
124
- refusal = approval_refusal(pending, selected, id) || continue_refusal(pending, selected)
125
+ refusal = parent_refusal(pending, selected)
125
126
  return failure(refusal) if refusal
126
127
 
127
128
  posted = post(id, qid, selected, options)
@@ -158,21 +159,16 @@ module Samagotchi
158
159
  labels.uniq
159
160
  end
160
161
 
161
- # Why a parent may not give this answer to an approval, or nil
162
- # (Guardrails::ParentApprovals, with this process's config.yml). A
163
- # Deny, text alone or a dismissal always passes: each one denies. The
164
- # worker checks it again with its own config.
165
- def approval_refusal(pending, selected, _id)
162
+ # Why a parent may not give this answer, or nil (Guardrails::ParentPolicy,
163
+ # with this process's config.yml and the question's own tags). A deny,
164
+ # text alone or a dismissal always passes. The worker checks it again
165
+ # with its own config.
166
+ def parent_refusal(pending, selected)
166
167
  offered = Array(pending.options).map(&:to_s)
167
168
  indices = selected.map { |label| offered.index(label) }
168
- reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: Guardrails::ParentApprovals.setting)
169
- reason && Guardrails::ParentApprovals.message(reason)
170
- end
171
-
172
- # Why a parent may not answer Continue (turn.parent_continue: false in
173
- # this process's config.yml), or nil; the worker checks it again.
174
- def continue_refusal(pending, selected)
175
- Guardrails::ParentContinue.refusal(pending, selected) && Guardrails::ParentContinue.message
169
+ strategy = Guardrails::ParentPolicy.current
170
+ reason = Guardrails::ParentPolicy.refusal(pending, indices, strategy: strategy)
171
+ reason && Guardrails::ParentPolicy.message(reason, pending: pending, strategy: strategy)
176
172
  end
177
173
 
178
174
  # A step-limit question can't be left unanswered: the turn waits on it.
@@ -206,8 +202,8 @@ module Samagotchi
206
202
 
207
203
  not_open(qid)
208
204
  when 400 then usage_failure(reply.json&.dig("detail") || "the worker refused the answer")
209
- # The worker's own guardrails.parent_approvals refused the allow.
210
- when 403 then failure(reply.json&.dig("detail") || "the worker refused the allow: deny it, and tell your user")
205
+ # The worker's own strategy refused the option (ParentPolicy).
206
+ when 403 then failure(reply.json&.dig("detail") || "the worker refused that option: deny it, and tell your user")
211
207
  when 408 then failure("the worker did not take the answer in time; the question is still open: try again")
212
208
  when 404
213
209
  return failure(worker_gone(id)) if reply.json&.dig("error") == "unknown_session"