samagotchi 0.14.0 → 0.16.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -1
  3. data/docs/architecture.md +10 -0
  4. data/docs/cli.md +3 -2
  5. data/docs/configuration.md +3 -1
  6. data/docs/guardrails.md +16 -2
  7. data/docs/plugins.md +3 -3
  8. data/docs/sessions.md +7 -2
  9. data/docs/sub-agent.md +15 -3
  10. data/lib/samagotchi/answer_display.rb +6 -2
  11. data/lib/samagotchi/bridge/card_store.rb +16 -1
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -0
  13. data/lib/samagotchi/bridge.rb +84 -0
  14. data/lib/samagotchi/bridge_client.rb +33 -6
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  17. data/lib/samagotchi/empty_answer_retry.rb +2 -1
  18. data/lib/samagotchi/engine.rb +58 -17
  19. data/lib/samagotchi/kernel_loop.rb +16 -5
  20. data/lib/samagotchi/llm/chat_loop.rb +17 -9
  21. data/lib/samagotchi/llm/model_result.rb +12 -7
  22. data/lib/samagotchi/log_subscriber.rb +1 -0
  23. data/lib/samagotchi/parent_report.rb +8 -0
  24. data/lib/samagotchi/plugin_session_state.rb +30 -0
  25. data/lib/samagotchi/question_desk.rb +97 -18
  26. data/lib/samagotchi/relay.rb +88 -0
  27. data/lib/samagotchi/relay_desk.rb +73 -0
  28. data/lib/samagotchi/relay_verifier.rb +113 -0
  29. data/lib/samagotchi/relay_watcher.rb +46 -0
  30. data/lib/samagotchi/reply_wait.rb +10 -1
  31. data/lib/samagotchi/self_report.rb +24 -4
  32. data/lib/samagotchi/session.rb +15 -2
  33. data/lib/samagotchi/session_manager.rb +5 -2
  34. data/lib/samagotchi/session_metrics.rb +18 -1
  35. data/lib/samagotchi/session_retention.rb +6 -1
  36. data/lib/samagotchi/sessions_command.rb +10 -4
  37. data/lib/samagotchi/terminal_ui/attach_launcher.rb +11 -2
  38. data/lib/samagotchi/terminal_ui/attached_loop.rb +88 -12
  39. data/lib/samagotchi/terminal_ui/event_renderer.rb +6 -1
  40. data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
  41. data/lib/samagotchi/terminal_ui/question_prompt.rb +48 -2
  42. data/lib/samagotchi/terminal_ui/question_slot.rb +5 -1
  43. data/lib/samagotchi/terminal_ui.rb +11 -0
  44. data/lib/samagotchi/tool_declarations.rb +2 -2
  45. data/lib/samagotchi/tools/delegate_relay.rb +216 -0
  46. data/lib/samagotchi/tools/delegate_wait.rb +72 -6
  47. data/lib/samagotchi/tools/peers.rb +6 -4
  48. data/lib/samagotchi/turn_note.rb +30 -2
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +24 -0
  51. data/lib/samagotchi/web/public/app.js +32 -11
  52. data/lib/samagotchi/web/public/index.html +9 -0
  53. data/lib/samagotchi/web/public/notify.js +18 -5
  54. data/lib/samagotchi/web/public/question_card.js +45 -3
  55. data/lib/samagotchi/web/public/question_cards.js +49 -1
  56. data/lib/samagotchi/web/public/sessions_list.js +16 -1
  57. data/lib/samagotchi/web/public/stage_model.js +8 -3
  58. data/lib/samagotchi/web/public/stage_view.js +7 -2
  59. data/lib/samagotchi/web/public/strip.js +8 -1
  60. data/lib/samagotchi/web/public/timing.js +22 -5
  61. data/lib/samagotchi/web/public/turn_events.js +9 -0
  62. data/lib/samagotchi/web/public/turn_view.js +38 -13
  63. metadata +7 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 760138c7792d414a15c39e074c5a6dc0ad44dfd0937c12707c3e6428a3544d43
4
- data.tar.gz: d347cc3e3d7433292c40484beb73b95e2e3a9efb48ecd5ba5168b3c562c8d7e2
3
+ metadata.gz: 0dd06759f4b1d8b5c2d799aed0a4a57940c34bfd663599fcd1dbbad4f4421c99
4
+ data.tar.gz: 040c8e4d3c4e3aee3f9605fded5676088c21a80b50426696a9cdee4a16f65d7a
5
5
  SHA512:
6
- metadata.gz: ae3585c68672ad0b163448b92c83e9f2d60dbc5efd60c47090170d80750ad20a08a5eae97aebf0dbc57629909db228de565a6a3a00ccb40bff9757ea66c3aeff
7
- data.tar.gz: 4330ef708fe3ebc22f30e4cf249f88fb68a870eb1afd3be9894822e8e819ca25d393b34ffbabf56ef23ee784016b7cdcae9014d709f42db31fac88f501048ddb
6
+ metadata.gz: 418ba106ef5c7f610fd831b93b3441ab1de00910b84a782d37bc3fe617dab833ce35073f4733dc2fe6bdca9d90dba5c7af6fba7b7df29edf60c72c08c99acff9
7
+ data.tar.gz: 8c78e649bbadc097d5cb9b3de7adaac3bcebeda06d04ea1a34f7cbfab531fcaf15e0bce7bbc25480eb28e18c47867cbd0226d57ac3cece9452e7912471aa55b5
data/CHANGELOG.md CHANGED
@@ -8,6 +8,80 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.16.0] - 2026-10-03
12
+
13
+ ### Added
14
+
15
+ - A delegated child's guardrail approvals go to the parent session's user (the approval relay): while the parent
16
+ waits (`delegate`, `delegate_result`), the child's approval opens as the parent's own approval, in the parent's web
17
+ page and attached terminal, with the same facts and diff and a line naming the delegate (linked) and its task. The
18
+ user answers once there; the parent's model sees only an outcome line in the tool's result (`approval relayed to
19
+ your user: execute: git push → allowed once`). The child verifies each answer with the parent's own worker before
20
+ taking it, and holds a parent agent's answer to its own `guardrails.parent_approvals`. The child's card stays
21
+ answerable (first answer wins) and says it waits in the parent too; `chi sessions list` shows it as
22
+ `waiting (in parent ab12)` (json `relayed_to`), and the web's bell rings once, for the parent's card. The other
23
+ running children's approvals are relayed during a wait too, one card at a time (`+1 more delegate waiting`), and a
24
+ grandchild's hop by hop. The time a card is open doesn't count against the wait's timeout.
25
+ - For other parents (`chi send --wait`, `chi answer`), an approval's question block also offers to leave it open for
26
+ the user: `chi send --wait --format json ID` waits until they answer it in chi.
27
+
28
+ ### Changed
29
+
30
+ - A turn that ends with no answer (the empty-answer retries used up) saves the same shape from both loops: the hidden
31
+ turn note alone. The native loop no longer saves the empty thinking-only reply and a literal `[No response]` (the
32
+ next prompt had two assistant turns in a row), and the chat loop's `(the model returned an empty answer)` is no
33
+ longer the answer text (`chi send --wait` saw it as an answer). The note carries a marker the UIs draw from, never
34
+ sent to the model. Older sessions keep their `[No response]`.
35
+ - A parent agent's answer (`chi answer`, a marked `chi --attach`) no longer brings an archived session back to the
36
+ lists; only a human's does.
37
+
38
+ ### Fixed
39
+
40
+ - A turn with no answer shows one muted line in the attached TUI and the REPL,
41
+ `no answer: the model returned nothing (after 1 retry)`, like the retry row, and so does `chi --resume` when it was
42
+ the last turn (it showed `[No response]` or an earlier step's text as the answer, or nothing).
43
+ - The web shows a turn with no answer as the same muted notice where the answer would go, before the timing line,
44
+ live and after a reload; the reload keeps the turn's steps (both thinkings, the retry row inside them). Live it
45
+ showed nothing (native hosts) or an answer bubble below the timing line (`api: openai`); a reload showed
46
+ `[No response]` as the answer with one step fewer, or lost the steps and turned the retry row into a bubble of
47
+ its own.
48
+ - Attached `chi -p` from a script exits 1 with `chi: the model gave an empty answer` on stderr when its turn ends with
49
+ no answer, as `chi -p --non-interactive` does; it exited 0 with a blank line (native hosts) or the placeholder text
50
+ (`api: openai`) as the answer. `chi send --wait` reports such a turn as `no_answer` from both kinds of host (an
51
+ `api: openai` host's placeholder was reported as the answer).
52
+ - `chi --resume` and `--attach` give the last exchange's replayed tool rows their durations, as the live rows show
53
+ them (`ok (1.3s)`), from the session's saved tool records; a session from before turn ids keeps them bare.
54
+ - The web stage's status row reads `∅ no answer` (muted, with a warn edge) for a turn that ended with no answer; it
55
+ said `✓ answered`.
56
+
57
+ Update with `chi update` (no bundle changes).
58
+
59
+ ## [0.15.0] - 2026-10-02
60
+
61
+ ### Added
62
+
63
+ - The hidden session strip's pill counts the sessions that wait for you (`▾ 5 sessions · 1 live · 1 waiting`, in the
64
+ warn colour).
65
+ - The all-sessions search finds the sessions that wait for you: `waiting`, `question`, `approval` or `needs you`.
66
+
67
+ ### Fixed
68
+
69
+ - `chi --resume ID` (and `--attach`) shows the last exchange's tool calls as the live tool rows
70
+ (`tool> running command (execute command="true"): ok`) between the prompt and the answer, not the prompt and the
71
+ answer alone.
72
+ - A `--no-shared` REPL keeps the session's `cards.json` as a worker does: its turns count, so the cards and notices an
73
+ earlier worker showed stay where they were in the web, and the REPL's own are saved too.
74
+ - A deleted, discarded or retention-pruned session takes its plugins' state with it: a bundle's
75
+ `plugins/<bundle>/sessions/<id>.json` (or `<id>/`) under the state dir, such as check-in's `/checkin` settings, no
76
+ longer stays behind.
77
+ - `chi self`'s context window row shows the current model's configured window: its `models.<key>.window_tokens`, else
78
+ its host's `hosts.<name>.window_tokens`, else `context.window_tokens` or the default, and says which.
79
+ - The web pairs each prompt with its own turn's timing: a failed turn (its prompt went back to you) no longer shifts
80
+ the timing lines and turn numbers of the turns after it. chi saves a turn id on each prompt; prompts saved before
81
+ this still pair by their place.
82
+
83
+ Update with `chi update` (no bundle changes).
84
+
11
85
  ## [0.14.0] - 2026-10-02
12
86
 
13
87
  ### Added
@@ -782,7 +856,9 @@ and long-lived sessions.
782
856
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
783
857
  hotkey panel that send selected text or the clipboard to your sessions.
784
858
 
785
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.14.0...HEAD
859
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.16.0...HEAD
860
+ [0.16.0]: https://github.com/dm1try/samagotchi/compare/v0.15.0...v0.16.0
861
+ [0.15.0]: https://github.com/dm1try/samagotchi/compare/v0.14.0...v0.15.0
786
862
  [0.14.0]: https://github.com/dm1try/samagotchi/compare/v0.13.0...v0.14.0
787
863
  [0.13.0]: https://github.com/dm1try/samagotchi/compare/v0.12.0...v0.13.0
788
864
  [0.12.0]: https://github.com/dm1try/samagotchi/compare/v0.11.0...v0.12.0
data/docs/architecture.md CHANGED
@@ -228,6 +228,16 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
228
228
  `guardrails.parent_approvals` is `403 parent_approval_refused`, the question still open.
229
229
  - `POST /session/:id/question/dismiss` — leave the question unanswered (an approval: denied);
230
230
  `200`, or `409` when it is no longer pending.
231
+ - `POST /session/:id/relay` — the approval relay, on a delegated child's worker
232
+ (`{action, relay_id, question_id}`): `opened` / `closed` (with a `reason`) set or clear the
233
+ pending question's `relayed_to` mark (`QuestionDesk#annotate`, `:question_relay`); `answered`
234
+ makes the child ask its parent's Bridge for the answer (`RelayVerifier`) and take it only for
235
+ this child and the question pending now. `200`, `409` no longer pending, `403` a parent agent's
236
+ allow beyond the child's `guardrails.parent_approvals`, `422 relay_unverified` (the question
237
+ stays open).
238
+ - `POST /session/:id/relay/status` — on a parent's worker, `{relay_id}`: what its `RelayDesk`
239
+ (memory only) holds for that relay (`child_id`, `child_question_id`, `state`, `answer`, `by`);
240
+ `404` for an unknown id. A POST with a body because routes match exact paths.
231
241
  - `POST /session/:id/command` — a session command (`/model`, `/models`, `!rollback`, `!cmd`,
232
242
  `/continue`) for the worker loop; `202` with a `command_id` its `:command_ran` names, `400` when
233
243
  the line isn't one.
data/docs/cli.md CHANGED
@@ -211,8 +211,9 @@ Notes:
211
211
  ended, 3 a question or an approval waits for an answer (stderr gets the
212
212
  whole question as `chi send --wait` prints it, with the
213
213
  `chi answer ID --question QID --option N` command; the worker keeps it
214
- open, and `chi answer` or the web answers it), 1 the turn failed or the
215
- worker went away. For a
214
+ open, and `chi answer` or the web answers it), 1 the turn failed, ended
215
+ with no answer (stderr: `chi: the model gave an empty answer`, as with
216
+ `--non-interactive`) or the worker went away. For a
216
217
  script, `chi send --new --wait` is the better entry point: stdout holds the
217
218
  answer alone (see [Starting a session](sessions.md#starting-a-session)).
218
219
  - `-p` always feeds **and** runs the prompt; there is no feed-and-edit variant. To
@@ -625,7 +625,9 @@ asks again in the same turn with a hidden note ("your last reply had no visible
625
625
  unless `sampling:` sets one; the REPL prints `↻ empty answer, asking again (1/1)` and the web shows it as a row of
626
626
  the step. `retry.empty_answer` (default `1`, at most `3`, env `SAMAGOTCHI_RETRY_EMPTY_ANSWER`, no CLI flag) is how
627
627
  many times per turn; `0` ends the turn at the empty answer as before. An answer cut because the context is full
628
- (90 % or more) is not retried. When the retries run out the turn ends with "(the model returned an empty answer)".
628
+ (90 % or more) is not retried. When the retries run out the turn ends with no answer: every UI shows one muted line,
629
+ `no answer: the model returned nothing (after 1 retry)`, and the model is told on its next turn with a hidden note;
630
+ no made-up answer is saved.
629
631
  A generation a plugin cuts while it streams (loop-guard's thinking watch, or any `stop_generation`,
630
632
  [hooks.md](hooks.md#watching-the-stream)) uses the same budget: `↻ cut by loop-guard, asking again (1/1)`, and with
631
633
  none left the turn ends as cancelled (hook).
data/docs/guardrails.md CHANGED
@@ -56,9 +56,14 @@ Who answers:
56
56
  the approval waits (in the session file) and shows on attach.
57
57
  - `-p … --non-interactive`: nobody; the call is denied ("No one to approve it
58
58
  (non-interactive run)").
59
+ - A delegated child session (the `delegate` tool), while its parent waits:
60
+ the parent's user, on the parent's own card (the approval relay; see
61
+ [Sessions: Delegating](sessions.md#delegating)). The child's card stays
62
+ answerable too, the first answer wins. The parent's model sees only the
63
+ outcome.
59
64
  - A parent agent running chi as a sub-agent (`chi send --wait` exits 3 with
60
- the approval): `chi answer` may deny it, never allow it by default. See
61
- [Approvals from a parent agent](#approvals-from-a-parent-agent).
65
+ the approval): `chi answer` may deny it, never allow it by default, or leave
66
+ it for the user. See [Approvals from a parent agent](#approvals-from-a-parent-agent).
62
67
 
63
68
  The model gets one line on a deny. A rule's or hook's deny reads
64
69
  `[execute] Error: denied by guardrail (rule git-push, bundle guardrails): git push publishes commits. The user was not asked. Do not retry it or reach the same result another way; ask the user how to proceed.`
@@ -84,6 +89,15 @@ The file tools can't write the store.
84
89
 
85
90
  ### Approvals from a parent agent
86
91
 
92
+ A chi parent's `delegate` children don't need any of this: their approvals go
93
+ to the user on the parent's own card (the approval relay). This section is the
94
+ path for other agents, and for a chi parent that can't host the relay (a
95
+ `--no-shared` REPL, `--non-interactive`): there the model gets the approval
96
+ block and the rules below. With the relay, the same rules still hold for a
97
+ parent model that answers its own relay card (a piped or marked `chi --attach`
98
+ on the parent): that answer is a parent agent's, checked by the parent and
99
+ again by the child.
100
+
87
101
  When another agent (Claude Code, Codex, a script) runs chi with
88
102
  `chi send --wait`, an approval comes back to it as exit 3 with `kind:
89
103
  approval`, and `chi answer` answers it. A rule with `verdict: ask` means "ask
data/docs/plugins.md CHANGED
@@ -479,7 +479,7 @@ full label, `plugin.rb (bundle my-bundle)`.
479
479
  | `ctx.cwd` | the session's working directory |
480
480
  | `ctx.repo_root` | the git checkout holding `cwd`, or nil |
481
481
  | `ctx.settings` | the bundle's settings, frozen |
482
- | `ctx.data_dir` | `$XDG_STATE_HOME/samagotchi/plugins/<bundle>/`, created on first use |
482
+ | `ctx.data_dir` | `$XDG_STATE_HOME/samagotchi/plugins/<bundle>/`, created on first use. A session's own state goes in `sessions/<id>.json` or `sessions/<id>/` there: chi removes it when the session is deleted, discarded or pruned |
483
483
  | `ctx.log` | `ctx.log.info(:event, key: value)`: debug-log records tagged `plugins`, with `bundle=<bundle>` |
484
484
  | `ctx.messages` | the conversation, as a frozen copy, without the system prompt. While a turn runs, a session worker's (attached, web) adds that turn so far: its prompt, the model's text and the lines merged into it (no tool calls or thinking); the REPL's is the conversation before that turn |
485
485
  | `ctx.messages_partial?` | whether `ctx.messages` leaves out a running turn (the REPL mid-turn), so a plugin can say what its answer is about |
@@ -937,8 +937,8 @@ with a line and carry on unchanged.
937
937
 
938
938
  `/checkin` (anytime) for this session. What it changes (on/off, the mode,
939
939
  the threshold) is saved with the session, in the bundle's data dir
940
- (`plugins/check-in/sessions/<id>.json`), so it outlasts a worker's restart;
941
- another session starts from config.yml:
940
+ (`plugins/check-in/sessions/<id>.json`), so it outlasts a worker's restart
941
+ and goes with the session; another session starts from config.yml:
942
942
 
943
943
  | | |
944
944
  |---|---|
data/docs/sessions.md CHANGED
@@ -40,7 +40,7 @@ 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.
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). A delegate's approval its parent relayed to its own card (see *Delegating*) reads `waiting (in parent ab12)`, json `relayed_to: "ab12…"` (the parent's short id, else null). 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
44
 
45
45
  Examples:
46
46
 
@@ -180,7 +180,12 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
180
180
 
181
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.
182
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.
183
- - 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 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); `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.
183
+ - 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.
184
+ - **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.
185
+ - 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.
186
+ - 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.
187
+ - 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.
188
+ - 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:`.
184
189
  - 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.
185
190
  - 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.
186
191
  - 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 CHANGED
@@ -53,12 +53,22 @@ command, folder, rule and reason.
53
53
 
54
54
  ## Approvals
55
55
 
56
+ A chi parent (the `delegate` tool) doesn't go through any of this: its
57
+ children's approvals go to its own user as its own approvals, and its model
58
+ sees only how each was answered. See
59
+ [Sessions: Delegating](sessions.md#delegating). What follows is for other
60
+ parents (Claude Code, Codex, a script), and for a chi parent that can't host
61
+ the relay (a `--no-shared` REPL, `--non-interactive`).
62
+
56
63
  An approval is a guardrail rule with `verdict: ask`: its user said "ask me". So
57
64
  a parent may deny it (`--option Deny --text "why"`, `--text` alone, or
58
65
  `--dismiss`) but not allow it: `chi answer` refuses an Allow with exit 1 and
59
66
  `allowing a tool call is up to the user: deny it (--option Deny --text WHY),
60
67
  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
68
+ the deny command, not `--option N` or `chi --attach`. The block also offers to
69
+ leave it open: tell your user it waits in chi web (the bell and badge show
70
+ it there), and `chi send --wait --format json ID` (no message) waits until
71
+ they answer it. With
62
72
  `guardrails.parent_approvals: once` in chi's config.yml (it has no environment
63
73
  variable), "Allow once" goes through; the wider scopes never do,
64
74
  and neither does any allow on chi's own config, hooks or guardrail rules. See
@@ -113,8 +123,10 @@ Start a task: `chi send --new --wait --format json --timeout 500 -m "<task>"`
113
123
  - `question.kind: approval`: chi wants to run something its user's guardrails
114
124
  flagged, and allowing it is up to your user. Deny it (the `answer_with`
115
125
  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.
126
+ run and why. Or leave it open: tell your user it waits in chi web (the
127
+ session's short id), then `chi send --wait --format json --timeout 500 <session_id>`
128
+ waits until they answer it there. Never allow it yourself, through
129
+ `chi answer`, a piped `chi --attach` or chi's web API.
118
130
  - `status: running` (exit 4): still working. Wait again:
119
131
  `chi send --wait --format json --timeout 500 <session_id>`.
120
132
  - Anything else (exit 1): report `detail` to your user. `chi --attach <session_id>`
@@ -23,10 +23,14 @@ module Samagotchi
23
23
 
24
24
  # A message as a model-facing reader may see it: without `display`.
25
25
  # The same object when it has none.
26
+ # An empty-answer note's marker (TurnNote::EMPTY_ANSWER) is display
27
+ # only too: it goes the same way.
28
+ DISPLAY_ONLY = [KEY.to_s, "empty_answer"].freeze
29
+
26
30
  def self.strip(message)
27
- return message unless message.is_a?(Hash) && (message.key?(KEY) || message.key?(KEY.to_s))
31
+ return message unless message.is_a?(Hash) && DISPLAY_ONLY.any? { |key| message.key?(key.to_sym) || message.key?(key) }
28
32
 
29
- message.reject { |key, _| key.to_s == KEY.to_s }
33
+ message.reject { |key, _| DISPLAY_ONLY.include?(key.to_s) }
30
34
  end
31
35
 
32
36
  def self.strip_all(messages)
@@ -51,7 +51,7 @@ module Samagotchi
51
51
  # The events after which the file is written again: the ones that
52
52
  # change an entry or the turn count.
53
53
  SAVED_ON = (%i[card hook_notice empty_answer_retry question_requested question_answered question_cancelled
54
- turn_failed] + Events::TURN_KEPT).freeze
54
+ question_relay turn_failed] + Events::TURN_KEPT).freeze
55
55
 
56
56
  # What a session's saved file lists, as a store started from it lists
57
57
  # (no turn running): the web's cards for a session no worker runs.
@@ -171,6 +171,7 @@ module Samagotchi
171
171
  add_turn_row({ type: :question, pending_question: Marshal.load(Marshal.dump(event[:pending_question])) }) if @running
172
172
  when :question_answered then resolve_question(event[:id], answer: event[:answer])
173
173
  when :question_cancelled then resolve_question(event[:id], cancelled: true, reason: event[:reason])
174
+ when :question_relay then mark_relay(event[:id], event[:relayed_to])
174
175
  end
175
176
  end
176
177
 
@@ -219,6 +220,20 @@ module Samagotchi
219
220
  entry&.merge!(Marshal.load(Marshal.dump(outcome.compact)))
220
221
  end
221
222
 
223
+ # The open question was relayed to a parent's user, or no longer is.
224
+ def mark_relay(id, relayed_to)
225
+ entry = @entries.reverse_each.find do |e|
226
+ e[:type] == :question && e.dig(:pending_question, :id).to_s == id.to_s
227
+ end
228
+ return unless entry
229
+
230
+ if relayed_to
231
+ entry[:pending_question][:relayed_to] = Marshal.load(Marshal.dump(relayed_to))
232
+ else
233
+ entry[:pending_question].delete(:relayed_to)
234
+ end
235
+ end
236
+
222
237
  def push(entry)
223
238
  @entries << entry
224
239
  notice = notice?(entry)
@@ -268,6 +268,12 @@ module Samagotchi
268
268
  @turn[:pending_question] = event[:pending_question]&.dup
269
269
  when :question_answered, :question_cancelled
270
270
  @turn[:pending_question] = nil
271
+ when :question_relay
272
+ pending = @turn[:pending_question]
273
+ if pending && pending[:id].to_s == event[:id].to_s
274
+ relayed_to = event[:relayed_to]
275
+ @turn[:pending_question] = relayed_to ? pending.merge(relayed_to: relayed_to) : pending.except(:relayed_to)
276
+ end
271
277
  end
272
278
  parts << { kind: "notice", event: TurnNotice.slice(event) } if TurnNotice.notice?(event)
273
279
  end
@@ -24,6 +24,8 @@ require_relative "image_store"
24
24
  require_relative "log"
25
25
  require_relative "version"
26
26
  require_relative "worker_sidecar"
27
+ require_relative "relay_verifier"
28
+ require_relative "relay_watcher"
27
29
 
28
30
  module Samagotchi
29
31
  # Bridge is an optional HTTP transport that lets an external web / desktop
@@ -192,6 +194,7 @@ module Samagotchi
192
194
  await_answers(REQUEST_GRACE_SECONDS)
193
195
  @connection_threads.each { |t| t.kill rescue nil }
194
196
  @connection_threads.clear
197
+ @mutex.synchronize { @relay_watchers&.each { |t| t.kill rescue nil } }
195
198
  @accept_thread&.join(2)
196
199
  remove_sidecar
197
200
  nil
@@ -429,6 +432,8 @@ module Samagotchi
429
432
  %w[POST command] => :handle_command,
430
433
  %w[POST exit] => :handle_exit_request,
431
434
  %w[POST recap] => :handle_recap,
435
+ %w[POST relay] => :handle_relay,
436
+ %w[POST relay/status] => :handle_relay_status,
432
437
  %w[GET state] => :handle_state,
433
438
  %w[GET stats] => :handle_stats,
434
439
  %w[GET snapshot] => :handle_snapshot
@@ -525,6 +530,38 @@ module Samagotchi
525
530
  end
526
531
  end
527
532
 
533
+ # What a parent's relay may say about this session's approval
534
+ # (BridgeClient#relay): opened and closed only change how the question
535
+ # shows (QuestionDesk#annotate); answered makes this worker ask the
536
+ # parent for the answer (RelayVerifier). No auth: a poke at opened or
537
+ # closed can at most show a wrong banner.
538
+ RELAY_ACTIONS = %w[opened closed answered].freeze
539
+ # Why a relay closed, as a parent says it; anything else is "closed".
540
+ RELAY_CLOSE_REASONS = %w[stopped closed child_gone answered_on_child].freeze
541
+
542
+ # POST /session/:id/relay {action, relay_id, question_id}. 200, 409 (no
543
+ # longer pending, or another relay's), 403 refused (a parent agent's
544
+ # allow), 422 relay_unverified, 400 a bad request.
545
+ def handle_relay(session_id, body)
546
+ parsed = parse_json(body)
547
+ return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }] unless parsed.is_a?(Hash)
548
+
549
+ action, relay_id, qid = %w[action relay_id question_id].map { |key| fetched(parsed, key).to_s }
550
+ unless RELAY_ACTIONS.include?(action) && !relay_id.empty? && !qid.empty?
551
+ return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "action (opened, closed, answered), relay_id and question_id required" }]
552
+ end
553
+
554
+ case action
555
+ when "opened" then relay_opened(qid, relay_id)
556
+ when "closed"
557
+ reason = fetched(parsed, "reason").to_s
558
+ relay_closed(qid, relay_id, RELAY_CLOSE_REASONS.include?(reason) ? reason : "closed")
559
+ else
560
+ RelayVerifier.new(engine: @engine, state_dir: @state_dir, session_id: session_id)
561
+ .answer(relay_id: relay_id, question_id: qid)
562
+ end
563
+ end
564
+
528
565
  # Dismiss the pending question (an empty answer, as in the REPL): the
529
566
  # tool returns without an answer and every UI gets :question_cancelled.
530
567
  # Only the question the client saw, and only while nobody has answered
@@ -872,6 +909,53 @@ module Samagotchi
872
909
  headers["connection"].to_s.downcase == "close"
873
910
  end
874
911
 
912
+ # POST /session/:id/relay/status {relay_id}: what this (the parent's)
913
+ # worker's relay holds, for the child that verifies an answer
914
+ # (RelayVerifier). Read-only and no secret; 404 for an unknown id.
915
+ def handle_relay_status(_session_id, body)
916
+ parsed = parse_json(body)
917
+ return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }] unless parsed.is_a?(Hash)
918
+
919
+ relay = @engine.relay_desk.status(fetched(parsed, "relay_id").to_s)
920
+ return [{}, 404, { error: "unknown_relay" }] unless relay
921
+
922
+ [{}, 200, relay]
923
+ end
924
+
925
+ # A parent's relay card for +qid+ opened: the question says so (the
926
+ # child's card and lists show where else it can be answered).
927
+ def relay_opened(qid, relay_id)
928
+ parent_id = begin
929
+ Session.load(@session_id, state_dir: @state_dir).parent_id
930
+ rescue ArgumentError
931
+ nil
932
+ end
933
+ return [{}, 422, { error: "relay_unverified", detail: "this session has no parent" }] unless parent_id
934
+
935
+ marker = { parent_id: parent_id, parent_short: parent_id[0, 8], relay_id: relay_id }
936
+ return [{}, 409, { error: "question_not_pending", detail: "no pending question #{qid}" }] unless @engine.annotate_question(qid, relayed_to: marker)
937
+
938
+ Log.info(:bridge, "relay_opened", sid: @session_id, id: qid, parent: parent_id[0, 8])
939
+ watch = RelayWatcher.start(engine: @engine, question_id: qid, relay_id: relay_id,
940
+ parent_dir: Session.session_dir(parent_id, state_dir: @state_dir))
941
+ @mutex.synchronize { @relay_watchers = (@relay_watchers || []).select(&:alive?) << watch }
942
+ [{}, 200, { status: "opened", question_id: qid }]
943
+ end
944
+
945
+ # The parent's relay card closed unanswered: the mark goes, if it is
946
+ # still that relay's.
947
+ def relay_closed(qid, relay_id, reason)
948
+ pending = @engine.pending_question
949
+ relay = pending && pending[:relayed_to]
950
+ unless pending && pending[:id].to_s == qid && relay && (relay[:relay_id] || relay["relay_id"]).to_s == relay_id
951
+ return [{}, 409, { error: "question_not_pending", detail: "no question #{qid} relayed by #{relay_id}" }]
952
+ end
953
+ return [{}, 409, { error: "question_not_pending", detail: "no pending question #{qid}" }] unless @engine.annotate_question(qid, relayed_to: nil, reason: reason)
954
+
955
+ Log.info(:bridge, "relay_closed", sid: @session_id, id: qid, reason: reason)
956
+ [{}, 200, { status: "closed", question_id: qid }]
957
+ end
958
+
875
959
  def write_sidecar
876
960
  # version: the chi this worker runs (chi update reports older ones).
877
961
  WorkerSidecar.new(port: @port, bind: @bind, session_id: @session_id, started_at: Time.now.iso8601(3),
@@ -180,6 +180,33 @@ module Samagotchi
180
180
  post("cancel", { reason: reason }, read_body: true)
181
181
  end
182
182
 
183
+ # How long the approval relay's calls wait for a reply: a parent's Stop
184
+ # isn't held for READ_TIMEOUT by a child that doesn't answer.
185
+ RELAY_TIMEOUT = 5
186
+ # How long a child waits for its parent's word on a relay (#relay_status).
187
+ RELAY_STATUS_TIMEOUT = 2
188
+
189
+ # POST /session/:id/relay on a child's worker: a parent's relay of the
190
+ # child's approval opened, closed or was answered (the child asks the
191
+ # parent which answer, #relay_status). 200 answered, 409 no longer
192
+ # pending, 403 refused (a parent agent's allow), 422 relay_unverified.
193
+ # @param action [String] "opened", "closed" or "answered"
194
+ # @return [Response]
195
+ # @raise [SystemCallError] the worker is gone or didn't answer in time
196
+ def relay(action:, relay_id:, question_id:, read_timeout: RELAY_TIMEOUT, **extra)
197
+ post("relay", { action: action, relay_id: relay_id, question_id: question_id, **extra },
198
+ read_body: true, read_timeout: read_timeout)
199
+ end
200
+
201
+ # POST /session/:id/relay/status on a parent's worker: what its relay
202
+ # +relay_id+ holds (child_id, child_question_id, state, answer, by); 404
203
+ # for one it doesn't know. A POST with a body: routes match exact paths.
204
+ # @return [Response]
205
+ # @raise [SystemCallError]
206
+ def relay_status(relay_id, read_timeout: RELAY_STATUS_TIMEOUT)
207
+ post("relay/status", { relay_id: relay_id }, read_body: true, read_timeout: read_timeout)
208
+ end
209
+
183
210
  # GET /session/:id/<path> as JSON.
184
211
  # @return [Hash, nil] the parsed body, or nil unless the Bridge answered 200
185
212
  def get_json(path)
@@ -283,11 +310,11 @@ module Samagotchi
283
310
  # the same one.
284
311
  def deadline = (Time.now.to_f + (@read_timeout * DEADLINE_SHARE)).round(3)
285
312
 
286
- def post(path, payload, read_body:)
313
+ def post(path, payload, read_body:, read_timeout: @read_timeout)
287
314
  sock = TCPSocket.new(@host, @port)
288
315
  json_body = JSON.generate(payload)
289
316
  sock.write("POST /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\nContent-Type: application/json\r\nContent-Length: #{json_body.bytesize}\r\nConnection: close\r\n\r\n#{json_body}")
290
- reply = read_reply(sock, path, whole: read_body)
317
+ reply = read_reply(sock, path, whole: read_body, timeout: read_timeout)
291
318
  body = read_body ? reply.split("\r\n\r\n", 2)[1] : nil
292
319
  Response.new(status: reply[STATUS_LINE, 1].to_i, body: body)
293
320
  ensure
@@ -295,17 +322,17 @@ module Samagotchi
295
322
  end
296
323
 
297
324
  # The reply up to the Bridge's close (or its first line only), within
298
- # @read_timeout in all.
325
+ # +timeout+ seconds in all (@read_timeout by default).
299
326
  # @raise [Errno::ETIMEDOUT]
300
- def read_reply(sock, path, whole: true)
301
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @read_timeout
327
+ def read_reply(sock, path, whole: true, timeout: @read_timeout)
328
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
302
329
  reply = +""
303
330
  loop do
304
331
  break if !whole && reply.include?("\n")
305
332
 
306
333
  left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
307
334
  unless left.positive? && sock.wait_readable(left)
308
- raise Errno::ETIMEDOUT, "bridge #{path}: no reply within #{@read_timeout}s"
335
+ raise Errno::ETIMEDOUT, "bridge #{path}: no reply within #{timeout}s"
309
336
  end
310
337
 
311
338
  reply << sock.readpartial(16_384)
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.14.0
3
+ version: 0.16.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
- self_map.md: sha256:93c788fadaf98adeb8946b4a357d2372bd6ebb439042d7cef8dda8fb44187315
8
+ self_map.md: sha256:0410c2e62716e256a2a6aec73c7801def185cbae9c1db556efcedbbad22d908c
9
9
  config_modification_protocol.md: sha256:d0e9d4a84652bf54ec2fd664f61c536637af9decdd22fcd982f52195bf9f5fb4
10
10
  memory_guide.md: sha256:81b911b9a015f0924c48162b3dc4b6376309e743765074419a99fab46e480858
11
11
  delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
@@ -25,7 +25,8 @@
25
25
  - `delegate` hands a task to a child session that runs in parallel and returns only its final
26
26
  reply (`delegate_result` waits for it; `session:` sends a follow-up to a child). A child is a
27
27
  normal session: it shows in `chi sessions list` with `↳ <parent>`, and the user can steer it
28
- with `chi --attach <id>`; see `docs/sessions.md` "Delegating".
28
+ with `chi --attach <id>`. While I wait for a child, its tool approvals go to my user as my own
29
+ approvals (I see only how each was answered); see `docs/sessions.md` "Delegating".
29
30
  - Bundles extend me: memories, `hooks/*.rb`, `guardrails/*.yml`, and a `plugin.rb` that adds slash
30
31
  commands (`anytime:` ones run mid-turn), model tools (they can return images I see), hooks,
31
32
  cards, side answers (`ask_model`),
@@ -41,7 +41,8 @@ module Samagotchi
41
41
  used_tokens.to_f / window_tokens >= CONTEXT_FULL_RATIO
42
42
  end
43
43
 
44
- attr_reader :limit
44
+ # @return [Integer] the budget, and the attempts spent this turn
45
+ attr_reader :limit, :attempts
45
46
 
46
47
  def initialize(limit: self.class.limit)
47
48
  @limit = limit