samagotchi 0.29.0 → 0.31.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -1
  3. data/docs/cli.md +4 -4
  4. data/docs/configuration.md +4 -0
  5. data/docs/hooks.md +1 -1
  6. data/docs/internals/engine.md +1 -1
  7. data/docs/plugins.md +1 -1
  8. data/docs/sessions.md +11 -4
  9. data/lib/samagotchi/bridge/card_store.rb +1 -1
  10. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  11. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/mcp/plugin.rb +4 -3
  13. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +17 -4
  14. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/system/delegated.md +1 -0
  16. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  17. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  18. data/lib/samagotchi/child_reports.rb +214 -0
  19. data/lib/samagotchi/client.rb +35 -4
  20. data/lib/samagotchi/command_steps.rb +35 -15
  21. data/lib/samagotchi/config.rb +5 -0
  22. data/lib/samagotchi/engine.rb +2 -0
  23. data/lib/samagotchi/host_registry.rb +4 -0
  24. data/lib/samagotchi/idle_recap.rb +4 -0
  25. data/lib/samagotchi/kernel_loop.rb +63 -11
  26. data/lib/samagotchi/llm/chat_loop.rb +14 -7
  27. data/lib/samagotchi/llm/errors.rb +9 -0
  28. data/lib/samagotchi/log_subscriber.rb +1 -1
  29. data/lib/samagotchi/model_list_store.rb +117 -0
  30. data/lib/samagotchi/model_profile.rb +43 -0
  31. data/lib/samagotchi/prompt_warmup.rb +30 -5
  32. data/lib/samagotchi/reply_wait.rb +39 -8
  33. data/lib/samagotchi/send_command.rb +5 -3
  34. data/lib/samagotchi/session.rb +31 -5
  35. data/lib/samagotchi/session_inbox.rb +34 -1
  36. data/lib/samagotchi/session_manager.rb +41 -5
  37. data/lib/samagotchi/session_metrics.rb +6 -3
  38. data/lib/samagotchi/steer.rb +39 -7
  39. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  40. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  41. data/lib/samagotchi/terminal_ui/formatting.rb +38 -8
  42. data/lib/samagotchi/terminal_ui.rb +44 -5
  43. data/lib/samagotchi/tool_activity.rb +6 -2
  44. data/lib/samagotchi/tool_call_parser.rb +40 -0
  45. data/lib/samagotchi/tool_declarations.rb +7 -7
  46. data/lib/samagotchi/tools/delegate.rb +26 -13
  47. data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
  48. data/lib/samagotchi/tools/delegate_wait.rb +50 -31
  49. data/lib/samagotchi/tools/execute.rb +3 -1
  50. data/lib/samagotchi/tools/task_runtime.rb +21 -2
  51. data/lib/samagotchi/tools/task_wait.rb +2 -2
  52. data/lib/samagotchi/version.rb +1 -1
  53. data/lib/samagotchi/web/app.rb +8 -2
  54. data/lib/samagotchi/web/public/activity.js +2 -1
  55. data/lib/samagotchi/web/public/app.js +21 -11
  56. data/lib/samagotchi/web/public/command_view.js +19 -5
  57. data/lib/samagotchi/web/public/format.js +8 -1
  58. data/lib/samagotchi/web/public/index.html +14 -6
  59. data/lib/samagotchi/web/public/notify.js +4 -2
  60. data/lib/samagotchi/web/public/stage_model.js +18 -0
  61. data/lib/samagotchi/web/public/stage_view.js +16 -11
  62. data/lib/samagotchi/web/public/task_stop.js +11 -2
  63. data/lib/samagotchi/web/public/timing.js +12 -7
  64. data/lib/samagotchi/web/public/turn_events.js +29 -9
  65. data/lib/samagotchi/web/public/turn_model.js +16 -11
  66. data/lib/samagotchi/web/public/turn_view.js +49 -7
  67. data/lib/samagotchi/worker.rb +240 -23
  68. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 581b7b0fa7184fb49a83588921656f2f91f05ae04538592e69874ba1207b686c
4
- data.tar.gz: 4c381eb155ab69b933cda29461a02b111910adfb53309bb38ffc5819dfc764d6
3
+ metadata.gz: b18cad295a3d093e74497e8d271c571b12531797f0345bc7f929cc3e1d10d663
4
+ data.tar.gz: e72c996d2692f6a91a69f03943ca4e985ba777fea18ba4cb08728fe32a744c72
5
5
  SHA512:
6
- metadata.gz: 84b0ba1d65c4c5d41fcbee9e925934f6fd6ee1eb4b46bb35af98b46cde28f9ad334ab56ff44e1ee503f0f7f9dab107d03429c145c7097fa35f90aa8ab86f1f4f
7
- data.tar.gz: 7725dc03f4d5c9d42e7a5c4cae0ba5729740a6d95d37fb04c7bdcc611489907b039ad2ec9b0770c7f47ff04fc681d750309988825896c0e90c1e5b7c0dda3605
6
+ metadata.gz: 8878011a9051c4d815dc24e0785fd91772155e770d39974841c2d85b0d0d5f9a57d23f6f862d49f449e8b9436171a99b91e2fad5105ce9863ba5ce3a76e9fe5d
7
+ data.tar.gz: 11086d6eb896ea62961f87d70ecd7e6eaaacab9fefc519ef11249b643fbdcb3becdc28054447b23be10fbf898c599bd7da746a2a259504e54e9e9656f8d92d86
data/CHANGELOG.md CHANGED
@@ -8,6 +8,102 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.31.0] - 2026-10-06
12
+
13
+ ### Added
14
+
15
+ - Delegate reports: a child started with `delegate` (`wait: false`) brings its reply to its parent by itself when
16
+ it ends its turn, fails or asks the parent something. A parent mid-turn gets it at the next step; an idle parent
17
+ runs a turn for it and tells its user, waking its worker when that had exited. The report carries the reply, so
18
+ the model doesn't poll with `delegate_result`. The web shows a "delegate report" bubble, the attached terminal
19
+ `delegate report> <child> answered: …`, and the bell rings when such a turn ends. A stopped or archived parent
20
+ isn't woken: its reports join its next turn. New settings `session.delegate_reports` (`wake`, `queue`, `off`) and
21
+ `session.max_wakes` (default 10 turns in a row with no human input; then reports wait for your next message).
22
+
23
+ ### Changed
24
+
25
+ - The parent's record of which delegate replies its model already had is kept on disk (`delegates.json`), so a
26
+ restarted worker no longer hands the model a reply again; `delegate_result` asked again about a question it
27
+ already reported waits for its answer instead of repeating it.
28
+ - A delegated child does only what its task asks: a question gets a report with the change it would make, not
29
+ file edits; it edits files only when the task asks for a change, and then only the files that takes.
30
+ - `task_get`, `task_stop` and `task_wait` say they take a task id (from `task_create` or `task_list`, with an example),
31
+ not a session id: models passed session ids to them.
32
+
33
+ ### Fixed
34
+
35
+ - A message sent while a reminder turn ran was lost when that reminder turn was the session worker's first turn
36
+ (a fresh or restarted worker): the turn took the message and dropped it. It now joins the turn as steering does.
37
+ - `chi -p … --non-interactive` (and a fresh REPL session's first turn without a worker) could not use the `delegate`
38
+ tool: the session was not saved until its turn ended, so the child failed with "Session not found". A fresh
39
+ session is now saved before its first turn.
40
+ - `task_get`, `task_stop` and `task_wait` given a session id say so: `task not found: <id> (that is a session id; task
41
+ ids come from task_create, and task_list lists them)`.
42
+ - `task_get`, `task_stop` and `task_wait` take only an id shaped like task_create's (`20261005093000-1a2b3c4d`): an id
43
+ such as `../x` could read a `task.json` outside `tmp/tasks`. Anything else is `task not found`.
44
+ - `task_wait` for an unknown task said `Error: Error: task not found: …`; it now says it once.
45
+ - A corrupt Gemma 4 generation on a llama.cpp host (a tool call never closed, or a fresh thought header after the
46
+ answer text) was saved as the answer, and a delegate report passed it on to the parent. A poisoned llama.cpp prompt
47
+ cache produced one with another session's answer in it (ggml-org/llama.cpp#27148). chi now logs
48
+ `generation_malformed`, shows `↻ malformed answer, asking again (1/1)` and asks once more without the prompt cache;
49
+ a second corrupt generation fails the turn ("malformed generation from host …"). The half tool call never runs.
50
+ - On llama.cpp, a parent's turn-end warm-up no longer waits behind a delegate child generating on the same slot, and
51
+ its next turn no longer waits behind that warm-up: the warm-up is skipped when `/slots` says the slot is busy.
52
+
53
+ Update with `chi update` and restart `chi web`.
54
+
55
+ ## [0.30.0] - 2026-10-05
56
+
57
+ ### Added
58
+
59
+ - chi saves the model ids each host last listed (`~/.local/state/samagotchi/model_lists.json`), so a command that
60
+ starts a session without listing the hosts itself knows what they serve.
61
+ - `chi send --new --model` and the `delegate` tool's `model:` warn about a model id the named host's saved list
62
+ doesn't have: `host 'box' doesn't list model 'gemma-smal' (did you mean: gemma-small?); started it anyway`, on
63
+ stderr / in the tool's result. The session still starts: some hosts serve ids they don't list (a one-model
64
+ llama.cpp server takes any name, OpenRouter's `:nitro`). A name that names no host, a host with no saved list, and
65
+ a list over a week old are not checked.
66
+
67
+ ### Changed
68
+
69
+ - Web: an open step whose label is its narration's first line or its first call's title shows only its call count,
70
+ so it doesn't repeat the words its body starts with; closed, the label is unchanged.
71
+ - Web stage: an answered turn says its step count once (in the cloud's chip) and its time once (in the status row).
72
+ - Web stage: `execute` and the task tools are blue in the running-tool row and the ticks, as in the tool rows under
73
+ them; purple is thinking's colour only (other tools' ticks are a quieter blue-grey).
74
+ - Web stage: `edit` and `write` are blue in the running-tool row and the ticks too, as in the tool rows; green is a
75
+ finished call's `✓` and an answered turn's only.
76
+
77
+ ### Fixed
78
+
79
+ - A command the user's Stop killed (or kept from starting) is saved as `stopped`, not `error`: the web shows it with
80
+ ■ and `stopped`, and the turn's `(N failed)` and the session's tool errors no longer count it.
81
+ - Web: the stop-task confirm names a task from an earlier turn by its command (the wait row's title), not its id.
82
+ - Web: a canceled turn's end line (the Stop, Ctrl-C, a hook, the session's stop) reads `■ canceled (stopped)` in the
83
+ stopped calls' amber, live and after a reload, not as a red error; a worker that exited mid-turn stays red `✕`.
84
+ - Web stage: a call the Stop cut has an amber tick and an amber `■` in the trail, as its `■ STOPPED` row, instead of
85
+ its tool kind's colour.
86
+ - A call still running when its turn is canceled is saved in the session's tool records as `stopped` (was
87
+ `canceled`), the word its row uses; one cut by a failed turn stays `canceled`.
88
+ - A canceled turn's last step keeps the thinking it streamed, so reloading it shows the thought instead of an empty
89
+ step (both loops).
90
+ - A plain REPL (`--no-shared`) keeps its session's `pending_card.json` as a worker does, so `chi web` marks a session
91
+ waiting on an open card it showed.
92
+ - source-links: a ref that names its own repo (`other/repo#12`) takes `{host}` from the git remote only when the
93
+ source's `remote_host:` lists that host, so it can't link to the local checkout's host.
94
+ - Web stage: the running-tool row no longer reads `thinking…` once the turn has ended (answered, canceled or
95
+ failed): it is idle, with no word.
96
+ - The REPL and attached mode end a canceled turn with an amber `■` (`■ turn canceled (Ctrl-C)`, `■ turn stopped by
97
+ loop-guard`), as the web does, instead of `✕`: a stop someone chose, not an error; a failed turn keeps `✕`.
98
+ - Web stage: a call a guardrail blocked has a red tick and a red `✕` in the trail, as its `✕ BLOCKED` row, instead
99
+ of its tool kind's colour.
100
+ - A command step with more than one heredoc (`-f body="$(cat <<EOF …)" -f title="$(cat <<EOF …)"`) cuts every body
101
+ out of its text, and its chip says so: `EOF · 3 lines +1`, with all of them on hover.
102
+ - A session worker survives a failed save between turns too (its first prompt, a command, taken notes, a dropped
103
+ question): it logs it and keeps going; notes whose save failed are kept and saved on the next pass.
104
+
105
+ Update with `chi update` (source-links 0.3.4, mcp 0.4.4) and restart `chi web`.
106
+
11
107
  ## [0.29.0] - 2026-10-05
12
108
 
13
109
  ### Added
@@ -1500,7 +1596,9 @@ and long-lived sessions.
1500
1596
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
1501
1597
  hotkey panel that send selected text or the clipboard to your sessions.
1502
1598
 
1503
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.29.0...HEAD
1599
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.31.0...HEAD
1600
+ [0.31.0]: https://github.com/dm1try/samagotchi/compare/v0.30.0...v0.31.0
1601
+ [0.30.0]: https://github.com/dm1try/samagotchi/compare/v0.29.0...v0.30.0
1504
1602
  [0.29.0]: https://github.com/dm1try/samagotchi/compare/v0.28.0...v0.29.0
1505
1603
  [0.28.0]: https://github.com/dm1try/samagotchi/compare/v0.27.0...v0.28.0
1506
1604
  [0.27.0]: https://github.com/dm1try/samagotchi/compare/v0.26.0...v0.27.0
data/docs/cli.md CHANGED
@@ -849,12 +849,12 @@ While a turn runs, you can cancel it without exiting the process:
849
849
  Behavior notes:
850
850
 
851
851
  - Cancellation returns control to the prompt immediately; what you typed there stays.
852
- - The turn ends with one line, `✕ turn canceled (Ctrl-C) · 3.1s`, and for a prompt turn a dim
852
+ - The turn ends with one line, `■ turn canceled (Ctrl-C) · 3.1s` (`■`, a stop someone chose, in amber), and for a prompt turn a dim
853
853
  `partial progress kept; !rollback restores the pre-turn state` under it (a canceled continue is back where it
854
854
  started). A failed turn ends with `✕ turn failed: <summary> · 2.0s` and a dim `prompt restored for retry`. The
855
- REPL and attached mode say the same; the web says `✕ canceled (Ctrl-C)` (`stopped` for its Stop button,
856
- `by a hook` for a hook's). A turn a bundle's plugin stopped names it: `✕ turn stopped by loop-guard` (the web:
857
- `✕ stopped by loop-guard`).
855
+ REPL and attached mode say the same; the web says `■ canceled (Ctrl-C)` (`stopped` for its Stop button,
856
+ `by a hook` for a hook's). A turn a bundle's plugin stopped names it: `■ turn stopped by loop-guard` (the web:
857
+ `■ stopped by loop-guard`).
858
858
  - Visible text the canceled request had streamed stays in the conversation, marked `[interrupted]`, so the next
859
859
  message (or a continue) picks up from the half-finished reply; the canceled request's thinking and any unfinished
860
860
  tool call are dropped.
@@ -72,6 +72,8 @@ session:
72
72
  # idle_exit_minutes: 30 # an unused worker exits after this (0 = never)
73
73
  # keep_empty: false # true keeps sessions nothing happened in (default: deleted when left)
74
74
  # max_children: 4 # running sessions one session may have delegated at a time (the delegate tool)
75
+ # delegate_reports: wake # a child's reply reaches its parent by itself: wake (an idle parent runs a turn), queue, off
76
+ # max_wakes: 10 # turns in a row a parent runs for delegate reports with no human input
75
77
 
76
78
  # Baseline memories preloaded into the system prompt (same shape as --memory);
77
79
  # name entries you have, or chi warns at each start.
@@ -884,6 +886,8 @@ described in their own sections.
884
886
  | `session.idle_exit_minutes` | `30` | yes | An unused worker exits after this; `0` = never. |
885
887
  | `session.keep_empty` | `false` | | Keep sessions nothing happened in. |
886
888
  | `session.max_children` | `4` | | Running delegated sessions one session may have. |
889
+ | `session.delegate_reports` | `wake` | | When a delegate child ends a turn, its reply reaches the parent: `wake` runs an idle parent's turn for it, `queue` waits for the parent's next turn, `off` leaves it to `delegate_result`. |
890
+ | `session.max_wakes` | `10` | | Turns a parent runs for delegate reports in a row with no human input; past it reports wait for the next turn. |
887
891
  | `session.retention_days` | `14` | yes | Delete sessions not updated for N days; `0` = forever. See [Sessions](sessions.md). |
888
892
  | `session.max_count` | `500` | yes | Keep the newest N; `0` = uncapped. |
889
893
  | `session.keep_status` | none | yes | Comma list of statuses never pruned (a session a worker or `chi` has open is never pruned anyway). |
data/docs/hooks.md CHANGED
@@ -179,7 +179,7 @@ What a cut is, in detail:
179
179
  - It is an empty answer made early: it uses the `retry.empty_answer` budget
180
180
  ([configuration.md](configuration.md#llama-network-retry-behavior)). With
181
181
  no retry left (`retry.empty_answer: 0`, or already used) and nothing
182
- queued, the turn ends cancelled (hook): "✕ turn canceled (hook)".
182
+ queued, the turn ends cancelled (hook): "■ turn canceled (by a hook)".
183
183
  - It shows nothing by itself: post your own notice (`event[:notify]`) to say
184
184
  why. The bundle and the reason go into the note the model reads and the
185
185
  log (`generation_stopped`).
@@ -294,7 +294,7 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
294
294
  | `:generation_retrying` | the transport retries the request | `{iteration, ...}` (the retry's fields) |
295
295
  | `:generation_completed` | a generation ended, or was cut | `{iteration, content_length, thinking_chars, served_model, requested_model, finish_reason, ...}`; a cut adds `stopped_by`, `stop_reason`; the Engine adds `speed`, `tokens` |
296
296
  | `:generation_cancelled` | the turn was cancelled mid-loop | `{iteration, reason, stopped_by}` |
297
- | `:empty_answer_retry` | an empty or cut answer is asked again | `{iteration, attempt, of, finish_reason, thinking_chars, ...}` |
297
+ | `:empty_answer_retry` | an empty, cut or malformed answer is asked again | `{iteration, attempt, of, finish_reason, thinking_chars, stopped_by?, malformed?}` |
298
298
  | `:steer_cut` | a steer cut the generation | `{iteration, source}` |
299
299
  | `:pending_input_merged` | queued input joined the conversation | `{iteration, count, content, steers?, answer}` |
300
300
  | `:tool_dispatch_started` / `:tool_dispatch_completed` | around a batch | `{iteration, call_count}` |
data/docs/plugins.md CHANGED
@@ -863,7 +863,7 @@ What happens (`action: retry`, the default):
863
863
  `loop-guard> thinking repeats itself ("Wait, the count of the letter r…", 3 sentences ×3, 4k chars, 8 s): cut`
864
864
  (a short run or a window says how many different sentences it held:
865
865
  "12 different sentences in 48"), and `↻ cut by loop-guard, asking again (1/1)`.
866
- 2. If the retry loops too, the turn is stopped ("stopped the turn: …", "✕
866
+ 2. If the retry loops too, the turn is stopped ("stopped the turn: …", "■
867
867
  turn stopped by loop-guard") with a card that quotes the repeated
868
868
  sentences.
869
869
  3. A loop is forgotten after `forget_after` good steps in a row (generations
data/docs/sessions.md CHANGED
@@ -168,7 +168,7 @@ chi send --wait -m "and the tests?" 3fa2 # a follow-up in the same
168
168
  chi send --wait 3fa2 # sends nothing: waits for its next reply
169
169
  ```
170
170
 
171
- - `--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).
171
+ - `--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). A host config.yml doesn't have is refused before anything is spawned (exit 1). A `host:id` whose id that host's last saved list doesn't have (`~/.local/state/samagotchi/model_lists.json`, what `chi models` / `/models` / the web last listed for that host, at most a week old) gets one warning line on stderr, with up to three close ids as a hint (`did you mean: gemma-small?`), and the session starts anyway: some hosts serve ids they don't list (a one-model llama.cpp server takes any name). A bare id (no `host:`) isn't checked that way — routing sends it to whichever host lists it — and with no saved list for the host nothing is said; a wrong id then fails the worker's first turn.
172
172
  - `--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.
173
173
  - `--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.
174
174
  - Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question, a guardrail approval, or the step-limit question: see [chi as a sub-agent](sub-agent.md#the-step-limit)), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
@@ -179,9 +179,9 @@ chi send --wait 3fa2 # sends nothing: waits fo
179
179
 
180
180
  ## Delegating
181
181
 
182
- The `delegate` tool hands a task to a **child session**: an ordinary chi session in a worker of its own, started in the parent's `working_directory` with the task, verbatim, as its first user message, on the parent's model unless the call names one (`model:` takes a name or an alias, resolved as `--model` is; nothing checks the host serves it, so an unknown one fails the child's first turn). Children run in parallel and only their **final reply** comes back to the parent: the child's newest `output/<timestamp>.txt` file, which the worker writes at the end of each turn that produced visible text, cut head-and-tail like an `execute` result. Nothing of the child's trace enters the parent's context. Nothing is hidden: a child shows in `chi sessions list` (`↳ <parent>`), the web (a `↳` chip on its card, `delegated by` in its info bar, listed right after its parent in the all-sessions view) and the `list_sessions` tool (`child`; the parent shows as `parent` in the child's own list); the user can `chi --attach <child id>` and steer it mid-run, and `chi send` and `send_note` reach it like any session.
182
+ The `delegate` tool hands a task to a **child session**: an ordinary chi session in a worker of its own, started in the parent's `working_directory` with the task, verbatim, as its first user message, on the parent's model unless the call names one (`model:` takes a name or an alias, resolved as `--model` is; an unknown host comes back as the tool's `Error:` line; an id the host's saved model list doesn't have starts the child with a `Warning: host 'h' doesn't list model 'x' (did you mean: …?)` line in the result). Children run in parallel and only their **final reply** comes back to the parent: the child's newest `output/<timestamp>.txt` file, which the worker writes at the end of each turn that produced visible text, cut head-and-tail like an `execute` result. Nothing of the child's trace enters the parent's context. Nothing is hidden: a child shows in `chi sessions list` (`↳ <parent>`), the web (a `↳` chip on its card, `delegated by` in its info bar, listed right after its parent in the all-sessions view) and the `list_sessions` tool (`child`; the parent shows as `parent` in the child's own list); the user can `chi --attach <child id>` and steer it mid-run, and `chi send` and `send_note` reach it like any session.
183
183
 
184
- - `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.
184
+ - `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, and the reply comes back by itself as a *delegate report*, below; `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.
185
185
  - `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.
186
186
  - 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.
187
187
  - **A child's step-limit question goes back to the parent model**, not to its user: `delegate_result` returns `status: question` with the `chi answer` commands for Continue and Stop, and says the model may instead send a narrower follow-up (`delegate session:`, which drops the question) or report back. A parent's `chi answer` follows `turn.parent_continue` (see [chi as a sub-agent](sub-agent.md#the-step-limit)).
@@ -190,6 +190,13 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
190
190
  - 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.
191
191
  - 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.
192
192
  - 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:`.
193
+ - **Delegate reports: a child's reply comes back by itself.** When a child ends a turn that was its parent's (its task, a follow-up, a continue the parent answered, a reminder: not a turn its user typed into it directly), it rings the parent: a small file in the parent's `children/` dir, written after the child's turn is saved. So does a question the parent's model decides (the child's own `ask_user_question`, the step-limit continue; not an approval or a hook's question, which the user answers on the child's card), the turn after such a question whoever starts it, and the child's worker crashing. The parent reads the child as `delegate_result` would and hands its model a **report**: the same text (`session:`, `status:`, the reply), under its own header (`[Delegate report, delivered by chi …; not your user's message. Tell your user what it found …]`), as an input message with `source: "delegate_report"`.
194
+ - Parent mid-turn: the report joins the turn at its next iteration boundary (a "delegate report" bubble in the web, `delegate report> ab12cd34 answered: …` in the attached terminal).
195
+ - Parent idle: it runs a turn for the report (a *wake turn*, `last_turn.origin: "delegate_report"`) within the worker's 5 s tick, or wakes a worker for it when the parent's has idle-exited. Several reports that wait together are one turn and one message. A stopped, archived or scratch parent, or one open in a `--no-shared` REPL, is not woken: the rings wait on disk and join the next turn it runs (the user's message first). A worker just started (`chi send` to a stopped parent) runs no wake turn in its first 2 s, so the message it was started for goes first.
196
+ - The parent's cursor per child (the newest reply it was given and the child as it was then) is in its `delegates.json`, shared with `delegate_result` and `wait: true`: a reply the model already had is never reported again, also after the parent's worker restarts. A ring is deleted only when the turn that took its report is kept; a turn that fails leaves it for the next one (and the report isn't handed back to the composer).
197
+ - Limits: `session.max_wakes` (default `10`) wake turns in a row with no human input; past it the reports wait for the next message and the UIs show `N delegate reports waiting; they join your next message`. A wake turn that fails pauses wakes until the next human input. A continue offer waiting in the parent takes the reports into its continue turn instead.
198
+ - `session.delegate_reports`: `wake` (default), `queue` (no wake turns: reports join the parent's next turn) or `off` (no rings; only `delegate_result` asks, as before). Env `SAMAGOTCHI_SESSION_DELEGATE_REPORTS`, `SAMAGOTCHI_SESSION_MAX_WAKES`.
199
+ - The web's bell rings at a wake turn's end however short (nobody started it, so nobody watched it). The text a model writes about a report mid-turn, before its next tool call, stays visible after the report's bubble once the turn ends.
193
200
  - 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.
194
201
  - 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.
195
- - 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.
202
+ - The link is the session's `parent_id`, written before the worker starts, so a respawn keeps it (and `delegate: true`, which a fork's `parent_id` doesn't come with; a child saved before that field is known by its `system/delegated` memory); `GET /api/sessions` and `/api/sessions/:id` carry it, and `SessionManager.children_of` lists a parent's children.
@@ -166,7 +166,7 @@ module Samagotchi
166
166
  # this worker runs, where it showed live.
167
167
  when :guardrail_warning then push({ type: :guardrail_warning, message: event[:message], label: event[:label] }
168
168
  .compact.merge(in_turn: false, turns: @turns_done))
169
- when :empty_answer_retry then add_turn_row(event.slice(:type, :attempt, :of, :stopped_by)) if @running
169
+ when :empty_answer_retry then add_turn_row(event.slice(:type, :attempt, :of, :stopped_by, :malformed)) if @running
170
170
  when :steer_cut then add_turn_row(event.slice(:type, :source)) if @running
171
171
  when :question_requested
172
172
  add_turn_row({ type: :question, pending_question: Marshal.load(Marshal.dump(event[:pending_question])) }) if @running
@@ -11,7 +11,7 @@ module Samagotchi
11
11
  module TurnNotice
12
12
  FIELDS = {
13
13
  hook_notice: %i[hook text level],
14
- empty_answer_retry: %i[iteration attempt of stopped_by],
14
+ empty_answer_retry: %i[iteration attempt of stopped_by malformed],
15
15
  steer_cut: %i[iteration source],
16
16
  question_requested: %i[pending_question],
17
17
  question_answered: %i[id answer],
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.4.3
3
+ version: 0.4.4
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:079a42892f10822115235e1ced7ccd481f80c9ef54abb094f9a1c599bb03e8e2
10
+ sha256: sha256:05543038e2950a5391655184f57a1028133eda8ca05f9e46e5cd2eddc3e9e608
11
11
  requires_chi: ">= 0.8.0"
@@ -18,11 +18,12 @@
18
18
  # attach_image_paths: true # default: an image path in the text, in the
19
19
  # # temp dir or cwd, attaches it
20
20
  #
21
- # Image blocks in a result go to the model as images (text: "[image 1:
22
- # image/png, attached]"); so does an image file named in a text block,
21
+ # Image blocks in a result go to the model as images (the text keeps
22
+ # "[image 1: image/png]"); so does an image file named in a text block,
23
23
  # under the system temp dir or the server's cwd: the block is only its
24
24
  # absolute path (chrome-devtools-mcp --slim answers a screenshot that way),
25
- # or it says one ending in an image extension ("Saved it to /tmp/a.png.").
25
+ # or it says one ending in an image extension ("Saved it to /tmp/a.png."),
26
+ # noted as "[image 1: a.png]".
26
27
  # A server that doesn't start, answer or list its tools is skipped with a
27
28
  # notice; the rest of chi works. One that exits mid-session starts again on
28
29
  # its next call, at most MAX_RESTARTS times a session. /mcp lists the
@@ -40,8 +40,12 @@ require "open3"
40
40
  # A `url:` template takes {match} (group 1, else the whole ref), {1}…{9}
41
41
  # (numbered groups), {name} (named groups), and {repo}/{host}: the named
42
42
  # group when it took part, else the project's (Dir.pwd's) git remote, asked
43
- # of git once per worker. A ref with a placeholder that can't be filled is
44
- # not linked; a `{word}` that is none of these stays text (warned at load).
43
+ # of git once per worker. A {repo} the ref itself names ("other/repo#12")
44
+ # is that ref's own, so its {host} comes from the remote only when the
45
+ # source's `remote_host:` list has the remote's host: else the ref would
46
+ # link to this checkout's host with someone else's repo. A ref with a
47
+ # placeholder that can't be filled is not linked; a `{word}` that is none
48
+ # of these stays text (warned at load).
45
49
  #
46
50
  # The note is not stored in the conversation: it is an event, replayed by a
47
51
  # UI only while the session's worker lives (a reload keeps it; a stopped
@@ -457,7 +461,7 @@ class SourceLinks
457
461
  when /\A\d+\z/ then match[word.to_i]&.then { |value| escape_url(value) }
458
462
  when "repo", "host"
459
463
  value = match.names.include?(word) ? match[word] : nil
460
- value ||= remote_value(word, source)
464
+ value ||= remote_value(word, source, repo_from_match: match.names.include?("repo") ? match["repo"] : nil)
461
465
  word == "repo" ? escape_repo(value) : value&.then { |host| escape_url(host) }
462
466
  else match[word]&.then { |value| escape_url(value) }
463
467
  end
@@ -466,7 +470,16 @@ class SourceLinks
466
470
  # {repo} / {host} from the project's git remote (the source's `remote:`,
467
471
  # default origin); nil when there is none, or when its host is not one of
468
472
  # the source's `remote_host:` list.
469
- def remote_value(word, source)
473
+ #
474
+ # +repo_from_match+: the match's own {repo}, when it has one. Such a ref
475
+ # names its own repo (`other/repo#12`), not the local checkout's, so its
476
+ # {host} may only come from the remote when the source lists that remote's
477
+ # host (remote_host:): else the ref would link to the local remote's host
478
+ # with someone else's repo.
479
+ def remote_value(word, source, repo_from_match: nil)
480
+ derived_host = word == "host" && !repo_from_match.to_s.empty?
481
+ return nil if derived_host && source[:remote_hosts].empty?
482
+
470
483
  remote = project_remote(source[:remote])
471
484
  return nil unless remote
472
485
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: source-links
3
- version: 0.3.3
3
+ version: 0.3.4
4
4
  scope: system
5
5
  description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, and a one-line note after the turn (note false turns it off); sources are configured patterns
6
6
  trust_level: reviewed
@@ -8,7 +8,7 @@ files:
8
8
  source_links.md: sha256:aebd37f1e25491780994ecc4afc20ed6e8db32b724dc038302bb050177efada8
9
9
  hooks:
10
10
  source_links.rb:
11
- sha256: sha256:454d3512a4aa078b288ff6d0ed25867222a56ead1a57ef1eaa14918e631fec23
11
+ sha256: sha256:c93269964ebc824b5f15ab60db81a458facd50de61446c550f254ea4c68964e2
12
12
  event: after_turn
13
13
  on_error: log
14
14
  priority: 90
@@ -3,6 +3,7 @@
3
3
  Another chi session (the parent) gave you this task. It reads only your last message of each turn; the user may be watching, or not.
4
4
 
5
5
  - Finish the task in this turn if you can. End with one reply the parent can act on without asking back: the result first, then evidence (paths, commands run and what they printed), then anything you could not verify or finish.
6
+ - Do only what the task asks. A question ("why does this spec fail?", "what calls X?", "check whether…") gets a report, not file changes: say what you found and what change you would make, and leave making it to the parent. Edit files only when the task asks you to change, fix, add or write something, and then only the files that takes.
6
7
  - The parent may send follow-up messages later; each one is a new turn in this same session, with what you did so far.
7
8
  - Work in the directory you were started in. Don't change config, install bundles, edit managed files or write memories unless the task asks for it.
8
9
  - You can't delegate further. Don't start other chi sessions, and send notes only to the parent.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.29.0
3
+ version: 0.31.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:69bc0ec091fdaeedf6e3aa7365275461fe3eba32b2dedc9cf45044176c356af5
8
+ self_map.md: sha256:d8f93e2a4ce555316216ad63f9644c488e02e3f3dd19f6e8537a1ea1404314ff
9
9
  config_modification_protocol.md: sha256:ec0b0c7023dbf03b93879e51cd5cdc38b22d43a2e5984ee11e8542ace077c3be
10
10
  memory_guide.md: sha256:17126f5adb9ef7b33c40e920ef339388c8c8c045b38a7de825b08760604db4e4
11
- delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
11
+ delegated.md: sha256:eaaf66830d3ab1346c75812a5fc8fcb9d3dbaa6a3f6bcf47c103155d96e415b5
@@ -24,7 +24,8 @@
24
24
  `chi send --new --wait -m TEXT` starts a session the user can watch in the web and prints its
25
25
  answer (exit 3: it waits for the user's answer); see "Starting a session".
26
26
  - `delegate` hands a task to a child session that runs in parallel and returns only its final
27
- reply (`delegate_result` waits for it; `session:` sends a follow-up to a child). A child is a
27
+ reply (`delegate_result` waits for it; with `wait: false` chi brings the reply to me by itself
28
+ as a delegate report when the child ends its turn; `session:` sends a follow-up to a child). A child is a
28
29
  normal session: it shows in `chi sessions list` with `↳ <parent>`, and the user can steer it
29
30
  with `chi --attach <id>`. While I wait for a child, its tool approvals go to my user as my own
30
31
  approvals (I see only how each was answered); see `docs/sessions.md` "Delegating".
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "session"
5
+ require_relative "session_inbox"
6
+ require_relative "reply_wait"
7
+ require_relative "steer"
8
+ require_relative "log"
9
+ require_relative "config"
10
+ require_relative "tools/delegate_wait"
11
+ require_relative "tools/delegate_cursor"
12
+
13
+ module Samagotchi
14
+ # Loaded on first use: session_manager requires terminal_ui, which
15
+ # requires KernelLoop and so the tools (a require cycle otherwise).
16
+ autoload :SessionManager, File.expand_path("session_manager", __dir__)
17
+
18
+ # A delegate child's news reaching its parent by itself (the delegate
19
+ # tool's wait: false). The child rings (ChildRing): a small file in the
20
+ # parent's children/ dir. The parent's worker reads the rings
21
+ # (ChildReports) and asks each child what happened, as delegate_result
22
+ # would, and hands the model a report: mid-turn at the next iteration
23
+ # boundary, or in a turn of its own when the parent was idle (Worker).
24
+ # The rings stay on disk until the turn that took them is kept, so a
25
+ # worker that exits, restarts or crashes meanwhile loses nothing.
26
+ module ChildRing
27
+ MODE_KEY = "session.delegate_reports"
28
+ MODES = %w[wake queue off].freeze
29
+ # Turns an idle parent runs for reports in a row with no human input.
30
+ MAX_WAKES_KEY = "session.max_wakes"
31
+ MAX_WAKES_DEFAULT = 10
32
+
33
+ module_function
34
+
35
+ # wake: an idle parent runs a turn for a report; queue: reports wait
36
+ # for its next turn; off: no rings at all.
37
+ def mode
38
+ value = Config.get(MODE_KEY).to_s
39
+ MODES.include?(value) ? value : "wake"
40
+ rescue StandardError
41
+ "wake"
42
+ end
43
+
44
+ # Ring +child+'s parent; best effort (a failure is logged, never
45
+ # raised). A parent with no worker is woken on a thread
46
+ # (SessionManager.wake_for_report decides whether it may be).
47
+ # @param child [Session] a delegate child
48
+ # @param why [String] "turn_end", "question" or "crash"
49
+ # @param wake [#call, nil] (parent id) wakes a parent with no worker;
50
+ # nil: SessionManager.wake_for_report on a thread
51
+ # @return [String, nil] the ring file, nil when none was written
52
+ def ring(child, why:, state_dir:, wake: nil)
53
+ return nil if mode == "off"
54
+ return nil unless child&.delegate? && child.parent_id
55
+ # A deleted parent gets no orphan dir.
56
+ return nil unless Session.exist?(child.parent_id, state_dir: state_dir)
57
+
58
+ parent_dir = Session.session_dir(child.parent_id, state_dir: state_dir)
59
+ path = SessionInbox.write_ring(parent_dir, child_id: child.id, why: why)
60
+ Log.info(:worker, "delegate_rang", parent: child.parent_id[0, 8], why: why)
61
+ wake_parent(child.parent_id, state_dir: state_dir, wake: wake) if mode == "wake"
62
+ path
63
+ rescue StandardError => e
64
+ Log.warn(:worker, "delegate_ring_failed", parent: child&.parent_id.to_s[0, 8], error: e.class.name, msg: e.message)
65
+ nil
66
+ end
67
+
68
+ # A live parent worker sees the ring on its next pass; one with no
69
+ # worker is woken.
70
+ def wake_parent(parent_id, state_dir:, wake:)
71
+ return if SessionManager.session_owner(parent_id, state_dir: state_dir)
72
+
73
+ if wake
74
+ wake.call(parent_id)
75
+ else
76
+ Thread.new do
77
+ SessionManager.wake_for_report(parent_id, state_dir: state_dir)
78
+ rescue StandardError => e
79
+ Log.warn(:worker, "delegate_wake_failed", parent: parent_id[0, 8], error: e.class.name, msg: e.message)
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ # The parent's side: reads the rings in its children/ dir and turns them
86
+ # into reports for its model. One per worker; the turn thread and the
87
+ # loop thread both call it (never at once: worker turns run on the loop
88
+ # thread, the drain inside them).
89
+ #
90
+ # A turn takes reports (#take, at each boundary) and the worker settles
91
+ # them as the turn ends: #commit when it is kept (completed or canceled):
92
+ # the cursors move on (DelegateCursors) and the rings go; #release when it
93
+ # failed: the next turn reads the same rings again.
94
+ class ChildReports
95
+ SOURCE = Steer::DELEGATE_REPORT
96
+ CLIENT_PREFIX = Steer::CHILD_CLIENT_PREFIX
97
+
98
+ # A report for the model: the text delegate_result would return
99
+ # (DelegateWait.finish, its report variant), the cursor it moves the child to,
100
+ # and the cursor it was read from (a commit after the model moved it
101
+ # itself, with delegate_result, leaves it alone).
102
+ Report = Data.define(:child_id, :text, :before, :after, :rings) do
103
+ def line(mark: nil) = Steer::Line.new(text: text, source: ChildReports::SOURCE, mark: mark)
104
+ def origin = { client_id: "#{ChildReports::CLIENT_PREFIX}#{child_id[0, 8]}" }
105
+ end
106
+
107
+ def initialize(session_id:, state_dir:)
108
+ @session_id = session_id
109
+ @state_dir = state_dir
110
+ @session_dir = Session.session_dir(session_id, state_dir: state_dir)
111
+ # child id → the Report this turn took for it (rings merged).
112
+ @taken = {}
113
+ end
114
+
115
+ # Whether a ring this turn hasn't taken waits (the idle loop's check).
116
+ def waiting?
117
+ return false if ChildRing.mode == "off"
118
+
119
+ taken = taken_rings
120
+ SessionInbox.find_ring_files(@session_dir).any? { |file| !taken.include?(file) }
121
+ end
122
+
123
+ # The reports for rings this turn hasn't taken yet, recorded as taken.
124
+ # A ring that turns out to say nothing new (the reply was already given,
125
+ # an approval the user answers on the child's card) is deleted.
126
+ # @return [Array<Report>]
127
+ def take
128
+ return [] if ChildRing.mode == "off"
129
+
130
+ taken = taken_rings
131
+ fresh = SessionInbox.find_ring_files(@session_dir).reject { |file| taken.include?(file) }
132
+ by_child = fresh.group_by { |file| SessionInbox.read_ring(file)&.dig(:child_id) }
133
+ by_child.filter_map do |child_id, rings|
134
+ report = child_id && report_for(child_id, rings)
135
+ unless report
136
+ delete_rings(rings)
137
+ next
138
+ end
139
+
140
+ @taken[child_id] = merge_taken(@taken[child_id], report)
141
+ report
142
+ end
143
+ end
144
+
145
+ # The turn that took the reports was kept: move each child's cursor on
146
+ # (unless something else moved it since: the model's own
147
+ # delegate_result), then delete the rings.
148
+ def commit
149
+ @taken.each_value do |report|
150
+ Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
151
+ cursor == report.before ? report.after : cursor
152
+ end
153
+ delete_rings(report.rings)
154
+ rescue StandardError => e
155
+ Log.warn(:worker, "delegate_commit_failed", child: report.child_id[0, 8], error: e.class.name, msg: e.message)
156
+ end
157
+ @taken = {}
158
+ end
159
+
160
+ # The turn failed (rolled back): its rings stay for the next one.
161
+ def release
162
+ @taken = {}
163
+ end
164
+
165
+ def taken? = !@taken.empty?
166
+
167
+ private
168
+
169
+ def taken_rings
170
+ @taken.values.flat_map(&:rings)
171
+ end
172
+
173
+ # A second ring for a child taken earlier in this turn: read from where
174
+ # the first report left the cursor, keeping the first one's +before+.
175
+ def merge_taken(earlier, report)
176
+ return report unless earlier
177
+
178
+ report.with(before: earlier.before, rings: earlier.rings + report.rings)
179
+ end
180
+
181
+ # @return [Report, nil] nil when the child has nothing to report
182
+ def report_for(child_id, rings)
183
+ child = Session.load(child_id, state_dir: @state_dir)
184
+ return nil unless child.parent_id == @session_id
185
+
186
+ before = @taken[child_id]&.after || Tools::DelegateCursors.get(@session_id, child_id, state_dir: @state_dir)
187
+ wait = ReplyWait.call(child_id, state_dir: @state_dir, cursor: before.reply_file, baseline: before.baseline,
188
+ timeout: 0)
189
+ return nil unless reportable?(wait)
190
+
191
+ after = before.with(reply_file: wait.status == :done ? wait.file : before.reply_file)
192
+ .with_baseline(ReplyWait.baseline_of(wait.session))
193
+ text = Tools::DelegateWait.finish(wait, child_id, timeout: 0, report: true)
194
+ Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings)
195
+ rescue ArgumentError
196
+ nil # the child is gone
197
+ end
198
+
199
+ # A reply, a turn that left none, a crash, or a question the parent's
200
+ # model decides (its own ask_user_question, the step-limit continue).
201
+ # Approvals and hooks' questions are the user's, on the child's card.
202
+ def reportable?(wait)
203
+ case wait.status
204
+ when :done, :no_reply, :error then true
205
+ when :waiting_for_answer then [nil, "", "continue"].include?(wait.question&.dig(:kind)&.to_s)
206
+ else false
207
+ end
208
+ end
209
+
210
+ def delete_rings(rings)
211
+ rings.each { |file| FileUtils.rm_f(file) }
212
+ end
213
+ end
214
+ end