samagotchi 0.12.0 → 0.13.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/README.md +20 -7
  4. data/bin/chi +5 -2
  5. data/docs/architecture.md +4 -2
  6. data/docs/cli.md +34 -10
  7. data/docs/configuration.md +29 -8
  8. data/docs/desktop.md +10 -6
  9. data/docs/guardrails.md +73 -7
  10. data/docs/hooks.md +13 -9
  11. data/docs/memory.md +5 -3
  12. data/docs/plugins.md +4 -4
  13. data/docs/sessions.md +9 -6
  14. data/docs/sub-agent.md +128 -0
  15. data/lib/samagotchi/answer_command.rb +220 -0
  16. data/lib/samagotchi/bridge.rb +11 -1
  17. data/lib/samagotchi/bridge_client.rb +8 -3
  18. data/lib/samagotchi/bundle_command.rb +18 -10
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
  22. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  23. data/lib/samagotchi/cli/command.rb +3 -2
  24. data/lib/samagotchi/cli/exit.rb +23 -0
  25. data/lib/samagotchi/cli/parent_wait.rb +82 -0
  26. data/lib/samagotchi/config.rb +50 -18
  27. data/lib/samagotchi/desktop_command.rb +5 -3
  28. data/lib/samagotchi/engine.rb +40 -14
  29. data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
  30. data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
  31. data/lib/samagotchi/host_registry.rb +0 -1
  32. data/lib/samagotchi/llm/openai_chat.rb +9 -1
  33. data/lib/samagotchi/memory_bundle/installer.rb +12 -0
  34. data/lib/samagotchi/memory_bundle/status.rb +9 -1
  35. data/lib/samagotchi/memory_paths.rb +8 -0
  36. data/lib/samagotchi/model_profile.rb +30 -16
  37. data/lib/samagotchi/parent_report.rb +213 -0
  38. data/lib/samagotchi/plugin/api.rb +15 -2
  39. data/lib/samagotchi/prompt_literal_guard.rb +4 -2
  40. data/lib/samagotchi/question_desk.rb +29 -2
  41. data/lib/samagotchi/recap_setup.rb +15 -5
  42. data/lib/samagotchi/reply_wait.rb +5 -1
  43. data/lib/samagotchi/send_command.rb +50 -75
  44. data/lib/samagotchi/session.rb +15 -0
  45. data/lib/samagotchi/session_manager.rb +34 -7
  46. data/lib/samagotchi/sessions_command.rb +13 -3
  47. data/lib/samagotchi/system_prompt.rb +10 -2
  48. data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
  49. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
  50. data/lib/samagotchi/terminal_ui.rb +15 -1
  51. data/lib/samagotchi/thought_stream_splitter.rb +4 -4
  52. data/lib/samagotchi/tool_call_parser.rb +5 -6
  53. data/lib/samagotchi/tools/builtin_calls.rb +14 -3
  54. data/lib/samagotchi/tools/builtins.rb +14 -2
  55. data/lib/samagotchi/tools/delegate_wait.rb +16 -4
  56. data/lib/samagotchi/tools/execute.rb +8 -8
  57. data/lib/samagotchi/tools/task_create.rb +3 -2
  58. data/lib/samagotchi/tools/task_runtime.rb +7 -2
  59. data/lib/samagotchi/version.rb +1 -1
  60. data/lib/samagotchi/web/app.rb +4 -3
  61. data/lib/samagotchi/web/message_parts.rb +2 -1
  62. data/lib/samagotchi/web/server.rb +2 -1
  63. data/lib/samagotchi/web/session_summary.rb +3 -11
  64. metadata +7 -1
data/docs/hooks.md CHANGED
@@ -9,7 +9,8 @@ hooks. See [Plugins](plugins.md).
9
9
 
10
10
  ## Configuration
11
11
 
12
- Add a `hooks:` section to your global config file (`~/.config/samagotchi/config.yml`):
12
+ Add a `hooks:` section to your global config file (`$XDG_CONFIG_HOME/samagotchi/config.yml`, `$XDG_CONFIG_HOME`
13
+ defaulting to `~/.config`):
13
14
 
14
15
  ```yaml
15
16
  hooks:
@@ -48,15 +49,15 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
48
49
 
49
50
  | Event | When it fires | Event payload |
50
51
  |-------|--------------|---------------|
51
- | `:session_start` | First turn of the session | `{ type: :session_start, session_id: "..." }` |
52
+ | `:session_start` | The first turn this chi process runs for the session (so again after `--resume`, a worker that idle-exited and woke, or a restart) | `{ type: :session_start, session_id: "..." }` |
52
53
  | `:before_turn` | Before each turn starts | `{ type: :before_turn, session_id: "...", prompt: "..." (nil on a continue), messages: [...] (the history before this turn) }` |
53
- | `:after_turn` | After a turn completed or was cancelled (not after one that failed) | `{ type: :after_turn, status: "completed" \| "canceled", present: (see [Presenting the answer](#presenting-the-answer-display-only)), messages: [...] (the conversation the turn stored; a cancelled or empty turn ends it with a `kind: turn_note` system message, and a context line is `kind: context`, see [sessions.md](sessions.md#notes-a-turn-leaves-for-the-model)) }` |
54
+ | `:after_turn` | After a turn completed or was stopped once the model was asked (not after one that failed, was cut short by an interrupt signal (SIGINT), or was stopped before its `:before_turn` hooks) | `{ type: :after_turn, status: "completed" \| "canceled", present: (see [Presenting the answer](#presenting-the-answer-display-only)), messages: [...] (the conversation the turn stored; a cancelled or empty turn ends it with a `kind: turn_note` system message, and a context line is `kind: context`, see [sessions.md](sessions.md#notes-a-turn-leaves-for-the-model)) }` |
54
55
  | `:before_generation` | Before each LLM API call (both loops) | `{ type: :before_generation, iteration: N }` |
55
56
  | `:after_generation` | After LLM returns (both loops); not after a generation a hook cut (`stop_generation`) | `{ type: :after_generation, iteration: N, response: "...", messages: [...] (the conversation as sent) }` |
56
57
  | `:generation_progress` | While the response streams, in batches (see [Watching the stream](#watching-the-stream)) | `{ type: :generation_progress, iteration: N, thinking: "..." (new since the last fire), text: "..." (new visible text), thinking_chars: N, text_chars: N (this generation so far), elapsed_ms: N }` |
57
58
  | `:before_tool_call` | Before tool dispatch (and before `tool_call_started`) | `{ type: :before_tool_call, iteration: N, call: {...}, params: "...", guardrail: Verdict, context: {...}, targets: {...}, blocked: false, block_reason: nil }` |
58
59
  | `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (capped at max_tool_output_chars), status: "ok" \| "error" \| "blocked" \| "stopped" }` |
59
- | `:session_end` | After every turn (turn-level lifecycle) | `{ type: :session_end, session_id: "..." }` |
60
+ | `:session_end` | After each turn `:after_turn` fires for, after it (turn-level lifecycle) | `{ type: :session_end, session_id: "..." }` |
60
61
 
61
62
  Every event also carries the hook runtime (next section): `hook:` (the label
62
63
  of the hook about to run) and the callables `notify:`, `ask_user:`,
@@ -268,7 +269,7 @@ You can also register hooks programmatically during a turn (they are cleared
268
269
  automatically after each `run_turn`):
269
270
 
270
271
  ```ruby
271
- engine = Samagotchi::Engine.new(mode: :assist)
272
+ engine = Samagotchi::Engine.new
272
273
  engine.register_hook(:before_turn) do |event|
273
274
  puts "Turn starting..."
274
275
  end
@@ -404,7 +405,8 @@ stripped of surrounding whitespace, except file text, an edit's
404
405
 
405
406
  A `write`'s `content:` is the file text; an `edit` carries `old_text:` and
406
407
  `new_text:` (its `content:` is `""`). A plugin or MCP tool's call has its
407
- arguments whole on `args:` (string keys) instead.
408
+ arguments whole on `args:` (string keys) instead, and its `content:` is those
409
+ arguments as JSON.
408
410
 
409
411
  ## Bundle Hooks (unified workflow bundle)
410
412
 
@@ -439,6 +441,7 @@ hooks:
439
441
  on_error: log
440
442
  priority: 100
441
443
  trust_level: reviewed # reviewed | experimental (default)
444
+ requires_chi: ">= 0.12.0" # optional: a gem-style requirement, as for plugins
442
445
  needs: [gh] # optional: outside commands the memories use (docs/memory.md#bundles-that-need-outside-commands)
443
446
  ```
444
447
 
@@ -449,7 +452,8 @@ Notes:
449
452
  - `sha256` is integrity (not authenticity). No signing in v1. Install records the sha256 of the copied file; at `Engine.new` a hook whose file differs is not loaded (reinstall the bundle after editing one by hand).
450
453
  - Hook code is the bundle author's source of truth: on upgrade, hooks are overwritten; if the installed file was locally modified, a warning is emitted (`was locally modified; overwriting`).
451
454
  - `on_error` defaults to `skip` for every event, `:before_tool_call` included: a raising hook is silent and the call goes ahead. `log` warns. `fail_closed` (only on `:before_tool_call`; on any other event it acts as `skip`) denies the call the hook raised on. Choose `fail_closed` for a hook that is a guardrail, whose failure should stop tool calls rather than let them through.
452
- - A `fail_closed` `:before_tool_call` hook is required: if it is missing, fails to load or its sha256 differs, chi denies every tool call until it is fixed.
455
+ - `requires_chi`: when this chi doesn't meet it, none of the bundle's hooks load (each is reported, `not loaded: it requires chi …`), as for a [plugin](plugins.md#loading-and-when-it-fails).
456
+ - A `fail_closed` `:before_tool_call` hook is required: if it is missing, fails to load, its sha256 differs or chi doesn't meet the bundle's `requires_chi`, chi denies every tool call until it is fixed.
453
457
  - A bundle can also ship YAML rules in `guardrails/*.yml`; see [Guardrails](guardrails.md#the-guardrails-bundle).
454
458
  - Ordering: bundle hooks fire by `(priority, bundle_name, hook_name)` (lower priority first), then plain `config.yml` hooks in registration order.
455
459
  - Settings: a hook class with `initialize(settings = {})` gets the bundle's section of `config.yml` `bundles:` (see [Settings](#settings)).
@@ -459,8 +463,8 @@ Notes:
459
463
 
460
464
  Lifecycle:
461
465
 
462
- - `chi bundle install <source>` copies `hooks/*.rb` to `~/.config/samagotchi/memories/.bundles/<name>/hooks/` and persists metadata + `trust_level` + `source_commit` (git HEAD) to provenance.
463
- - `Engine.new` loads `config.yml` hooks first, then bundle hooks via `Provenance.each_installed_holding_hooks` → `Hooks::BundleLoader.load`. Bundle hooks are process-scoped (they survive the per-turn `clear_hooks`; only plain hooks are cleared). Experimental bundles emit a one-line startup warning.
466
+ - `chi bundle install <source>` copies the hook files its manifest's `hooks:` map lists (only those; with no `hooks:` map, every `hooks/*.rb`) to `$XDG_CONFIG_HOME/samagotchi/memories/.bundles/<name>/hooks/` (`$XDG_CONFIG_HOME` defaults to `~/.config`) and persists metadata + `trust_level` + `source_commit` (git HEAD) to provenance.
467
+ - `Engine.new` loads `config.yml` hooks first, then bundle hooks via `MemoryBundle::Provenance.each_installed(holding: :hooks)` → `Hooks::BundleLoader.load`. Bundle hooks are process-scoped (they survive the per-turn `clear_hooks`; only plain hooks are cleared). Experimental bundles emit a one-line startup warning.
464
468
  - `chi bundle status`, `diff`, `uninstall`, `build` are hook-aware (counts, metadata, removal).
465
469
 
466
470
  ## The source-links bundle
data/docs/memory.md CHANGED
@@ -2,10 +2,12 @@
2
2
 
3
3
  Samagotchi stores memories in two scopes:
4
4
 
5
- - Project scope: `~/.config/samagotchi/memories/projects/<name>_<hash>/`, one folder per
5
+ - Project scope: `$XDG_CONFIG_HOME/samagotchi/memories/projects/<name>_<hash>/`, one folder per
6
6
  git repository (named and hashed by its root), shared by all its worktrees and
7
7
  subdirectories; outside a repository, one per working directory
8
- - System scope: `~/.config/samagotchi/memories`
8
+ - System scope: `$XDG_CONFIG_HOME/samagotchi/memories`
9
+
10
+ `$XDG_CONFIG_HOME` falls back to `~/.config` when unset.
9
11
 
10
12
  Tool behavior:
11
13
 
@@ -19,7 +21,7 @@ Tool behavior:
19
21
 
20
22
  Each memory entry may have a companion file named `<name>.<model-key>.md` in the same scope directory. When the entry is read under a matching model, the overlay body is appended automatically, separated by the standard `---` separator with a `Model-specific guidance (<key>):` header.
21
23
 
22
- - **Key derivation**: The harness normalizes the full model name (lowercase, replace non-alphanumeric with `-`, squeeze dashes) to derive the file key. For example, `qwen3.6-35b-a3b` → `qwen3-6-35b-a3b`.
24
+ - **Key derivation**: The harness normalizes the model name sent to the server, without its host prefix (`box:qwen3.6-35b-a3b` keys as `qwen3.6-35b-a3b`; an alias keys as its target, and the alias's own key is read when the target has no overlay) (lowercase, replace non-alphanumeric with `-`, squeeze dashes) to derive the file key. For example, `qwen3.6-35b-a3b` → `qwen3-6-35b-a3b`.
23
25
  - **Saving overlays**: Pass `current_model_only: true` to `memory_write` (the harness resolves the model key automatically). This writes the content as `<name>.<model-key>.md` and skips index maintenance.
24
26
  - **Dormancy**: Overlays are only active under the matching model key; other models see the base entry only.
25
27
  - **Invariant**: The base entry is the contract. Overlays only add model-specific guidance and never contradict the base protocol.
data/docs/plugins.md CHANGED
@@ -24,7 +24,7 @@ version: 1.0.0
24
24
  plugin:
25
25
  file: plugin.rb
26
26
  sha256: sha256:6a1a7022… # shasum -a 256 plugin.rb
27
- requires_chi: ">= 0.1.28" # optional: a gem-style requirement (">= 0.1.28, < 0.2")
27
+ requires_chi: ">= 0.1.28" # optional: a gem-style requirement (">= 0.1.28, < 0.2"); the bundle's hooks/ check it too
28
28
  needs: [gh] # optional: outside commands it relies on (see memory.md#bundles-that-need-outside-commands)
29
29
  ```
30
30
 
@@ -73,7 +73,7 @@ Settings work as they do for hooks ([hooks.md](hooks.md#settings)). An
73
73
 
74
74
  ### `chi.command(name, description, anytime: false) { |args, ctx| … }`
75
75
 
76
- This adds a slash command. `name` is `/name` (a–z, 0–9, `_` and `-`).
76
+ This adds a slash command. `name` is `/name`: a lowercase letter first, then a–z, 0–9, `_` and `-`, at most 32 characters after the `/`.
77
77
  `args` is the text after the name, stripped, or `""` when there is none. The
78
78
  block returns the text to show (a String), or nil to show nothing. If the
79
79
  block raises, the user sees `/name: <error>`.
@@ -638,8 +638,8 @@ only against this API (`lib/samagotchi/bundles/btw/plugin.rb`).
638
638
  0.1.0 shipped `btw.md` as one, and `chi bundle upgrade btw` leaves that file
639
639
  (and its index line) behind. Drop it with `chi bundle uninstall btw`, then
640
640
  `chi bundle install btw`. After an upgrade already ran, delete
641
- `~/.config/samagotchi/memories/btw.md` and its `**btw**` line in `index.md`
642
- there by hand.
641
+ `$XDG_CONFIG_HOME/samagotchi/memories/btw.md` (`$XDG_CONFIG_HOME` defaults to
642
+ `~/.config`) and its `**btw**` line in `index.md` there by hand.
643
643
 
644
644
  ## The mcp bundle
645
645
 
data/docs/sessions.md CHANGED
@@ -25,7 +25,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
25
25
 
26
26
  <a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered; a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn refuses it), and an empty session is discarded instead; `chi scratch` refuses it. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and "include archived" by the all-sessions search shows them, dimmed with an `archived` badge. The info bar reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
27
27
 
28
- **Lazy sweep:** automatic prune runs at most once per 24h on `GET /api/sessions` (Web). No background thread or cron. Manual prune is always available.
28
+ **Lazy sweep:** automatic prune runs only while `chi web` runs, at most once per `session.sweep_interval_hours` (default 24): the web's session hub gives it a chance on its periodic full scan, and `GET /api/sessions` does too. No cron, and a terminal-only chi never sweeps. Manual prune is always available.
29
29
 
30
30
  **CLI:**
31
31
 
@@ -40,6 +40,8 @@ chi sessions prune [--dry-run] [--days N] [--keep N] [--keep-status running,...]
40
40
  chi sessions clean [--dry-run] [--days N] # test sessions: all, or older than N days
41
41
  ```
42
42
 
43
+ A session whose worker waits for an answer (a question, a hook's question or a guardrail approval) reads `waiting` in the status column, and json has `waiting: "question"|"approval"|"hook"` and `waiting_id`, the question's id for `chi answer --question` (both null otherwise). Only while a worker runs it: a question a dead worker left in the file shows the saved status. Answer it with `chi answer`, the web or `chi --attach ID`; `chi sessions stop ID` frees the worker instead.
44
+
43
45
  Examples:
44
46
 
45
47
  ```sh
@@ -146,7 +148,7 @@ pbpaste | chi send 3fa2 # the clipboard is the messa
146
148
  - It goes through the same path as the web composer (`SessionManager.deliver_turn`): the worker's Bridge when it is up, so every attached UI shows it as your message (client id `cli:send`, shown like any user message); the input file when the worker is on its way out. During a running turn it is merged into that turn at the next step, like a message typed then.
147
149
  - A session with no worker gets one started (like `--attach` or the web composer). A session open in a plain REPL (`--no-shared`) refuses it. Only sessions on this machine: Bridges listen on 127.0.0.1.
148
150
  - Fire and forget: it returns once the message is queued and never prints the answer; that shows in whatever is attached. A guardrail "ask" waits for a UI to answer it, so with nothing attached the turn stalls there until one attaches (`chi --attach ID`).
149
- - `--image PATH` (repeatable, up to 20) sends images with the message, as the web composer's chips do. Each file is read once before anything is sent (bmp, tiff and heic converted to png, large ones downscaled, as for `@path`); a missing file or one that isn't an image is a usage error (exit 2) and nothing is sent. Each session gets its own copy in `<session>/images/`. Text is still required (`-m` or stdin; context alone counts), also with `--wait`. The session's model must see images: one known not to (the host's model list says text-only, `vision: false` in `models:` or `hosts:`, a native llama.cpp without a vision model) is refused before anything is sent, with the reason (`<id> refused: gemma can't take images (…); send text only or switch the model (/model)`); the other sessions of the same send still get it, and the exit is 1. With `--new` nothing is started. When chi can't tell (a native host whose `/props` doesn't answer, a list that doesn't say), the message is sent as before and a text-only model fails the turn in the session. A running turn doesn't take images mid-turn: the message runs as the next turn, and the line says `(runs after the current turn)`. With `--new` the session starts idle with the message as its preview, then the message goes in as its first turn once its worker is up (`<id> started with 1 image`); if the worker doesn't come up in 5 s the session is kept, with its id on the `failed:` line.
151
+ - `--image PATH` (repeatable, up to 20) sends images with the message, as the web composer's chips do. Each file is read once before anything is sent (bmp, tiff and heic converted to png, large ones downscaled, as for `@path`); a missing file or one that isn't an image stops the send (exit 1; with `--wait --format json`, an `error` line) and nothing is sent; more than 20 is a usage error (exit 2). Each session gets its own copy in `<session>/images/`. Text is still required (`-m` or stdin; context alone counts), also with `--wait`. The session's model must see images: one known not to (the host's model list says text-only, `vision: false` in `models:` or `hosts:`, a native llama.cpp without a vision model) is refused before anything is sent, with the reason (`<id> refused: gemma can't take images (…); send text only or switch the model (/model)`); the other sessions of the same send still get it, and the exit is 1. With `--new` nothing is started. When chi can't tell (a native host whose `/props` doesn't answer, a list that doesn't say), the message is sent as before and a text-only model fails the turn in the session. A running turn doesn't take images mid-turn: the message runs as the next turn, and the line says `(runs after the current turn)`. With `--new` the session starts idle with the message as its preview, then the message goes in as its first turn once its worker is up (`<id> started with 1 image`); if the worker doesn't come up in 5 s the session is kept, with its id on the `failed:` line.
150
152
  - One line per session: `sent`, `sent with 2 images`, `sent (the running turn picks it up)`, `sent (started its worker)`, `refused: …` or `failed: …`. Exit 0 when all were sent, 1 when any was refused, failed or not found, 2 for a usage error. There is no `--all`.
151
153
 
152
154
  The Automator action above works for messages too: swap its last line for `pbpaste | "$chi" send -m "what do you make of this?" $(print -r -- "$picked" | cut -f1)`.
@@ -166,10 +168,11 @@ chi send --wait 3fa2 # sends nothing: waits fo
166
168
  - `--new` takes no ids (one new session per call). `--dir DIR` is its folder, and so its project (default the current one); `--model M` its model (default the configured one; the name isn't checked up front, a wrong one fails the worker's first turn).
167
169
  - `--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.
168
170
  - `--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 run after it, and its reply would come back as the answer.
169
- - Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question or a guardrail approval), with the line `waiting for an answer: …; open it: chi --attach ID or the web`, and the session keeps waiting; 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, the session was stopped, or `--timeout S` passed; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
170
- - `--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. A running session is fine (that is the point), so after exit 3 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).
171
+ - Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question or a guardrail approval), 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.
172
+ - `--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`, `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1). A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
173
+ - `--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. 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).
171
174
  - The 16 KiB cap applies: `git diff | chi send --new …` on a big diff is refused; name the branch in the message instead and let the session read it.
172
- - A session nobody attaches to stalls at its first guardrail ask until someone opens it; left alone it idle-exits after `session.idle_exit_minutes` like any worker. These are ordinary sessions: delete them like any other.
175
+ - A session nobody attaches to stalls at its first question or guardrail ask until someone answers it (`chi answer`, the web, `chi --attach`), and its worker stays up meanwhile: it doesn't idle-exit while a question waits (`chi sessions list` shows it as `waiting`; `chi sessions stop ID` frees it). Otherwise it idle-exits after `session.idle_exit_minutes` like any worker. These are ordinary sessions: delete them like any other.
173
176
 
174
177
  ## Delegating
175
178
 
@@ -177,7 +180,7 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
177
180
 
178
181
  - `delegate(task, model:, session:, wait:, timeout:)`: starts a child and, by default, waits for its reply (`wait: false` returns at once, so several children can run side by side; `timeout`, default 600 s, leaves a slow child running; 0 checks once without waiting). With `session:` (a child's id or prefix) the task goes to that child as a follow-up instead, delivered like `chi send` (client id `delegate:<parent short id>`, shown as `delegate>` in the child's UIs); only this session's children take one.
179
182
  - `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.
180
- - What a wait returns, `session: <id>` then `status:` and the text: `done` with the reply after a `---` line; `waiting_for_answer` as soon as the child opens a question or a guardrail approval (nothing is answered for it: attach or use the web, then wait again); `canceled` when the parent's own turn is canceled (the child keeps running; the next turn can wait again); `no_reply` when the child's turn ended with nothing to say (canceled, failed or empty: its session shows what happened); `error` (its worker crashed) or `stopped` (`chi sessions stop`) at once; `running` on timeout. A reply saved before the child's worker idle-exited is returned at once.
183
+ - What a wait returns, `session: <id>` then `status:` and the text: `done` with the reply after a `---` line; `waiting_for_answer` as soon as the child opens a question or a guardrail approval, 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); `canceled` when the parent's own turn is canceled (the child keeps running; the next turn can wait again); `no_reply` when the child's turn ended with nothing to say (canceled, failed or empty: 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. A reply saved before the child's worker idle-exited is returned at once.
181
184
  - Every child starts with the shipped `delegated` system memory preloaded (`preloaded_memory_names: ["system/delegated"]`; `--mute delegated` would hide it): put everything in the final reply, don't delegate further, don't write memories or change config, stay in the folder. Its system prompt also says `Delegated by session <parent id>`. The parent's own `--mute` list does not carry over.
182
185
  - Limits: a child cannot `delegate` (depth 1; the tool refuses), and one session may have at most `session.max_children` (default `4`, env `SAMAGOTCHI_SESSION_MAX_CHILDREN`) children running at a time, counted from the session files (a finished or idle-exited child does not count; one whose worker is still starting does, for 15 s). A refused call creates no session. Nothing stops children with the parent: `chi sessions stop ID` does.
183
186
  - The link is the session's `parent_id`, written before the worker starts, so a respawn keeps it; `GET /api/sessions` and `/api/sessions/:id` carry it, and `SessionManager.children_of` lists a parent's children.
data/docs/sub-agent.md ADDED
@@ -0,0 +1,128 @@
1
+ # chi as a sub-agent
2
+
3
+ Another agent (Claude Code, Codex, a script) can hand a task to chi and get the
4
+ answer back, the way you would from a terminal: `chi send --new --wait` starts a
5
+ session in a background worker and prints its reply. The session is an
6
+ ordinary one, so you (the human) can watch it live in `chi web` or join it with
7
+ `chi --attach ID`.
8
+
9
+ When chi's model asks a question (`ask_user_question`), a hook asks one, or a
10
+ guardrail wants an approval, chi stops and hands the question up: the wait ends
11
+ with exit 3 and the question, its options and the command that answers it. The
12
+ worker keeps the turn paused. The parent answers with `chi answer`, from what it
13
+ knows or after asking its own user, and chi goes on from there. The model gets
14
+ the answer as its tool result, as if you had answered in the web.
15
+
16
+ ## The commands
17
+
18
+ ```sh
19
+ chi send --new --wait --format json [--dir D] [--model M] [--timeout S] -m "task"
20
+ chi send --wait --format json [--timeout S] [-m "follow-up"] ID
21
+ chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--format json]
22
+ chi answer ID --question QID --dismiss [--timeout S] [--format json]
23
+ ```
24
+
25
+ `chi answer` waits after answering, the same way `chi send --wait` does, so the
26
+ next question comes back as exit 3 again. `--option` is 1-based or the label,
27
+ repeated on a multi-select question. `--text` is free text (where the question
28
+ allows it) or the reason for a Deny. `--dismiss` leaves the question
29
+ unanswered: the model doesn't do what it asked about and finishes its reply.
30
+
31
+ Exit codes, both commands:
32
+
33
+ | Exit | `status` | Meaning |
34
+ |---|---|---|
35
+ | 0 | `answered` | `text` is chi's reply |
36
+ | 3 | `question` | chi waits for an answer: `question`, `answer_with` |
37
+ | 4 | `running` | `--timeout` passed; the turn goes on, wait again |
38
+ | 1 | `failed`, `canceled`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened |
39
+ | 2 | (no JSON) | usage error, or an option the question doesn't offer |
40
+ | 130 | `running` | Ctrl-C; the turn goes on |
41
+
42
+ With `--format json` stdout is one JSON object (see
43
+ [Starting a session](sessions.md#starting-a-session) for every field):
44
+
45
+ ```json
46
+ {"status":"question","session_id":"297da360-…","question":{"id":"3f1c…","kind":"question",
47
+ "text":"Which file should I read?","options":["README.md","NOTES.md"],"multi_select":false,
48
+ "allow_freeform":false},"answer_with":"chi answer 297da360-… --question 3f1c… --option N"}
49
+ ```
50
+
51
+ `kind` is `question` (the model's), `hook`, or `approval`, which adds the tool,
52
+ command, folder, rule and reason.
53
+
54
+ ## Approvals
55
+
56
+ An approval is a guardrail rule with `verdict: ask`: its user said "ask me". So
57
+ a parent may deny it (`--option Deny --text "why"`, `--text` alone, or
58
+ `--dismiss`) but not allow it: `chi answer` refuses an Allow with exit 1 and
59
+ `allowing a tool call is up to the user: deny it (--option Deny --text WHY),
60
+ and tell your user`. For an approval, the question block and `answer_with` give
61
+ the deny command, not `--option N` or `chi --attach`. With
62
+ `guardrails.parent_approvals: once` in chi's config.yml (it has no environment
63
+ variable), "Allow once" goes through; the wider scopes never do,
64
+ and neither does any allow on chi's own config, hooks or guardrail rules. See
65
+ [Guardrails](guardrails.md#approvals-from-a-parent-agent).
66
+
67
+ An answer typed into `chi --attach` or the REPL counts as a parent's too when
68
+ stdin isn't a terminal or an agent marker (`CLAUDECODE`, `AI_AGENT`,
69
+ `SAMAGOTCHI_PARENT_SESSION`) is set.
70
+
71
+ The worker checks it again with its own config.yml. The guardrails settings
72
+ have no environment variables, and a worker drops any `SAMAGOTCHI_GUARDRAILS_*`
73
+ it inherits; but a parent that sets `XDG_CONFIG_HOME` picks which config.yml a
74
+ worker it starts or wakes reads. Still, this guards a parent that follows its instructions,
75
+ not a security boundary: the worker's Bridge and `chi web` on localhost take
76
+ answers from any local process.
77
+
78
+ ## Things to know
79
+
80
+ - A question waits for as long as it takes: the worker doesn't idle-exit while
81
+ one is open. `chi sessions list` shows such sessions as `waiting`
82
+ (`--format json`: `waiting_id` is the question's id);
83
+ `chi sessions stop ID` ends one.
84
+ - If the worker dies while a question waits, the question goes with it.
85
+ `chi answer` then exits 1 and says to send the task again
86
+ (`chi send --wait -m "…" ID`); a wait ends with `worker_gone` (exit 1).
87
+ - A follow-up (`chi send --wait -m "…" ID`) to a session waiting for an answer
88
+ isn't sent: it exits 1 with the `chi answer` command for the question.
89
+ - An answer to a question that is no longer open (you answered it in the web
90
+ first) isn't sent; `chi answer` says so and waits for what comes next.
91
+ - `chi send --wait` and `chi answer` don't read stdin unless it's a pipe or a
92
+ file. Agent shells usually pass `/dev/null`, which is fine.
93
+
94
+ ## Instructions for the parent agent
95
+
96
+ Paste this into the parent's `CLAUDE.md` or `AGENTS.md`:
97
+
98
+ ```markdown
99
+ ## Using chi as a sub-agent
100
+
101
+ Start a task: `chi send --new --wait --format json --timeout 500 -m "<task>"`
102
+ (add `--dir <project>`). Read the JSON on stdout:
103
+
104
+ - `status: answered` (exit 0): `text` is chi's reply.
105
+ - `status: question` (exit 3): chi is paused, waiting for an answer. If the answer
106
+ is clear from your task and context, answer it yourself. Otherwise ask your
107
+ user, showing them the question and its options; if you can't ask (no user in
108
+ this run), stop and report the question and options instead of guessing. To
109
+ answer, run the `answer_with` command with `--option N` (1-based, repeat for
110
+ multi-select) and/or `--text "…"`. It returns the same JSON. If you won't
111
+ answer, add `--dismiss` instead: chi does nothing it asked about, finishes
112
+ with what it found, and the reply comes back (exit 0).
113
+ - `question.kind: approval`: chi wants to run something its user's guardrails
114
+ flagged, and allowing it is up to your user. Deny it (the `answer_with`
115
+ command: `--option Deny --text "why"`), and tell your user what it wanted to
116
+ run and why. Never allow it yourself, through `chi answer`, a piped
117
+ `chi --attach` or chi's web API.
118
+ - `status: running` (exit 4): still working. Wait again:
119
+ `chi send --wait --format json --timeout 500 <session_id>`.
120
+ - Anything else (exit 1): report `detail` to your user. `chi --attach <session_id>`
121
+ shows the session.
122
+
123
+ Run each chi command on its own, nothing chained after it: the exit code comes
124
+ back with the output. Talk to chi only through these commands; never call its
125
+ Bridge or web API directly. Follow-ups in the same session:
126
+ `chi send --wait --format json --timeout 500 -m "…" <session_id>`.
127
+ Keep `--timeout` under your shell tool's limit (Claude Code: 600 s per call).
128
+ ```
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session"
4
+ require_relative "session_manager"
5
+ require_relative "bridge_client"
6
+ require_relative "reply_wait"
7
+ require_relative "parent_report"
8
+ require_relative "guardrails/parent_approvals"
9
+ require_relative "cli/command"
10
+ require_relative "cli/flags"
11
+ require_relative "cli/parent_wait"
12
+
13
+ module Samagotchi
14
+ # `chi answer`: answer the question a session's worker waits on (exit 3
15
+ # from chi send --wait), then wait for what comes next and print it the
16
+ # same way: the reply (0), the next question (3), still running (4). It is
17
+ # how a parent agent running chi as a sub-agent answers; the web and
18
+ # chi --attach answer the same question. See docs/sub-agent.md.
19
+ #
20
+ # An approval is the user's to allow: a parent may deny it, and allow it
21
+ # only "once" when guardrails.parent_approvals (config.yml) says so. It is
22
+ # checked here and again by the worker, which sees the answer marked as
23
+ # chi answer's. That guards an honest but eager parent, not a boundary:
24
+ # the Bridge and the localhost web take answers from any local process.
25
+ class AnswerCommand
26
+ include CLI::Command
27
+ include CLI::ParentWait
28
+
29
+ USAGE = <<~TEXT
30
+ Usage: chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--format json]
31
+ chi answer ID --question QID --dismiss [--timeout S] [--format json]
32
+ Answers the question session ID waits on (chi send --wait exits 3
33
+ with it), then waits for what comes next and prints it as chi send
34
+ --wait does.
35
+ --question QID the question's id (chi send --wait prints it); a
36
+ question no longer open is not answered
37
+ --option N|LABEL
38
+ an option, by number (1-based) or label; repeat
39
+ on a multi-select question
40
+ --text T free text, where the question allows it; with an
41
+ approval, the reason for a Deny (text alone denies)
42
+ --dismiss leave it unanswered: chi does nothing it asked
43
+ about and finishes its reply
44
+ --timeout S give up waiting after S seconds (exit 4)
45
+ --format json one JSON object on stdout (see chi send --help)
46
+ An approval (a guardrail's ask) can be denied here: deny it, and
47
+ tell your user; allowing it is the user's. With
48
+ guardrails.parent_approvals: once, "Allow once" is let through.
49
+ Exit: 0 answered, 1 failed, refused or the worker is gone, 2
50
+ usage or an option the question doesn't offer, 3 a question waits,
51
+ 4 still running (--timeout), 130 Ctrl-C.
52
+ TEXT
53
+
54
+ FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
55
+ f.value "--question"
56
+ f.value "--option", key: :options, repeat: true
57
+ f.value "--text"
58
+ f.switch "--dismiss"
59
+ f.value "--timeout"
60
+ f.value "--format"
61
+ end
62
+
63
+ def initialize(argv, stdout: $stdout, stderr: $stderr, state_dir: nil)
64
+ @argv = argv.dup
65
+ @stdout = stdout
66
+ @stderr = stderr
67
+ @state_dir = state_dir || Session.default_state_dir
68
+ end
69
+
70
+ private
71
+
72
+ def command_name = "chi answer"
73
+
74
+ def parse
75
+ parsed = parse_flags(FLAGS, @argv, options: [])
76
+ return parsed if parsed.is_a?(Integer)
77
+
78
+ options = parsed.options
79
+ return usage_error("give one session id") unless parsed.args.size == 1
80
+ return usage_error("--question QID is required (chi send --wait prints it)") if options[:question].to_s.strip.empty?
81
+ if options[:dismiss]
82
+ return usage_error("--dismiss takes no --option or --text") if !options[:options].empty? || options[:text]
83
+ elsif options[:options].empty? && options[:text].nil?
84
+ return usage_error("nothing to answer with: --option, --text or --dismiss")
85
+ end
86
+ return usage_error("--format takes text or json") if options[:format] && !FORMATS.include?(options[:format])
87
+ if options[:timeout]
88
+ options[:timeout] = Float(options[:timeout], exception: false)
89
+ return usage_error("--timeout takes seconds") unless options[:timeout]&.positive?
90
+ end
91
+ options.merge(id: parsed.args.first)
92
+ end
93
+
94
+ # Answer, then wait for what comes next.
95
+ # @return [Integer] the exit status
96
+ def run_parsed(options)
97
+ id = resolve(options[:id]) or return 1
98
+ qid = options[:question].strip
99
+ session = Session.load(id, state_dir: @state_dir)
100
+ pending = session.pending_question
101
+ # Taken before the answer goes in, with the question answered as the
102
+ # one already reported: the file keeps it until the turn thread
103
+ # wakes, and it must not come back as exit 3.
104
+ cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
105
+ baseline = ReplyWait.baseline_of(session, question_id: qid)
106
+
107
+ if pending && pending[:id].to_s == qid
108
+ selected = selection(pending, options) or return @selection_status
109
+ refusal = approval_refusal(pending, selected, id)
110
+ return failure(refusal) if refusal
111
+
112
+ posted = post(id, qid, selected, options)
113
+ return posted if posted.is_a?(Integer)
114
+ elsif session.status != Session::STATUS_RUNNING
115
+ return failure("no question #{qid} waits in #{id[0, 8]}; nothing to answer")
116
+ else
117
+ not_open(qid)
118
+ end
119
+ # Answered here, or no longer open (answered elsewhere, or a newer
120
+ # question): what comes next either way.
121
+ wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout])
122
+ end
123
+
124
+ # The labels to send. A number is an option's place (1-based); an
125
+ # exact label wins over a number.
126
+ # @return [Array<String>, nil] nil after a usage error
127
+ def selection(pending, options)
128
+ offered = Array(pending[:options]).map(&:to_s)
129
+ labels = options[:options].map do |given|
130
+ next given if offered.include?(given)
131
+
132
+ index = Integer(given, exception: false)
133
+ next offered[index - 1] if index&.between?(1, offered.size)
134
+
135
+ listed = offered.each_with_index.map { |label, i| "#{i + 1}. #{label}" }.join(", ")
136
+ @selection_status = usage_failure("no option #{given}; the options: #{listed}")
137
+ return nil
138
+ end
139
+ if labels.uniq.size > 1 && !pending[:multi_select]
140
+ @selection_status = usage_failure("one option only: the question is single-select")
141
+ return nil
142
+ end
143
+ labels.uniq
144
+ end
145
+
146
+ # Why a parent may not give this answer to an approval, or nil
147
+ # (Guardrails::ParentApprovals, with this process's config.yml). A
148
+ # Deny, text alone or a dismissal always passes: each one denies. The
149
+ # worker checks it again with its own config.
150
+ def approval_refusal(pending, selected, id)
151
+ offered = Array(pending[:options]).map(&:to_s)
152
+ indices = selected.map { |label| offered.index(label) }
153
+ reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: Guardrails::ParentApprovals.setting)
154
+ reason && Guardrails::ParentApprovals.message(reason)
155
+ end
156
+
157
+ # Post the answer (or the dismissal) to the live worker's Bridge.
158
+ # @return [Integer, nil] an exit status when it didn't go in; nil to
159
+ # wait (it went in, or the question was no longer open: 409)
160
+ def post(id, qid, selected, options)
161
+ client = BridgeClient.discover(id, session_dir: Session.session_dir(id, state_dir: @state_dir))
162
+ return failure(worker_gone(id)) unless client
163
+
164
+ reply = nil
165
+ 2.times do
166
+ reply = if options[:dismiss]
167
+ client.dismiss_question(id: qid)
168
+ else
169
+ client.answer(id: qid, selected: selected, freeform: options[:text],
170
+ client_id: Guardrails::ParentApprovals::CLIENT_ID)
171
+ end
172
+ break unless reply.status == 408
173
+ end
174
+
175
+ case reply.status
176
+ when 200 then nil
177
+ when 409 then not_open(qid)
178
+ when 400 then usage_failure(reply.json&.dig("detail") || "the worker refused the answer")
179
+ # The worker's own guardrails.parent_approvals refused the allow.
180
+ when 403 then failure(reply.json&.dig("detail") || "the worker refused the allow: deny it, and tell your user")
181
+ when 408 then failure("the worker did not take the answer in time; the question is still open: try again")
182
+ when 404
183
+ return failure(worker_gone(id)) if reply.json&.dig("error") == "unknown_session"
184
+
185
+ cant = options[:dismiss] ? "dismiss a question" : "take an answer"
186
+ failure(BridgeClient.stale_worker_message(id, cant: cant))
187
+ else failure("the worker answered #{reply.status}: #{reply.json&.dig("detail") || reply.body}")
188
+ end
189
+ rescue Errno::ETIMEDOUT
190
+ failure("the worker did not take the answer in time; the question is still open: try again")
191
+ rescue SystemCallError, IOError, SocketError
192
+ failure(worker_gone(id))
193
+ end
194
+
195
+ # The question was answered elsewhere (the web first), or another one
196
+ # waits now: said, then what comes next is waited for.
197
+ # @return [nil]
198
+ def not_open(qid)
199
+ error_line("#{command_name}: question #{qid} is no longer open; not answered here, waiting for what comes next")
200
+ nil
201
+ end
202
+
203
+ def worker_gone(id)
204
+ "the worker is gone and the question with it; send the task again: chi send --wait -m \"…\" #{id}"
205
+ end
206
+
207
+ # @return [Integer] 1, after the line
208
+ def failure(line)
209
+ error_line("#{command_name}: #{line}")
210
+ 1
211
+ end
212
+
213
+ # The Bridge refused the answer itself (an option not offered, two on a
214
+ # single-select question): a usage error, the question still open.
215
+ def usage_failure(line)
216
+ error_line("#{command_name}: #{line}")
217
+ CLI::Exit::USAGE
218
+ end
219
+ end
220
+ end
@@ -18,6 +18,7 @@ require_relative "bridge/sse_writer"
18
18
  require_relative "bridge/turn_accumulator"
19
19
  require_relative "session"
20
20
  require_relative "engine"
21
+ require_relative "guardrails/parent_approvals"
21
22
  require_relative "session_commands"
22
23
  require_relative "image_store"
23
24
  require_relative "log"
@@ -496,6 +497,10 @@ module Samagotchi
496
497
  qid = fetched(parsed, "id")
497
498
  selected = fetched(parsed, "selected")
498
499
  freeform = fetched(parsed, "freeform")
500
+ # chi answer's marker: a parent agent, held to this worker's
501
+ # guardrails.parent_approvals (QuestionDesk::Refused). Not a boundary:
502
+ # any local process may post here, with or without it.
503
+ client_id = fetched(parsed, "client_id")
499
504
  if qid.to_s.strip.empty?
500
505
  return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "id required" }]
501
506
  end
@@ -504,11 +509,15 @@ module Samagotchi
504
509
  return deadline_passed("answer") if expired?("answer_expired", deadline, sid: session_id, id: qid)
505
510
 
506
511
  begin
507
- result = @engine.answer_question(id: qid, selected: selected, freeform: freeform)
512
+ result = @engine.answer_question(id: qid, selected: selected, freeform: freeform, client_id: client_id)
508
513
  [{}, 200, { status: "answered", session_id: session_id, answer: result }]
509
514
  rescue Engine::QuestionNotPending => e
510
515
  # Another client answered first, or the question was cancelled.
511
516
  [{}, 409, { error: "question_not_pending", detail: e.message }]
517
+ rescue QuestionDesk::Refused => e
518
+ Log.info(:bridge, "parent_approval_refused", sid: session_id, id: qid, reason: e.reason)
519
+ [{}, 403, { error: "parent_approval_refused",
520
+ detail: Guardrails::ParentApprovals.message(e.reason) }]
512
521
  rescue ArgumentError => e
513
522
  [{}, 400, { error: "invalid_answer", detail: e.message }]
514
523
  end
@@ -888,6 +897,7 @@ module Samagotchi
888
897
  202 => "Accepted",
889
898
  204 => "No Content",
890
899
  400 => "Bad Request",
900
+ 403 => "Forbidden",
891
901
  404 => "Not Found",
892
902
  409 => "Conflict",
893
903
  500 => "Internal Server Error",
@@ -108,10 +108,15 @@ module Samagotchi
108
108
 
109
109
  # POST /session/:id/answer. 200 = recorded, 409 = another client answered
110
110
  # first (or the question is gone), 400 = invalid selection, 408
111
- # deadline_passed = read too late and dropped (see DEADLINE_SHARE).
111
+ # deadline_passed = read too late and dropped (see DEADLINE_SHARE), 403
112
+ # parent_approval_refused = a parent's allow (client_id cli:answer)
113
+ # beyond the worker's guardrails.parent_approvals.
114
+ # @param client_id [String, nil] who answers (chi answer marks its own)
112
115
  # @return [Response]
113
- def answer(id:, selected:, freeform: nil)
114
- post("answer", { id: id, selected: selected, freeform: freeform, deadline: deadline }, read_body: true)
116
+ def answer(id:, selected:, freeform: nil, client_id: nil)
117
+ body = { id: id, selected: selected, freeform: freeform, deadline: deadline }
118
+ body[:client_id] = client_id if client_id
119
+ post("answer", body, read_body: true)
115
120
  end
116
121
 
117
122
  # POST /session/:id/question/dismiss: leave the question unanswered.