samagotchi 0.156.0 → 0.157.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 (161) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/bin/chi +1 -0
  4. data/docs/architecture.md +12 -7
  5. data/docs/bundles.md +3 -1
  6. data/docs/cli.md +9 -8
  7. data/docs/configuration.md +36 -4
  8. data/docs/guardrails.md +9 -5
  9. data/docs/hooks.md +11 -5
  10. data/docs/internals/bridge.md +83 -6
  11. data/docs/internals/bundles.md +28 -18
  12. data/docs/internals/context-telemetry.md +4 -3
  13. data/docs/internals/engine.md +16 -2
  14. data/docs/internals/participants.md +3 -2
  15. data/docs/internals/remote-sessions.md +5 -2
  16. data/docs/internals/web-frontend.md +3 -2
  17. data/docs/internals/working-set.md +1 -1
  18. data/docs/memory.md +13 -2
  19. data/docs/plugins.md +35 -2
  20. data/docs/sessions.md +9 -9
  21. data/docs/sub-agent.md +17 -2
  22. data/docs/testing.md +53 -2
  23. data/lib/samagotchi/answer_command.rb +2 -1
  24. data/lib/samagotchi/archive_store.rb +4 -3
  25. data/lib/samagotchi/bootstrap_command.rb +3 -2
  26. data/lib/samagotchi/bridge/card_places.rb +10 -4
  27. data/lib/samagotchi/bridge/card_store.rb +3 -0
  28. data/lib/samagotchi/bridge/turn_accumulator.rb +109 -9
  29. data/lib/samagotchi/bridge/turn_drawings.rb +265 -0
  30. data/lib/samagotchi/bridge.rb +31 -6
  31. data/lib/samagotchi/broadcast/recipients.rb +8 -8
  32. data/lib/samagotchi/broadcast/triage_model.rb +1 -1
  33. data/lib/samagotchi/bundle_command/check.rb +18 -0
  34. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  35. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  36. data/lib/samagotchi/bundle_command.rb +5 -2
  37. data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
  38. data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
  39. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
  40. data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
  41. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  42. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  43. data/lib/samagotchi/child_move.rb +6 -6
  44. data/lib/samagotchi/child_reports.rb +2 -2
  45. data/lib/samagotchi/children_status.rb +15 -21
  46. data/lib/samagotchi/cli/parent_wait.rb +8 -22
  47. data/lib/samagotchi/config.rb +3 -0
  48. data/lib/samagotchi/context_sources.rb +5 -4
  49. data/lib/samagotchi/context_window.rb +9 -12
  50. data/lib/samagotchi/effective_model.rb +57 -22
  51. data/lib/samagotchi/engine/plugins.rb +1 -1
  52. data/lib/samagotchi/engine/recap.rb +8 -4
  53. data/lib/samagotchi/engine.rb +29 -3
  54. data/lib/samagotchi/events.rb +3 -2
  55. data/lib/samagotchi/extension_load.rb +6 -3
  56. data/lib/samagotchi/guardrails_command.rb +1 -1
  57. data/lib/samagotchi/host_address.rb +52 -0
  58. data/lib/samagotchi/host_registry.rb +57 -45
  59. data/lib/samagotchi/kernel_loop.rb +7 -3
  60. data/lib/samagotchi/llm/chat_loop.rb +6 -3
  61. data/lib/samagotchi/llm_context_strategy.rb +27 -63
  62. data/lib/samagotchi/memory_bundle/asset_installer.rb +71 -76
  63. data/lib/samagotchi/memory_bundle/builder.rb +2 -1
  64. data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
  65. data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
  66. data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
  67. data/lib/samagotchi/memory_bundle/installed_copy.rb +2 -1
  68. data/lib/samagotchi/memory_bundle/installed_manifest.rb +8 -1
  69. data/lib/samagotchi/memory_bundle/installer.rb +185 -169
  70. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  71. data/lib/samagotchi/memory_bundle/merger.rb +69 -65
  72. data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
  73. data/lib/samagotchi/memory_bundle/profile.rb +5 -5
  74. data/lib/samagotchi/memory_bundle/provenance.rb +15 -1
  75. data/lib/samagotchi/memory_bundle/remerge.rb +25 -12
  76. data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -6
  77. data/lib/samagotchi/memory_bundle/status.rb +14 -20
  78. data/lib/samagotchi/memory_bundle/system_bundle.rb +24 -6
  79. data/lib/samagotchi/memory_bundle/trash.rb +23 -8
  80. data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
  81. data/lib/samagotchi/memory_usage.rb +1 -1
  82. data/lib/samagotchi/model_lookup.rb +75 -0
  83. data/lib/samagotchi/model_profile.rb +1 -0
  84. data/lib/samagotchi/models_command.rb +1 -1
  85. data/lib/samagotchi/note_command.rb +1 -1
  86. data/lib/samagotchi/participant_mode.rb +2 -1
  87. data/lib/samagotchi/plugin/api.rb +41 -6
  88. data/lib/samagotchi/plugin/loader.rb +33 -10
  89. data/lib/samagotchi/plugin/sessions.rb +1 -1
  90. data/lib/samagotchi/reasoning_history.rb +14 -20
  91. data/lib/samagotchi/recap_place.rb +27 -0
  92. data/lib/samagotchi/reply_wait/liveness.rb +38 -0
  93. data/lib/samagotchi/reply_wait/poll.rb +44 -15
  94. data/lib/samagotchi/reply_wait/target.rb +113 -0
  95. data/lib/samagotchi/reply_wait.rb +22 -2
  96. data/lib/samagotchi/sampling_settings.rb +9 -16
  97. data/lib/samagotchi/self_report.rb +34 -35
  98. data/lib/samagotchi/send_command.rb +17 -23
  99. data/lib/samagotchi/session.rb +321 -439
  100. data/lib/samagotchi/session_access/local.rb +5 -6
  101. data/lib/samagotchi/session_access/remote.rb +5 -3
  102. data/lib/samagotchi/session_field.rb +133 -0
  103. data/lib/samagotchi/session_inbox.rb +49 -1
  104. data/lib/samagotchi/session_manager/emptiness.rb +2 -1
  105. data/lib/samagotchi/session_manager/listing.rb +29 -100
  106. data/lib/samagotchi/session_manager.rb +2 -1
  107. data/lib/samagotchi/session_metrics.rb +7 -4
  108. data/lib/samagotchi/session_paths.rb +57 -0
  109. data/lib/samagotchi/session_retention.rb +2 -2
  110. data/lib/samagotchi/session_row.rb +200 -0
  111. data/lib/samagotchi/session_setup.rb +15 -12
  112. data/lib/samagotchi/sessions_command.rb +11 -129
  113. data/lib/samagotchi/steer_cut_after.rb +7 -14
  114. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
  115. data/lib/samagotchi/terminal_ui/join_replay.rb +86 -8
  116. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  117. data/lib/samagotchi/terminal_ui.rb +3 -1
  118. data/lib/samagotchi/testing/install.rb +2 -1
  119. data/lib/samagotchi/thinking.rb +16 -21
  120. data/lib/samagotchi/tool_response.rb +5 -3
  121. data/lib/samagotchi/tool_runner.rb +18 -1
  122. data/lib/samagotchi/tools/delegate.rb +48 -43
  123. data/lib/samagotchi/tools/delegate_relay.rb +5 -5
  124. data/lib/samagotchi/tools/delegate_result.rb +1 -1
  125. data/lib/samagotchi/tools/delegate_wait.rb +41 -17
  126. data/lib/samagotchi/tools/list_sessions.rb +7 -7
  127. data/lib/samagotchi/update_command.rb +11 -7
  128. data/lib/samagotchi/verify_fresh.rb +7 -11
  129. data/lib/samagotchi/version.rb +1 -1
  130. data/lib/samagotchi/vision_support.rb +11 -14
  131. data/lib/samagotchi/web/app.rb +31 -21
  132. data/lib/samagotchi/web/message_display.rb +57 -7
  133. data/lib/samagotchi/web/participant_access.rb +1 -1
  134. data/lib/samagotchi/web/public/activity.js +16 -7
  135. data/lib/samagotchi/web/public/app.js +29 -8
  136. data/lib/samagotchi/web/public/bubbles.js +8 -3
  137. data/lib/samagotchi/web/public/cards_place.js +15 -0
  138. data/lib/samagotchi/web/public/commands.js +5 -1
  139. data/lib/samagotchi/web/public/data.js +4 -2
  140. data/lib/samagotchi/web/public/history_shape.js +220 -0
  141. data/lib/samagotchi/web/public/recap_stamp.js +17 -0
  142. data/lib/samagotchi/web/public/reload_check.js +71 -0
  143. data/lib/samagotchi/web/public/stage_model.js +7 -5
  144. data/lib/samagotchi/web/public/stage_view.js +7 -3
  145. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  146. data/lib/samagotchi/web/public/timing.js +43 -2
  147. data/lib/samagotchi/web/public/turn_block.js +215 -30
  148. data/lib/samagotchi/web/public/turn_drawing.js +43 -0
  149. data/lib/samagotchi/web/public/turn_events.js +10 -1
  150. data/lib/samagotchi/web/public/turn_pieces.js +29 -0
  151. data/lib/samagotchi/web/reload_check_route.rb +95 -0
  152. data/lib/samagotchi/web/server.rb +4 -2
  153. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  154. data/lib/samagotchi/web/session_control.rb +1 -1
  155. data/lib/samagotchi/web/session_hub.rb +9 -17
  156. data/lib/samagotchi/web/session_show.rb +64 -2
  157. data/lib/samagotchi/web/session_summary.rb +36 -24
  158. data/lib/samagotchi/worker.rb +28 -5
  159. data/lib/samagotchi/worker_link.rb +3 -0
  160. metadata +20 -4
  161. data/lib/samagotchi/send_command/wait_target.rb +0 -85
data/docs/sessions.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sessions
2
2
 
3
- Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/sessions/<uuid>.json` (XDG-aware via `XDG_STATE_HOME`) plus a sidecar dir `<uuid>/` with `input/`/`notes/`/`output/`/`owner.lock`/`bridge.json` (`Session.session_dir`), and `tool_output/` once a long `execute` result was cut: its whole output, which the cut result names (`ToolOutputFiles`, the newest 50 kept; see [Tool output guardrails](internals/tool-output-limits.md#the-runners-cut-max_tool_output_chars)). Its keys are in [the Session file table](#session-file). A file from before `used_memory_names`, `preloaded_memory_names`, `muted_memory_names`, `parent_id` or `continues` reads those as empty lists, no parent and no previous link.
3
+ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/sessions/<uuid>.json` (XDG-aware via `XDG_STATE_HOME`) plus a sidecar dir `<uuid>/` with `input/`/`notes/`/`output/`/`owner.lock`/`bridge.json` (`Session.session_dir`), `turn_drawings.jsonl` once a turn ended with a Bridge (each finished turn as live drew it, which the web's reload draws: [bridge.md](internals/bridge.md#turndrawings)), and `tool_output/` once a long `execute` result was cut: its whole output, which the cut result names (`ToolOutputFiles`, the newest 50 kept; see [Tool output guardrails](internals/tool-output-limits.md#the-runners-cut-max_tool_output_chars)). Its keys are in [the Session file table](#session-file). A file from before `used_memory_names`, `preloaded_memory_names`, `muted_memory_names`, `parent_id` or `continues` reads those as empty lists, no parent and no previous link.
4
4
 
5
5
  **Message times:** an entry of `messages` may carry `at`, when it happened: a local-offset ISO time with milliseconds (`2026-10-24T14:32:05.120+02:00`). User prompts, lines merged into a turn and steers, reminders, context and task notes, and the model's replies have one (a note's is its `created_at`); tool results, `!cmd` messages and chi's own turn notes don't, nor does anything saved before chi stamped messages (no backfill). It is for the UIs only (the gap dividers, a bubble's time): it never goes to the model, so it changes nothing in the requests or their prompt cache. The one time the model reads is the reminders': due reminders go into a turn as a system message headed `[SYSTEM: REMINDERS DUE 2026-10-24 14:32 local]` (the turn's local time; `ReminderQueue#inject!`), written into the saved content then, so a later request sends the same text.
6
6
 
@@ -8,7 +8,7 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
8
8
 
9
9
  **Revise stacks:** a web Revise turn (POST /turn `revises: {turn_id}`, the answer it rewrites) stamps its opening message (its prompt) with `revises: {turn_id}` at the turn's end, only when the turn completed with an answer (`AnswerTail`): a failed, canceled or empty turn revised nothing (`TurnStages::VerifyAnchors#stamp_opening`). A draft no prompt carries (a stale page, a rolled-back turn) is no target: no stamp. A draft a stamped turn already revised is re-targeted to its stack's top, the newest version, so a stack is a chain `v1 ← v2 ← v3`. Like `verify`, it is metadata only, never added to the prompt (no change to a request's bytes); chi web's history passes it on the opening message (and on a wake turn's note), and `:turn_completed` carries the stamped `revises` (the participant view's too). The history also marks a Verify turn's opening `verify: true`, and so does its live end event (`:turn_completed`'s `verify: true`, with its `turn_id`): the page offers no Revise on its answer. The web page draws the stacks from these stamps (docs/cli.md, Revise). Like a Verify turn's, a Revise message waits for the running turn's end (`SessionInbox.turn_end_data?`; the Bridge takes either as `delivery: queue`, so it never cuts and its ack and `:turn_enqueued` say it queues), is sent only to a worker whose `bridge.json` names the `revise` feature (else 409 `revise_unavailable`), and a failed turn gives it back (`:prompt_restored`'s `revises`). Participants can't Revise: chi web and the Bridge drop it from their turns. Both anchors travel as one `TurnAnchors` inside chi; on the wire and in an input file each keeps its own key (`verify`, `revises`).
10
10
 
11
- **Input files:** a message for a session's worker waits in `input/<timestamp>.json` (`SessionInbox.write_input`: `prompt`, `client_id`, `enqueued_id`, and `images`, `no_interrupt` and `delivery` when they apply; `delivery` is written only for `"queue"`). While a turn runs, the worker merges them at each step boundary in name order, except a queued one or one with images: those wait for the turn's end and run one turn each, in name order, and a file after them still merges (`Worker#pending_input_drain`). Such a file, with a prompt, from the user, `chi send` or a delegating parent, also ends a running `task_wait` or `delegate_result` and hands a running `execute` over as a task after 3 s (`SessionInbox.steer_waiting?`, a peek the worker's turn polls through `Engine#input_waiting?`; never in a participant's turn): the file stays for the drain. A queue is sent only to a worker whose `bridge.json` names the `delivery` feature; an older one would steer it, so `SessionManager.deliver_turn` refuses it (409 `delivery_unavailable`), and any other 4xx the Bridge answers is a refusal too, never a fallback file (except a 413, a body over its size limit: that one still goes in as a file, its `delivery` kept). A session command recognized in a file runs without a turn; its outcome is kept at `input/settled/<enqueued_id>` (`completed`, `failed` for a command error, `canceled` with `cancel_reason` `busy` or `dropped` for one refused mid-turn or dropped as the worker left; `origin: command`) so `chi send --wait` ends instead of waiting for a turn that will not run, its detail saying `the command failed` or `the command was refused (busy)` rather than a turn's words. **Reply files:** a turn that ends with visible text leaves its reply in `output/<timestamp>.txt`, or `output/<timestamp>-<enqueued_id>.txt` when the turn ran an input file with an id (`SessionInbox.write_output`), and the session's `last_turn` keeps that `enqueued_id`; so a wait for one queued message (`chi send --queue --wait`, `ReplyWait`'s `turn:`) finds its own turn's reply or end, never another turn's. The names still sort by time, and every reader takes `*.txt`.
11
+ **Input files:** a message for a session's worker waits in `input/<timestamp>.json` (`SessionInbox.write_input`: `prompt`, `client_id`, `enqueued_id`, and `images`, `no_interrupt` and `delivery` when they apply; `delivery` is written only for `"queue"`). While a turn runs, the worker merges them at each step boundary in name order, except a queued one or one with images: those wait for the turn's end and run one turn each, in name order, and a file after them still merges (`Worker#pending_input_drain`). For each file it merges, before deleting it, the drain records which turn took it: `input/merged/<enqueued_id>` = `{"into": the turn's enqueued_id, "at"}` (`SessionInbox.mark_merged`; none for a turn without an id), so a wait for that message waits for that turn (`ReplyWait::Poll#follow_merge`; a 0.156 worker writes none and the wait infers the merge). A turn nobody typed (a reminder, a wake turn: a delegate report's, an attached context change's, a finished task's) gets an `enqueued_id` its worker mints as it runs, `wake-` and 16 hex digits (`Worker#wake_origin`), for its mark, `last_turn`, reply file and settle; the reports it merges keep id-less origins. Such a file, with a prompt, from the user, `chi send` or a delegating parent, also ends a running `task_wait` or `delegate_result` and hands a running `execute` over as a task after 3 s (`SessionInbox.steer_waiting?`, a peek the worker's turn polls through `Engine#input_waiting?`; never in a participant's turn): the file stays for the drain. A queue is sent only to a worker whose `bridge.json` names the `delivery` feature; an older one would steer it, so `SessionManager.deliver_turn` refuses it (409 `delivery_unavailable`), and any other 4xx the Bridge answers is a refusal too, never a fallback file (except a 413, a body over its size limit: that one still goes in as a file, its `delivery` kept). A session command recognized in a file runs without a turn; its outcome is kept at `input/settled/<enqueued_id>` (`completed`, `failed` for a command error, `canceled` with `cancel_reason` `busy` or `dropped` for one refused mid-turn or dropped as the worker left; `origin: command`) so `chi send --wait` ends instead of waiting for a turn that will not run, its detail saying `the command failed` or `the command was refused (busy)` rather than a turn's words. **Reply files:** a turn that ends with visible text leaves its reply in `output/<timestamp>.txt`, or `output/<timestamp>-<enqueued_id>.txt` when the turn ran an input file with an id (`SessionInbox.write_output`), and the session's `last_turn` keeps that `enqueued_id`; so a wait for one queued message (`chi send --queue --wait`, `ReplyWait`'s `turn:`) finds its own turn's reply or end, never another turn's. The names still sort by time, and every reader takes `*.txt`.
12
12
 
13
13
  **Removing a queued message:** a message queued for after the running turn (`chi send --queue`, the web's Alt+Enter, `/queue TEXT` in a terminal), or one with images (it waits for the turn's end too), can be taken back while it still waits: the worker's Bridge (`POST /session/:id/turn/dequeue {enqueued_id}`, the `unqueue` feature) deletes its input file and every UI drops it (`:turn_dequeued`). It is a race with the turn's end, decided by the same rename the worker uses to take the file: once a turn took it the answer is `409 already_started` (it runs), and an id the worker no longer knows is `404 unknown_enqueued_id`. A plain message sent without `--queue` is not removable (`409 not_queued`): a running turn's next step may take it at any moment. A wait for its turn (`chi send --queue --wait`, a delegate follow-up's wait) ends at once with `removed` (the tombstone `input/removed/<enqueued_id>` says so), and a `chi -p` run whose queued prompt went ends with exit 1. The `200` carries the removed `prompt`: the web and the attached terminal put it back into the composer to edit (not its images). The images a removed message named stay in the session's `images/`. Not removable: a queued session command (`/queue /model x`: it waits in the worker, not in `input/`), and a message in a stopped worker's inbox (the next worker runs it).
14
14
 
@@ -33,11 +33,11 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
33
33
 
34
34
  **Which chi a worker runs:** a new or woken worker runs the newest chi installed for this Ruby (`gem 'samagotchi', '>= 0.a'`, prereleases count), whoever starts it: a `chi web` or terminal still on an older chi starts it on the newest one, so a stale process doesn't spread old code. A worker already running keeps its version until it is restarted. Should the newest install fail to load, the worker runs the starter's own version and logs `boot_fallback` (its stdio is `/dev/null`). Under `bundle exec` the worker drops Bundler's env first, so it activates the newest gem too. Two consequences: a wrapper pin (`chi _0.17.0_ web`) pins only that process, not the workers it starts; and a source checkout (`bin/chi` in a worktree, `bundle exec`) starts its workers on its own `lib/`, but a session it made that is later woken by an installed chi (`chi web`, `chi send`) runs the installed code. The names an older starter hands a newer worker (the boot line, its env keys, `input/` files, the session JSON fields and the Bridge routes sessions use on each other) are pinned in `spec/worker_contract_spec.rb`. A chi newer than 0.130.0 keeps the top-level keys it doesn't know (a newer chi's fields) when it saves a session, after its own, and doesn't act on them; `written_by` names the chi that saved the file last, and a worker woken on a file a newer chi saved logs `newer_writer`. chi 0.130.0 and older drop those keys: a session a newer chi made loses its newer fields when an older chi wakes or saves it (for example `chi --attach` from a project whose Ruby has an older chi). Keep the chi in every Ruby you run it from newer than 0.130.0.
35
35
 
36
- A new session field (the rule in `Session::FIELDS`' comment): only top-level keys are kept (a new sub-key of `review`, `toolkit` or another value object is still dropped by an older chi), and an older chi keeps a key without honouring it, so a new field must be safe to come back unchanged after an older chi ran turns on the session.
36
+ A new session field is one `Session::FIELDS` row, plus a `SessionSetup` member when a fork and a continue carry it (`SessionSetup.of` and `Session.new_session` go by its members). The rule in `FIELDS`' comment: only top-level keys are kept (a new sub-key of `review`, `toolkit` or another value object is still dropped by an older chi), and an older chi keeps a key without honouring it, so a new field must be safe to come back unchanged after an older chi ran turns on the session.
37
37
 
38
38
  **A worker outlives whoever started it:** a spawned worker leaves its spawner's process tree (it is forked again, in a session of its own, and reparented to init), so closing the terminal of a `chi web`, or a tool tearing down a background `chi send --wait` and its children, no longer takes the worker and its turn with it.
39
39
 
40
- **A worker killed mid-turn:** while a turn runs, its worker keeps it on disk (`TurnCheckpoint`): the session file has the turn so far (the prompt from the turn's start, then the finished tool steps and lines merged into it) and `interrupted_turn: {id, started_at, at, iteration, enqueued_id}` (`enqueued_id`: the input the turn runs, so any process can tell which turn is running; a continue turn carries its offer turn's), written as the turn starts, at the loop's first iteration boundary and then at most every 10 s (`updated_at` moves with it). The turn's end saves the real messages without the mark. A worker that died without ending its turn (SIGKILL, a crash of the process) leaves the mark, and loses what came after its last written boundary: at most the last 10 s of steps, plus the step in flight. Each checkpoint write also saves the turn's metrics so far to `analytics.json` (tokens, speeds, timings, its finished tool calls), as the record a killed turn leaves: canceled, `cancellation_reason: worker_stopped`; the turn's own end writes over it. The next worker to start for the session (a `chi send`, `--resume`, `--attach`, the web), or a `chi --resume ID -p TEXT --non-interactive` run, settles the turn before anything else (`TurnCheckpoint.settle`, log `interrupted_turn_settled`): a note `the previous turn was cancelled (worker-stopped) after Ns` goes at the tail, the status goes idle and the mark goes; `last_turn` stays the previous turn's (a new one saved before the turn a `chi send --wait` woke the worker for would end that wait at once); the dead turn's end is recorded under its `enqueued_id` instead, in `<id>/input/settled/<enqueued_id>` (a `last_turn` record: canceled, `cancel_reason: worker_stopped`), which only a wait for that very turn reads (`chi send --queue --wait`, a `chi send --wait -m` to an idle session or `--new`, `chi send --wait ID --turn`, a continue's wait): it ends `canceled` with `cancel_reason: worker_stopped`, detail "its worker exited mid-turn", instead of timing out. A wait with no turn id that saw the turn running ends the same way when the settle comes (`chi send --wait ID` with no message on a turn with no id, such as a session's first prompt or a reminder turn; a message merged into the turn as a steer), unless an input still waits or was claimed since: an unmerged message runs as its own turn next, and that turn's end is the answer. A reply the turn wrote before its worker died is still its answer. The session shows the turn's prompt and steps with that note; the dead turn is not run again, and the model reads on its next turn that it was cut.
40
+ **A worker killed mid-turn:** while a turn runs, its worker keeps it on disk (`TurnCheckpoint`): the session file has the turn so far (the prompt from the turn's start, then the finished tool steps and lines merged into it) and `interrupted_turn: {id, started_at, at, iteration, enqueued_id}` (`enqueued_id`: the input the turn runs, so any process can tell which turn is running; a continue turn carries its offer turn's), written as the turn starts, at the loop's first iteration boundary and then at most every 10 s (`updated_at` moves with it). The turn's end saves the real messages without the mark. A worker that died without ending its turn (SIGKILL, a crash of the process) leaves the mark, and loses what came after its last written boundary: at most the last 10 s of steps, plus the step in flight. Each checkpoint write also saves the turn's metrics so far to `analytics.json` (tokens, speeds, timings, its finished tool calls), as the record a killed turn leaves: canceled, `cancellation_reason: worker_stopped`; the turn's own end writes over it. The next worker to start for the session (a `chi send`, `--resume`, `--attach`, the web), or a `chi --resume ID -p TEXT --non-interactive` run, settles the turn before anything else (`TurnCheckpoint.settle`, log `interrupted_turn_settled`): a note `the previous turn was cancelled (worker-stopped) after Ns` goes at the tail, the status goes idle and the mark goes; `last_turn` stays the previous turn's (a new one saved before the turn a `chi send --wait` woke the worker for would end that wait at once); the dead turn's end is recorded under its `enqueued_id` instead, in `<id>/input/settled/<enqueued_id>` (a `last_turn` record: canceled, `cancel_reason: worker_stopped`), which only a wait for that very turn reads (`chi send --queue --wait`, a `chi send --wait -m` to an idle session or `--new`, `chi send --wait ID --turn`, a continue's wait): it ends `canceled` with `cancel_reason: worker_stopped`, detail "its worker exited mid-turn", instead of timing out. A wake or reminder turn's settle is recorded under its worker-minted `wake-…` id the same way, and a message merged into a turn as a steer follows that turn's id (`input/merged/`). A wait with no turn id that saw the turn running ends the same way when the settle comes (`chi send --wait ID` with no message on a turn with no id, such as a 0.156 worker's reminder turn; a steer merged by a 0.156 worker), unless an input still waits or was claimed since: an unmerged message runs as its own turn next, and that turn's end is the answer. A reply the turn wrote before its worker died is still its answer. The session shows the turn's prompt and steps with that note; the dead turn is not run again, and the model reads on its next turn that it was cut.
41
41
 
42
42
  **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM, then CONT (a stopped worker, SIGSTOP or Ctrl-Z, runs its TERM handler only once continued, so it leaves at once instead of outliving the wait), and waits (up to 10 s) until the worker has let go of `owner.lock`. A running turn is canceled over the Bridge first, with reason `session_stopped` (the turn's line ends "(session stopped)", its note says `cancelled (session-stopped)`, and a command it was running says "stopped as the session was stopped"; a bare signal to the worker makes it "stopped by a signal to chi"; an older worker takes the reason as `manual`); the TERM stops whatever still runs as a Stop with reason `signal` and saves it (a spawned worker traps TERM, HUP and INT: `WorkerSignals`; a turn not ended within 3 s, 0.5 s after a stop or a second signal, is cut and the session saved as it was), and a stopped worker still saves its kept turn, since the marker, not the saved status, says stopped. Any TERM, HUP or INT does the same to a worker outside a stop: it leaves (log `worker stop reason=signal`) and the next send wakes a new one, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's ⚙ panel, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s (the page's Stop shows the stop at once), or for the body's `wait` up to the same 10 s (`SessionManager::STOP_WAIT`): `chi --remote NAME sessions stop` sends it, so it waits as long as here. Before that stop, the page's `stop session` button asks what the worker still has that the user can stop: `GET /api/sessions/:id/holds` (owner-only) answers the worker's `holds` — the same list the exit ask's 409 carries — always an array, empty when there is no live worker, one from before the route, or one that did not answer. With something, a sheet (a bottom sheet on a phone, a popover at the button otherwise — `stop_sheet.js`) lists them, at most three of a kind named and `+N more`, and offers **Stop all** (`POST /api/sessions/:id/holds/stop` → `HoldStop#stop_all`, then the stop), **Stop session only** (the stop exactly as it was) or **Cancel**. Nothing to ask about is the plain confirm, one tap as before. Both buttons stay because a stop only TERMs the worker: its reminders die with it (a stop also deletes a restart's `reminders.json` no successor took yet, so a later resume doesn't bring them back), while a background task and a delegate are their own processes and keep running. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
43
43
 
@@ -93,7 +93,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
93
93
 
94
94
  **Projects:** a session belongs to the git project it was started in: the repository, whichever worktree or subfolder of it (the same project root memories use). `chi sessions list` (with `--live` and `--format` too) and `chi web` show the current folder's project; `--scope=all` shows every session, and so does a folder in no repo (`~`). `--cwd PATH` is a folder filter instead of the project. The project is stored with the session (`project_root` in its JSON, `project` in `--format json`), so a session keeps it after its worktree is deleted; sessions older than that are placed by their folder, and one whose folder is gone shows in `--scope=all` only. Retention, `--resume`/`--attach ID`, `chi send`/`chi note ID` and `chi note --all` (every live session) are not scoped. The agent's `list_sessions` lists its own project's sessions; `cwd: "/"` lists every one.
95
95
 
96
- `--live`, `--cwd` and `--format` make `list` a picker for scripts (`SessionManager.session_summaries`): `--live` keeps the sessions a worker runs now (the owner lock, not the saved status; a session a one-turn `chi --non-interactive` run has is left out), `--cwd PATH` those in PATH or below, and test runs are left out unless the list is itself run as one (`SAMAGOTCHI_ENV=test`, `RACK_ENV=test` or `CI` set: then they show, marked `[test]`; `chi note --all` likewise). `--live` shows 10 unless `--limit` says otherwise; filters apply before the limit. `--format json` prints `[{id, short_id, desc, cwd, project, updated_at, live, busy, owner, recap, parent_id, continues, delegate, archived, scratch, ctx_pct}]` (`owner`: `"worker"`, `"tui"` for a one-turn `chi --non-interactive` run, which takes no notes or messages, or null; `recap`: the first sentence of the session's recap, or null; `delegate`: true for a child the `delegate` tool started, false for a fork, which has a `parent_id` too), `--format tsv` one `id<TAB>desc` line per session, where `desc` is `<folder> · <last prompt>` cut to 60 characters (the text form of `--live`/`--cwd` shows `<folder> · <recap>` when there is one; tsv and json keep `desc`).
96
+ `--live`, `--cwd` and `--format` make `list` a picker for scripts (`SessionManager.session_rows`: the rows are `SessionRow`; the plain list, the picker's text and a remote list print one line, `SessionRow#cli_line`): `--live` keeps the sessions a worker runs now (the owner lock, not the saved status; a session a one-turn `chi --non-interactive` run has is left out), `--cwd PATH` those in PATH or below, and test runs are left out unless the list is itself run as one (`SAMAGOTCHI_ENV=test`, `RACK_ENV=test` or `CI` set: then they show, marked `[test]`; `chi note --all` likewise). `--live` shows 10 unless `--limit` says otherwise; filters apply before the limit. `--format json` prints `[{id, short_id, desc, cwd, project, updated_at, live, busy, owner, recap, parent_id, continues, delegate, archived, scratch, ctx_pct}]` (`owner`: `"worker"`, `"tui"` for a one-turn `chi --non-interactive` run, which takes no notes or messages, or null; `recap`: the first sentence of the session's recap, or null; `delegate`: true for a child the `delegate` tool started, false for a fork, which has a `parent_id` too), `--format tsv` one `id<TAB>desc` line per session, where `desc` is `<folder> · <last prompt>` cut to 60 characters (the text form of `--live`/`--cwd` prints the plain list's line, with its state, `idle` for a live idle worker too, and its text; tsv and json keep `desc`).
97
97
 
98
98
  **Ordering:**
99
99
 
@@ -115,7 +115,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
115
115
 
116
116
  ## Session file
117
117
 
118
- `Session::FIELDS` is the table of `<uuid>.json`, in the order `Session#save` writes it (`Session.from_h` and `#to_h` both go by it; 41 keys). A key a file lacks reads as the default below; `review`, `first_turn_id`, `verdicts` and `verify_fresh` are written only when set (`OPTIONAL_FIELDS`). A key this chi doesn't know is kept and written back as it was (see the rule above). "Who writes it": the Engine runs in the session's worker (or a `--non-interactive` run); the rest are named.
118
+ `Session::FIELDS` is the table of `<uuid>.json`, in the order `Session#save` writes it (`Session.new`, `.from_h` and `#to_h` all go by it; 41 keys). Each key is one `SessionField` row (`session_field.rb`): its default for a file without it, how a value is cast when read and what the file gets, the codec being its type's own (`Toolkit.from_file`, `SessionReview.of`, `PromptNote.list`, `#to_file`…); the typed fields' writers cast through their row, and `#to_h` reads each field's instance variable, not its reader. A key a file lacks reads as the default below, a key present as `null` as `null` (then cast: `test_run` false, `toolkit_origin` chosen); `review`, `first_turn_id`, `verdicts` and `verify_fresh` are written only when set (`OPTIONAL_FIELDS`). A key this chi doesn't know is kept and written back as it was (see the rule above). `thinking`, `reasoning_history` and `llm_context` follow one Unread rule (`SessionField.level`): a value this chi can't read (a newer chi's level, a hand edit) reads as none and is kept and written back as it was, until the session sets its own; `llm_context` keeps the parts it can't read under the ones it can. Where the file and the session's dir are, and which ids name a session, is `SessionPaths` (`session_paths.rb`, a leaf that `Session` extends: `Session.session_dir` is `SessionPaths.session_dir`). The bytes a save writes for each field and on each path that makes or rewrites a file are pinned in `spec/session_file_goldens_spec.rb`, with what a fork, a continue, a delegate child and a respawn keep of their source (`carry_matrix.json`). "Who writes it": the Engine runs in the session's worker (or a `--non-interactive` run); the rest are named.
119
119
 
120
120
  | Key | Meaning | Written by |
121
121
  |-----|---------|------------|
@@ -494,12 +494,12 @@ mise.toml pins Ruby 3.4 gets that `ruby`, `gem` and `bundle` in its tools while
494
494
 
495
495
  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 above 64 KB (`DelegateWait.cut`); a reply longer than `max_tool_output_chars` keeps its start and its end (the conclusion), with a gap line naming that `output/` file, where the whole reply is (`ToolOutputCut`'s `:head_tail_ref`; see [Tool output guardrails](internals/tool-output-limits.md#the-runners-cut-max_tool_output_chars)). Nothing of the child's trace enters the parent's context. Nothing is hidden: a child shows in `chi sessions list` (`↳ <parent>`), the web (folded into its parent's card on the strip and in all sessions, whose `▸ N delegates` chip lists the delegates at any depth, see Web UI above; a card of its own with a `↳` chip when its parent isn't listed; `↑ <parent id>` in its info bar, its tooltip `delegated by session …`; the parent's info bar carries a `⑂ N` chip counting its delegates, with how many run, wait, are done, failed, stopped or idle in the tooltip and the waiting ones named in the attention colour; a click opens all sessions), the terminal's status row (a parent's `⑂ 2 running · 1 waiting`, see [Status Line](cli.md#status-line)) 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.
496
496
 
497
- - `delegate(task, model:, session:, cwd:, wait:, timeout:)`: starts a child and, by default, waits for its reply. `wait: false` is the usual way for work that takes a while, several children side by side, or to keep talking with the user: it returns at once, the parent ends its turn or works on, and the reply comes back by itself as a *delegate report* (below), in a turn of its own if the parent is idle. The tool descriptions tell the model so, and to wait (`wait: true`, `delegate_result`) only when its turn can't go on without the reply. `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. The wait (`wait: true`, a later `delegate_result`) and the delegate report are for the reply of the child's turn the follow-up went to: its running turn, which it merges into, or the turn of its own it runs when the child is idle (`turn` in `delegates.json`, set when the child's worker names `turn_ids`), not a newer reply of a turn queued behind it. A follow-up merged as a steer into a turn the child started at that moment (a wake, a reminder, another message) gets that turn's reply or question. A child on its first turn, or with an older worker, gives its next reply, as before.
497
+ - `delegate(task, model:, session:, cwd:, wait:, timeout:)`: starts a child and, by default, waits for its reply. `wait: false` is the usual way for work that takes a while, several children side by side, or to keep talking with the user: it returns at once, the parent ends its turn or works on, and the reply comes back by itself as a *delegate report* (below), in a turn of its own if the parent is idle. The tool descriptions tell the model so, and to wait (`wait: true`, `delegate_result`) only when its turn can't go on without the reply. `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. The wait (`wait: true`, a later `delegate_result`) and the delegate report are for the reply of the child's turn the follow-up went to (`turn` in `delegates.json` is the follow-up's own id, set when the child's worker names `turn_ids`): the turn of its own it runs, or the running turn whose drain merges it as a steer (the drain's `input/merged/` record says which; inferred for an older child), not a newer reply of a turn queued behind it. A follow-up the running turn ended without taking runs as its own turn and gets that turn's reply. A child on its first turn, or with an older worker, gives its next reply, as before.
498
498
  - `cwd:` starts the child in another folder of the parent's repository: a linked worktree (`git worktree add ../<repo>-<task> -b <branch>`, which the model runs with `execute` first, under guardrails; chi runs no git here) or a subfolder; a relative path is taken from the parent's folder. The folder must exist, have the parent's project root (the same common git dir, `MemoryPaths.project_root`) and be in a work tree: a git dir (`.git`, `.git/objects`, a bare layout's `.bare`) or a bare layout's container is refused (`git rev-parse --is-inside-work-tree`, read-only, decides). Outside git only a subfolder of the parent's folder is allowed. Otherwise the tool returns `Error: cwd must be a folder of this session's repository …` or `… is not in a work tree …`. A follow-up (`session:`) refuses `cwd:`: the child keeps its folder. The child's guardrails measure from its own worktree, and project memories, `list_sessions` and the web's project are the same as the parent's. With no `cwd:` and a task that names another checkout of the same repository (a path in the task belonging to a linked worktree, read from the files with `Guardrails::GitInfo.checkouts`, no git process), the result carries `Warning: this child runs in <folder>, but its task names <checkout>. If it should work there, stop it (chi sessions stop <id>) and call delegate again without session, with cwd: <checkout>.` One line however many are named; nothing for a path of the child's own checkout, a folder that isn't one, a path with spaces, or the main checkout when the child runs in a linked worktree of it (the work belongs in the worktree, so naming a main-checkout file is not a forgotten `cwd:`). The child starts as it would anyway, and with `wait: true` (the default) the line comes after the child ran, so it only helps with `wait: false`. A `Warning:` for an unknown model id comes first, then this one. `Log.info(:worker, "delegate_other_checkout", parent:)` records each line.
499
499
  - `delegate_result(session:, timeout:)`: waits for a child's next reply: the one named, or the newest running child (`timeout` as for `delegate`: 0 checks once and returns at once). The reply comes back once; a later call waits for the next one. After `wait: false` it isn't needed: the report brings the reply.
500
500
  - A wait also ends early when a person's message waits for the parent's turn's next step (the `task_wait` rule: `Engine#input_waiting?`, `SessionInbox.steer_waiting?`, never in a participant's turn): `ReplyWait`'s `message_waiting:` probe gives `:message_waiting`, checked after every outcome the child can arrive at (a reply, a question, an end, a gone worker) and a cancel, before the deadline, and the result says `no reply yet: a message from the user is waiting; the child keeps running. Read the message first.` with the same last line as a timeout's. The child is never touched, the cursor doesn't move (the reply still comes, as a report or at the next `delegate_result`), a relayed approval's outcome line stays in the result, and a reserved report reads as for a timeout. A delegate child's report doesn't end a wait. This holds for `delegate`'s own wait too (both go through `DelegateWait`).
501
501
  - A wait that timed out or was canceled still gets its delegate report: neither moves the parent's cursor, so the child's reply comes as a report when its turn ends. The result says so (`chi brings its reply here by itself as a delegate report …`; with `queue`, that it joins the next step or turn) and that `delegate_result` waits again only if the turn needs the reply now. A parent that gets no reports (`session.delegate_reports: off`, `--non-interactive`) is told `delegate_result <id> waits again` instead, and `wait: false`'s result says `delegate_result waits for its reply`.
502
- - 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.
502
+ - 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 5 seconds (it crashed without saying so, or idle-exited; `ReplyWait::GONE_AFTER`, the one rule `chi send --wait` follows too), or, for a child the call started or woke, when its worker didn't come up within the boot limit (30 s: "its worker did not start"; the wait awaits a booting worker first, within the call's `timeout` and until a cancel, which then read as `running`): `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.
503
503
  - **A child's step-limit question goes back to the parent model**, not to its user: `delegate_result` returns `status: question` with the `chi answer` commands for Continue and Stop, and says the model may instead send a narrower follow-up (`delegate session:`, which drops the question) or report back. A parent's `chi answer` follows `guardrails.parent_answers`, or the child's own when stricter (`hard`: Stop only; see [chi as a sub-agent](sub-agent.md#the-step-limit)).
504
504
  - **A child at its cost cap** (the [cost-cap bundle](bundles.md#the-cost-cap-bundle), the child's own cap: `child:` in its settings, or `/cap`) asks a `hook` question, "Cost cap", `Continue` or `Stop`. Like every hook's question on a `delegate` child, it goes where it can be decided, under the child's `guardrails.parent_answers` (config.yml's, or the child's own when stricter): when the parent may pick an option other than a deny (Stop) there, `standard` and `soft` here, it comes back to the parent's model as `status: question` with the `chi answer` commands; when it may not, `hard` here (a parent's Continue is refused), it goes to the parent's user as a card, as approvals do (the relay below), and the model sees one line: `hook question relayed to your user: Cost cap → Stop` (or `dismissed`, which the hook reads as it likes, cost-cap as Stop; `refused by the child …`, `answered on the child`, `still open (your turn was stopped)`). Free text alone doesn't count as an option the parent may pick. A **background** child (`wait: false`) paused at its cap rings its parent as a question does (delegate reports, below): the parent's model gets it as a report when it may answer; otherwise the parent's worker relays it to its user at its next step boundary, or in a wake turn's first boundary when idle (before the model is called), and the report the model gets is the outcome line. While that card is open the parent's turn waits on it; a Stop closes it and leaves the question for your next input (or a `delegate_result`): no wake turn offers that question again meanwhile, while other children's reports and attached-context wakes go on. Under the default `soft` that means a background parent's model now hears its child's cost cap, and may Continue it.
505
505
  - **A child's approvals go to the parent's user** (the approval relay), and so do the hook questions its parent can't usefully answer (above: a card headed `delegate ab12 asks: Cost cap`, the child's options and kind, no approval facts). 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.
@@ -510,7 +510,7 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
510
510
  - **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 decides (the child's own `ask_user_question`, the step-limit continue, a hook's question, relayed to the parent's user first when the parent may only deny it; not an approval, which the relay or the user on the child's card answers, nor a card the child relays from its own child), 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"`.
511
511
  - 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).
512
512
  - 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 a one-turn `chi --non-interactive` run has, is not woken: the rings wait on disk and join the next turn it runs (the user's message first). An archived parent that a later link continues wakes that link instead, which takes the rings its moved delegates left there (see *Session chains*). 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.
513
- - 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 stays in the conversation (completed, canceled, or failed but kept: one that got to tool steps, or a reminder or continue turn, which keep what they read); a turn rolled back (a failed one that got nowhere, a canceled continue turn) leaves it for the next one (and the report isn't handed back to the composer). So the model reads each report once, unless a later `!rollback` erases a kept turn (what it read goes with it).
513
+ - The parent's cursor per child (the newest reply it was given and the child as it was then, and the turn waited for: a new child's first turn, by its `first_turn_id`, or the turn a follow-up joined) 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 stays in the conversation (completed, canceled, or failed but kept: one that got to tool steps, or a reminder or continue turn, which keep what they read); a turn rolled back (a failed one that got nowhere, a canceled continue turn) leaves it for the next one (and the report isn't handed back to the composer). So the model reads each report once, unless a later `!rollback` erases a kept turn (what it read goes with it).
514
514
  - 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, or is stopped before the model call (a Stop, a `before_turn` hook's cancel), pauses wakes until the next human input; its reports' rings stay, so they join that input's turn, and a failure's note tells the model so. A continue offer waiting in the parent takes the reports into its continue turn instead.
515
515
  - `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`.
516
516
  - 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.
data/docs/sub-agent.md CHANGED
@@ -81,8 +81,15 @@ With `--format json` stdout is one JSON object (see
81
81
  when the wait is about a known turn, it includes `turn_id`. Use
82
82
  `chi send --wait --format json --turn <turn_id> <session_id>` to wait for
83
83
  that exact turn again after exit 4, or after exit 3 if someone else will
84
- answer the question. A turn id can name the running turn, a queued turn or
85
- the last turn that ended; any other id is an error.
84
+ answer the question. A turn id can name the running turn, a queued turn,
85
+ the last turn that ended, or a message a running turn merged (waited for as
86
+ that turn); any other id is an error. A turn nobody typed (a
87
+ reminder, an attached context change, a finished task, a delegate report: a
88
+ wake turn) has an id its worker made, `wake-` and 16 hex digits, so
89
+ `chi send --wait` with no message names it too and `--turn` waits for it. A
90
+ message merged into a running turn (a steer) gets that turn's reply or end;
91
+ its `turn_id` stays the message's own, and `--turn` with it waits for that
92
+ turn again.
86
93
 
87
94
  ```json
88
95
  {"status":"question","session_id":"297da360-…","question":{"id":"3f1c…","kind":"question",
@@ -198,6 +205,14 @@ answers from any local process.
198
205
  - If the worker dies while a question waits, the question goes with it.
199
206
  `chi answer` then exits 1 and says to send the task again
200
207
  (`chi send --wait -m "…" ID`); a wait ends with `worker_gone` (exit 1).
208
+ - `worker_gone` follows one rule everywhere (`ReplyWait::GONE_AFTER`): no
209
+ worker has owned the session for 5 s while the wait runs (it died without
210
+ marking the session). A worker chi just started is awaited first (`chi send
211
+ --new`, a `delegate` child), so a slow boot isn't read as a death; a
212
+ delegate child whose worker never starts is reported `worker_gone` ("its
213
+ worker did not start") once the boot limit (30 s) passes. That await is
214
+ part of the `delegate` call's wait: the call's `timeout` bounds it (the
215
+ child then reads `running`) and the parent's Stop ends it.
201
216
  - A follow-up (`chi send --wait -m "…" ID`) to a session waiting for an answer
202
217
  isn't sent: it exits 1 with the `chi answer` command for the question. The
203
218
  step-limit question is the exception: it waits between turns, so a message
data/docs/testing.md CHANGED
@@ -75,6 +75,47 @@ spec/engine_turn_golden_spec.rb`, then read `git diff spec/fixtures/turn_goldens
75
75
  to `tmp/turn_goldens/<golden>.actual` and names the `diff` to run. Changes to the turn path (engine, kernel loop, chat loop,
76
76
  tool runner, gate, guardrail wiring, system prompt, tool declarations, registry) pick it in `script/affected-specs`.
77
77
 
78
+ The model-setting characterizations (`spec/engine_effective_model_characterization_spec.rb`,
79
+ `spec/engine_session_model_settings_characterization_spec.rb`, `spec/web/app_session_settings_characterization_spec.rb`
80
+ over `spec/fixtures/effective_model/`, and `spec/model_setting_precedence_characterization_spec.rb`'s ladders over
81
+ `spec/fixtures/model_setting_precedence/`) pin the settings on the code as it was before the `ModelLookup` refactor
82
+ (`UPDATE_GOLDENS=1` regenerates them after an intended change only). They are retired after their refactor: the
83
+ next round folds the first three into one wiring example each in `effective_model_spec.rb`,
84
+ `session_model_settings_spec.rb` and `session_setting_spec.rb`, and the ladders the round after into the setting
85
+ modules' and `model_lookup_spec.rb`.
86
+
87
+ The session file goldens (`spec/session_file_goldens_spec.rb`, `spec/fixtures/session_goldens/`) pin the bytes `Session#save`
88
+ writes: every field away from its default, a new session, a newer chi's file, an old chi's file in a git repo, every key
89
+ present as `null`, the plain writers' raw values, a running turn's checkpoint and the scrub (the `*_in.json` files are
90
+ hand-written inputs, never regenerated); and the file each path that makes or rewrites a session writes (a fork, a
91
+ continue, a delegate child, a plain spawn, a review, a Verify fresh child, a resume, a respawn, an archive), with
92
+ `carry_matrix.json`, what each keeps of its source. They are pinned before Session's fields became one table and are kept
93
+ as the file format's pins, not retired: a diff is a change to every session file on disk, so only a commit that means to
94
+ change the format regenerates them (`UPDATE_GOLDENS=1 bundle exec rspec spec/session_file_goldens_spec.rb`).
95
+
96
+ `UPDATE_GOLDENS=1` is the characterization specs' variable (the prompt and turn goldens above use `UPDATE_PROMPTS`):
97
+ the model-setting ones above, the session file goldens, claude-peer's goldens and the bundle-state corpus below. Regenerate only for an intended
98
+ change, then read the diff.
99
+
100
+ The bundle-state corpus (`spec/samagotchi/memory_bundle/file_state_corpus/`, `spec/fixtures/bundle_states/`) pins what
101
+ every bundle file state does under every command that reads or writes it. Each case in `memory_cases.yml` (a memory:
102
+ owned, edited, behind, missing, dropped, the user's own, another bundle's too…) and `asset_cases.yml` (a rule, the
103
+ plugin, a script, a hook) is written directly as a starting tree, in the 0.155 and the 0.149 record shape, and run
104
+ through install, `--force`, `--dry-run`, upgrade and its flags, chi update's plan, Remerge, the system bundle's restore,
105
+ uninstall and status. A cell holds only what a user or the next command sees (the bytes, the trash, base and checksum,
106
+ the mark, the summary's word, status's label, the warnings), never the Installer's result strings. `expect/*.yml` are
107
+ generated (`UPDATE_GOLDENS=1 bundle exec rspec spec/samagotchi/memory_bundle/file_state_corpus/`, which also writes
108
+ `tmp/bundle_states/review.<file>.txt`, every cell in words); a mismatch names the case, command and shape and writes
109
+ `tmp/bundle_states/<file>.yml.actual` to diff. A regeneration asserts nothing, so run the folder again without
110
+ `UPDATE_GOLDENS`: that run checks the new cells against `cases_spec.rb`, which holds what every regeneration keeps. No
111
+ user bytes (neither the bundle's text nor what the record vouches for, its checksum first) are changed or removed without
112
+ a copy in the trash first; the two record shapes give one answer except on a marked case (an allowlist entry needs a
113
+ note); and for every asset, chi update's plan lists as replaced exactly the files the upgrade copies to the trash. To stay
114
+ under 3 s, `upgrade_spec.rb` runs the 0.149 shape for marked cases only (`MARKED_ONLY_149`), where the shapes can differ. A regenerated cell is
115
+ reviewed: a refactor that turns the corpus red stops and asks, and only a commit that means to change a cell
116
+ regenerates, its changed cells read one by one; a cell that looks wrong today carries a `divergent:` tag and a note in
117
+ its case.
118
+
78
119
  The web specs run in plain node. One that loads lit-html, itself or through a module it imports (`turn_block.js`,
79
120
  `turn_html.js`, `card.js`, `copy.js`, `images.js`, `scope.js`, `command_view.js`, `diff_view.js`, `read_view.js`,
80
121
  `question_cards.js`, `stream_handlers.js`, `stage_view.js`, `bubbles.js`, `cards_place.js`, the chips, `info_bar.js`, `info_bar_model.js`, `lists_view.js`, `context_chips.js`, `families_view.js`,
@@ -124,7 +165,15 @@ Each level owns its own share; a detail is asserted once, at the lowest level th
124
165
  - **e2e** is for real-browser behaviour (layout, focus, reload, two tabs), one happy path per Ruby↔web mechanism and
125
166
  a few journeys. Before adding a test, extend an existing journey's step; a variant goes to node, a Ruby↔web shape
126
167
  variant to a shared fixture (`spec/fixtures/composer_routes.json`). A journey names its stages with `test.step`
127
- (the report names the one that failed: web.spec.js's notifications). The `lan` project's chi web is on the LAN only
168
+ (the report names the one that failed: web.spec.js's notifications). A turn test ends with
169
+ `expectSameAfterReload` (spec/e2e/support/history_shape.js): the history's shape live, then after a reload, the
170
+ same (live is the truth). Its allowlist is `history_shape.js ACCEPTED`, each entry with its reason; a test names
171
+ the entries its scenario has (only deliberate ones are left). A drawn turn's variants are node's: the shared
172
+ scenarios in `spec/shared/turn_drawings/` (written from `spec/support/turn_drawing_scenarios.rb`;
173
+ `UPDATE_TURN_DRAWINGS=1 bundle exec rspec spec/bridge/turn_drawings_spec.rb spec/web/session_show_drawings_spec.rb`
174
+ rewrites their lines and show after an intended change) feed `turn_drawing_parity.test.js`, which draws each one
175
+ live (the real stream handlers and stage) and from its drawing and compares the shapes with no allow list. The
176
+ `lan` project's chi web is on the LAN only
128
177
  when the machine has a private IPv4: without one its LAN tests skip and the participant review journey runs on
129
178
  loopback.
130
179
 
@@ -254,6 +303,7 @@ any other file requires it, so a spec file run alone still finds its requires. E
254
303
  | `lock_holder.rb` | *helper*: `spawn_lock_holder(session_dir)`, a stand-in worker process holding a session's owner lock, which says so on a pipe |
255
304
  | `integration_server.rb` | *helper*: the live model server `:integration` examples use, from `SAMAGOTCHI_INTEGRATION_*` ([Integration specs](#integration-specs)) |
256
305
  | `mcp_bundle.rb` | The shipped mcp bundle's path and the fake MCP server its specs start (`MCP_FAKE_COMMAND`, no RubyGems) |
306
+ | `model_setting_ladders.rb` | shared context "model setting ladders": per setting a config, env and CLI with every layer set, peeled top down (`peel`), against `spec/fixtures/model_setting_precedence/` |
257
307
  | `no_console.rb` | Loaded first (`ruby -r`) by spawned bin/chi children: `IO.console` is nil, so output never wraps at a terminal's width |
258
308
  | `recording_surface.rb` | `RecordingSurface`: a `TerminalUI::Surface` that records commits and slots instead of drawing |
259
309
  | `remote_backend.rb` | `RemoteBackend`: `SessionAccess::Remote` against chi web's Rack app in this process (no socket, LAN token gate) |
@@ -334,7 +384,8 @@ files it changed (`lib/samagotchi/foo/bar.rb` → `spec/foo/bar_spec.rb`, `bar_*
334
384
  `lib/samagotchi/foo/bar/baz.rb` → `spec/foo/bar_baz_spec.rb`), the specs that read
335
385
  docs or source as text (its `RULES`: add one when a full run catches what the names missed) and the golden of a changed
336
386
  file's layer (any `foo/*.rb` → `spec/foo_golden_spec.rb` when there is one; `turn_flow.rb` and `turn_note.rb`, whose
337
- words the attached TUI prints, pick its `event_renderer` and `attached_loop` specs by a rule). Changes to `tool_declarations.rb` and `system_prompt.rb` also pick the
387
+ words the attached TUI prints, pick its `event_renderer` and `attached_loop` specs by a rule; `memory_bundle/*.rb`,
388
+ `bundle_command.rb`, `memory_paths.rb`, `model_overlay.rb` and the corpus's fixtures pick the bundle-state corpus). Changes to `tool_declarations.rb` and `system_prompt.rb` also pick the
338
389
  effective-model, request, prompt-snapshot and full system-prompt goldens because they hash the request's prompt and
339
390
  tools. Then come the other name matches (a spec named after several changed files first, e.g. `engine_toolkit_spec.rb` for `engine.rb` and
340
391
  `toolkit.rb`; then one beside the file, so `plugin/context.rb` picks `spec/samagotchi/plugin/*context*` before
@@ -138,7 +138,8 @@ module Samagotchi
138
138
  end
139
139
  # Answered here, or no longer open (answered elsewhere, or a newer
140
140
  # question): what comes next either way.
141
- wait_for_reply(id, cursor: start.cursor, baseline: start.baseline, timeout: options[:timeout], turn: start.own_turn)
141
+ wait_for_reply(id, target: ReplyWait::Target.answer(start),
142
+ timeout: options[:timeout])
142
143
  end
143
144
 
144
145
  # The labels to send. A number is an option's place (1-based); an
@@ -4,6 +4,7 @@ require "json"
4
4
  require "fileutils"
5
5
  require "time"
6
6
  require_relative "atomic_file"
7
+ require_relative "session_paths"
7
8
 
8
9
  require_relative "session"
9
10
 
@@ -46,7 +47,7 @@ module Samagotchi
46
47
 
47
48
  # @return [Boolean] whether it was archived (and is not now)
48
49
  def self.unarchive(session_id, state_dir:)
49
- return false unless archived?(Session.session_dir(session_id, state_dir: state_dir))
50
+ return false unless archived?(SessionPaths.session_dir(session_id, state_dir: state_dir))
50
51
 
51
52
  write(session_id, { "unarchived_at" => Time.now.iso8601(3) }, state_dir: state_dir)
52
53
  end
@@ -63,9 +64,9 @@ module Samagotchi
63
64
  end
64
65
 
65
66
  def self.write(session_id, record, state_dir:)
66
- return false unless File.exist?(Session.session_file(session_id, state_dir: state_dir))
67
+ return false unless File.exist?(SessionPaths.session_file(session_id, state_dir: state_dir))
67
68
 
68
- dir = Session.session_dir(session_id, state_dir: state_dir)
69
+ dir = SessionPaths.session_dir(session_id, state_dir: state_dir)
69
70
  FileUtils.mkdir_p(dir)
70
71
  path = File.join(dir, FILE)
71
72
  AtomicFile.write(path, JSON.generate(record))
@@ -3,6 +3,7 @@
3
3
  require "rbconfig"
4
4
  require_relative "config"
5
5
  require_relative "model_profile"
6
+ require_relative "host_address"
6
7
  require_relative "host_registry"
7
8
  require_relative "host_keys"
8
9
  require_relative "chi_home"
@@ -342,7 +343,7 @@ module Samagotchi
342
343
  # The "loading the model?" hint fits a local server (a llama.cpp loading
343
344
  # weights); a remote provider's slow answer is the network, not a load.
344
345
  def loading_hint?(result)
345
- !HostRegistry.remote_address?(result.candidate.scheme, result.candidate.host)
346
+ !HostAddress.remote_address?(result.candidate.scheme, result.candidate.host)
346
347
  end
347
348
 
348
349
  # 0 written, 1 written but not as planned, nil with @exit on a refusal.
@@ -489,7 +490,7 @@ module Samagotchi
489
490
  when :skipped then "skipped (#{result.error})"
490
491
  else "failed (#{result.error})"
491
492
  end
492
- @stdout.puts("system bundle: #{text}")
493
+ @stdout.puts("system bundle: #{[text, *result.warnings].join("; ")}")
493
494
  result.status != :failed
494
495
  end
495
496
 
@@ -18,6 +18,15 @@ module Samagotchi
18
18
  # comes after (a prompt's turn_id, a line's opening_id), which the page
19
19
  # finds by its bubble, not by counting. Pure: no Engine, no file.
20
20
  class CardPlaces
21
+ # An opening's stamp: its turn_id (a prompt's, a wake note's), else its
22
+ # own opening_id (/continue no's reason line); nil for neither (a line
23
+ # an older chi wrote). The recap's too (RecapPlace).
24
+ # @return [String, nil]
25
+ def self.stamp(message)
26
+ id = message[:turn_id] || message["turn_id"] || message[:opening_id] || message["opening_id"]
27
+ id.is_a?(String) && !id.empty? ? id : nil
28
+ end
29
+
21
30
  # @param turn_log [Array<String>] the turn ids, oldest first
22
31
  # @param messages [Array<Hash>] the messages the page draws
23
32
  def initialize(turn_log, messages)
@@ -114,10 +123,7 @@ module Samagotchi
114
123
  position >= 0 ? @last_at_or_before[position] : nil
115
124
  end
116
125
 
117
- def stamp_of(message)
118
- id = message[:turn_id] || message["turn_id"] || message[:opening_id] || message["opening_id"]
119
- id.is_a?(String) && !id.empty? ? id : nil
120
- end
126
+ def stamp_of(message) = self.class.stamp(message)
121
127
 
122
128
  def idless_run(opening)
123
129
  @ids.drop(opening + 1).take_while(&:nil?).size
@@ -147,6 +147,9 @@ module Samagotchi
147
147
  stamp = places&.opening_of(entry)
148
148
  out[:opening_id] = stamp if stamp
149
149
  out[:current] = current
150
+ # Whose row it is (its turn's id, +of_turn+): the page leaves a drawn
151
+ # turn's own rows and cards to its drawing (Bridge::TurnDrawings).
152
+ out[:of_turn] = entry[:turn_id] if shown[:in_turn] && entry[:turn_id]
150
153
  out[:during] = true if entry[:during]
151
154
  out[:after_failed] = true if entry[:after_failed] && entry[:turn_id] == @failed_turn
152
155
  out[:label] = "from #{entry[:command]}" if entry[:command]
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../events"
4
4
  require_relative "../kernel_loop"
5
+ require_relative "../log"
5
6
  require_relative "../steer"
6
7
  require_relative "../tool_output_cut"
7
8
  require_relative "../tool_row_fields"
@@ -23,10 +24,17 @@ module Samagotchi
23
24
  # its head and tail: a reload's cap, so a joined row is the row a
24
25
  # reload draws
25
26
  # @param clock [#call] monotonic seconds: a tool call's duration
27
+ # @param on_turn_end [#call, nil] (turn, end_event): the turn's final
28
+ # fold (a deep copy, #current_turn's shape without elapsed_ms) and its
29
+ # end event, at the end event, before the turn is cleared (the Bridge's
30
+ # TurnDrawings#append). It runs under this accumulator's mutex and the
31
+ # SessionObserver's lock: it must never call back in here. An error is
32
+ # logged and the fold goes on.
26
33
  def initialize(max_output_chars: ToolOutputCut::WEB_PREVIEW_CHARS,
27
- clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
34
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, on_turn_end: nil)
28
35
  @max_output_chars = max_output_chars
29
36
  @clock = clock
37
+ @on_turn_end = on_turn_end
30
38
  @mutex = Mutex.new
31
39
  @turn = nil
32
40
  # When each running tool call started, by [iteration, call_index].
@@ -47,13 +55,14 @@ module Samagotchi
47
55
 
48
56
  # @return [Hash, nil] a copy of the turn in progress: prompt, turn_id, origin,
49
57
  # continue, at (its wall time), ordered parts (generation / thinking / text / tool /
50
- # input / steer / reminder / notice), pending_question and the last event_seq folded
58
+ # input / steer / reminder / notice / card), pending_question and the last event_seq folded
51
59
  # in. A finished tool part keeps the live row's action and duration_ms
52
60
  # too, a running one the time it has run so far (elapsed_ms), so a
53
61
  # join times its row from its start. A notice part holds the event
54
62
  # of one of the turn's rows (TurnNotice: a hook's notice, an
55
63
  # empty-answer retry, a question and its answer), which a UI replays
56
- # through its live handler.
64
+ # through its live handler. A card part is a card the turn showed in
65
+ # its step (in_turn), its latest version in the place of its first.
57
66
  def current_turn
58
67
  @mutex.synchronize do
59
68
  next nil unless @turn
@@ -132,10 +141,13 @@ module Samagotchi
132
141
  # the live activity: action, tool, params, status), a merged_input
133
142
  # (the one synthetic type) per merged prompt, a steer-only
134
143
  # pending_input_merged per steer, a reminder_injected, and a notice
135
- # part's own event.
144
+ # part's own event. Card parts only with +cards+ (a finished turn's
145
+ # drawing, chi web's unfold of it): a running turn's join gets its cards
146
+ # from CardStore's snapshot.
136
147
  # @param started_at [String, nil] the turn's start, on turn_started
148
+ # @param cards [Boolean] replay card parts as their card events
137
149
  # @return [Array<Hash>] symbol keys and types; [] with no turn
138
- def self.replay_events(turn, started_at: nil)
150
+ def self.replay_events(turn, started_at: nil, cards: false)
139
151
  return [] unless turn
140
152
 
141
153
  started = { type: :turn_started, prompt: turn[:prompt], origin: turn[:origin], continue: !!turn[:continue] }
@@ -175,14 +187,66 @@ module Samagotchi
175
187
  when "steer"
176
188
  close_text.call
177
189
  events << { type: :pending_input_merged, count: 0, content: nil, steers: [{ source: part[:source], text: part[:text] }] }
190
+ when "card"
191
+ next unless cards && part[:event].is_a?(Hash)
192
+
193
+ close_text.call
194
+ events << part[:event].merge(type: :card, in_turn: true)
178
195
  end
179
196
  end
180
197
  events
181
198
  end
182
199
 
200
+ # The page's unfold of a session view (chi web's show sends it as
201
+ # `replay`; turn_events.js snapshotEvents was its JS twin): the turn in
202
+ # progress as #replay_events has it, its turn_started with what the
203
+ # page also reads (its wall time `at`, the run's `cut_after`, and every
204
+ # author key, a pair turn's participant_mode too), then a turn_enqueued
205
+ # per prompt queued behind it and a command_queued per command waiting
206
+ # for its end (spec/shared/turn_snapshot.json pins both sides). The TUI
207
+ # keeps #replay_events.
208
+ # @param current_turn [Hash, nil] #current_turn's shape, symbol keys
209
+ # (a snapshot's: .from_wire)
210
+ # @param queued [Array<Hash>] #queued's entries
211
+ # @param queued_commands [Array<Hash>] #queued_commands' entries
212
+ # @return [Array<Hash>] symbol keys and types
213
+ def self.join_events(current_turn:, queued: [], queued_commands: [], started_at: nil)
214
+ events = replay_events(current_turn, started_at: started_at)
215
+ if (started = events.first)
216
+ started[:at] = current_turn[:at] if current_turn[:at].is_a?(String)
217
+ started[:cut_after] = current_turn[:cut_after] unless current_turn[:cut_after].nil?
218
+ Steer::AUTHOR_KEYS.each { |key| started.delete(key) }
219
+ Steer::AUTHOR_KEYS.each { |key| started[key] = current_turn[key] if current_turn[key] } if current_turn[:author]
220
+ end
221
+ Array(queued).each do |entry|
222
+ enqueued = { type: :turn_enqueued, enqueued_id: entry[:enqueued_id], client_id: entry[:client_id], prompt: entry[:prompt] }
223
+ enqueued[:delivery] = entry[:delivery] if entry[:delivery]
224
+ enqueued[:author] = entry[:author] if entry[:author]
225
+ events << with_images(enqueued, entry[:images])
226
+ end
227
+ Array(queued_commands).each do |entry|
228
+ command = { type: :command_queued, command_id: entry[:command_id], client_id: entry[:client_id], line: entry[:line],
229
+ waits: "turn_end" }
230
+ command[:author] = entry[:author] if entry[:author]
231
+ events << command
232
+ end
233
+ events
234
+ end
235
+
236
+ # A Bridge snapshot's part (JSON: string keys) as the accumulator's own
237
+ # shapes: symbol keys all the way down, values as they are.
238
+ def self.from_wire(value)
239
+ case value
240
+ when Hash then value.to_h { |key, item| [key.is_a?(String) ? key.to_sym : key, from_wire(item)] }
241
+ when Array then value.map { |item| from_wire(item) }
242
+ else value
243
+ end
244
+ end
245
+
183
246
  def self.tool_replay_events(part)
184
247
  call = { iteration: part[:iteration], call_index: part[:call_index], tool: part[:tool] }
185
248
  call[:label] = part[:label] if part[:label]
249
+ call[:tool_id] = part[:tool_id] if part[:tool_id]
186
250
  # Its row fields (ToolRowFields), each in its place: title and, for
187
251
  # an alias's call, the name the model used, also in the activity.
188
252
  title = part.slice(*ToolRowFields::ACTIVITY_KEYS).compact
@@ -296,6 +360,7 @@ module Samagotchi
296
360
 
297
361
  fold_turn_event(event)
298
362
  if Events::TURN_END.include?(type)
363
+ turn_ended(event)
299
364
  @turn = nil
300
365
  @merged_origins = []
301
366
  @working_set = nil
@@ -326,6 +391,8 @@ module Samagotchi
326
391
  part = { kind: "tool", iteration: event[:iteration], call_index: event[:call_index],
327
392
  tool: event[:tool], params: event[:params], status: "running" }
328
393
  part[:label] = event[:label] if event[:label]
394
+ # The id its output is saved under (ToolIds; not a row field).
395
+ part[:tool_id] = event[:tool_id] if event[:tool_id]
329
396
  ToolRowFields::KEYS.each { |key| part[key] = event[key] if event[key] }
330
397
  parts << part
331
398
  @tool_started_at[[event[:iteration], event[:call_index]]] = @clock.call
@@ -342,16 +409,26 @@ module Samagotchi
342
409
  # Only System 1's auto-allow is drawn on the row (the web's label).
343
410
  guardrail = event.dig(:activity, :guardrail)
344
411
  tool[:guardrail] = guardrail if guardrail.is_a?(Hash) && guardrail[:decided_by] == "system one"
345
- tool[:output] = capped ? ToolOutputCut.preview(output, cap: @max_output_chars, tool: event[:tool]) : output.dup
412
+ # The event's web preview (ToolRunner: the reload's cut) when it
413
+ # has one; an older worker's event is cut here the same way.
414
+ tool[:output] = if event[:preview] then event[:preview].to_s.dup
415
+ elsif capped then ToolOutputCut.preview(output, cap: @max_output_chars, tool: event[:tool])
416
+ else output.dup
417
+ end
346
418
  tool[:output_truncated] = capped || !!event[:output_truncated]
347
419
  tool[:images] = event[:images] if event[:images]
348
420
  tool[:diff] = event[:diff] if event[:diff]
349
421
  action = event.dig(:activity, :action)
350
422
  tool[:action] = action.to_s if action
351
- # As the live row times it (EventRenderer): from tool_call_started,
352
- # less an approval wait.
423
+ # The event's (ToolRunner's: the figure every UI shows); without
424
+ # one (an older worker's event), as the live row times it: from
425
+ # tool_call_started, less an approval wait.
353
426
  started_at = @tool_started_at.delete([event[:iteration], event[:call_index]])
354
- tool[:duration_ms] = [((@clock.call - started_at) * 1000) - event[:waited_ms].to_f, 0].max.round if started_at
427
+ if event[:duration_ms].is_a?(Integer)
428
+ tool[:duration_ms] = event[:duration_ms]
429
+ elsif started_at
430
+ tool[:duration_ms] = [((@clock.call - started_at) * 1000) - event[:waited_ms].to_f, 0].max.round
431
+ end
355
432
  when :pending_input_merged
356
433
  # A steer-only merge (count 0) has no user part: the origins stay
357
434
  # for the user lines' own merge.
@@ -366,6 +443,8 @@ module Samagotchi
366
443
  end
367
444
  when :working_set_updated
368
445
  @working_set = { items: event[:items], at: event[:at] }
446
+ when :card
447
+ fold_card(parts, event) if event[:in_turn]
369
448
  when :reminder_injected
370
449
  parts << { kind: "reminder", reminders: Array(event[:reminders]).map(&:dup) }
371
450
  when :question_requested
@@ -382,6 +461,27 @@ module Samagotchi
382
461
  parts << { kind: "notice", event: TurnNotice.slice(event) } if TurnNotice.notice?(event)
383
462
  end
384
463
 
464
+ # The card fields a UI draws (CardStore keeps the same), a copy.
465
+ CARD_FIELDS = %i[id source title body level actions].freeze
466
+ private_constant :CARD_FIELDS
467
+
468
+ # A card the turn shows in its step; one with an earlier card's id
469
+ # replaces that part where it was.
470
+ def fold_card(parts, event)
471
+ card = { kind: "card", id: event[:id], event: Marshal.load(Marshal.dump(event.slice(*CARD_FIELDS))) }
472
+ index = parts.index { |part| part[:kind] == "card" && part[:id] == card[:id] }
473
+ index ? parts[index] = card : parts << card
474
+ end
475
+
476
+ # The turn's final fold to the hook (#initialize's on_turn_end).
477
+ def turn_ended(event)
478
+ return unless @on_turn_end
479
+
480
+ @on_turn_end.call(Marshal.load(Marshal.dump(@turn)), event)
481
+ rescue StandardError => e
482
+ Log.warn(:bridge, "turn_end_hook_failed", error: "#{e.class}: #{e.message}")
483
+ end
484
+
385
485
  def append_text(iteration, kind, text)
386
486
  return if text.nil? || text.empty?
387
487