samagotchi 0.63.0 → 0.64.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/docs/architecture.md +12 -2
  4. data/docs/cli.md +16 -8
  5. data/docs/configuration.md +15 -8
  6. data/docs/internals/engine.md +10 -3
  7. data/docs/plugins.md +7 -0
  8. data/docs/sessions.md +11 -8
  9. data/docs/sub-agent.md +21 -4
  10. data/docs/testing.md +6 -0
  11. data/lib/samagotchi/answer_command.rb +5 -1
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  13. data/lib/samagotchi/bridge.rb +18 -4
  14. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/child_reports.rb +7 -2
  19. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  20. data/lib/samagotchi/composer_route.rb +90 -0
  21. data/lib/samagotchi/config.rb +6 -3
  22. data/lib/samagotchi/continue_offer.rb +17 -3
  23. data/lib/samagotchi/delivery.rb +13 -8
  24. data/lib/samagotchi/engine.rb +2 -0
  25. data/lib/samagotchi/host_config.rb +6 -6
  26. data/lib/samagotchi/host_model.rb +2 -2
  27. data/lib/samagotchi/interrupted_turn.rb +7 -3
  28. data/lib/samagotchi/last_turn.rb +5 -2
  29. data/lib/samagotchi/pending_input_queue.rb +10 -3
  30. data/lib/samagotchi/plugin/sessions.rb +27 -5
  31. data/lib/samagotchi/reply_wait.rb +171 -6
  32. data/lib/samagotchi/send_command.rb +63 -19
  33. data/lib/samagotchi/session_commands.rb +1 -1
  34. data/lib/samagotchi/session_inbox.rb +63 -7
  35. data/lib/samagotchi/session_manager.rb +49 -20
  36. data/lib/samagotchi/terminal_ui/attached_loop.rb +47 -26
  37. data/lib/samagotchi/terminal_ui/formatting.rb +3 -19
  38. data/lib/samagotchi/terminal_ui.rb +72 -57
  39. data/lib/samagotchi/tool_activity.rb +5 -5
  40. data/lib/samagotchi/tools/delegate.rb +28 -1
  41. data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
  42. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  43. data/lib/samagotchi/tools/execute.rb +11 -4
  44. data/lib/samagotchi/turn_checkpoint.rb +9 -3
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/public/app.js +6 -0
  47. data/lib/samagotchi/web/public/commands.js +8 -8
  48. data/lib/samagotchi/web/public/composer.js +3 -3
  49. data/lib/samagotchi/web/public/timing.js +2 -1
  50. data/lib/samagotchi/web/public/turn_model.js +13 -3
  51. data/lib/samagotchi/worker.rb +7 -5
  52. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20b05cd0da6dfdd621eba3b2add37f00b2abe96c2e3950d8ddd066bdf3078424
4
- data.tar.gz: ecf5784f9f82c8a8c9e813185570d326a3f4d9ad3a404033f53abf1b62a20902
3
+ metadata.gz: 61245b8998baef2bbb4c29cabc648314b4cc124cf9233099948cb7cf66dc890e
4
+ data.tar.gz: ac4e8fa165b5cc59d683b1541a3758e03f60a1d14b7fd9cded087770fda162de
5
5
  SHA512:
6
- metadata.gz: 302c8ccf54cc947c9165fe8e30f4d32b4054ee02a1e2d62cbb7d7942a30574af58af84a7eafc5b2daf24a8f167e98024e2798fcacccf597b919435019d498613
7
- data.tar.gz: b6ac96ff05fb4e52a5705b55de3fa505054860b154cd6622c4badc232c6284bc5e80d57ffc94b13a7eae1384557acd5b697b630a0ed21be1a8e85d99610b3d04
6
+ metadata.gz: 717ca2421c97d4f93493891e4f695528a0b8495ab11a2b47c348b29c726004abb32e638ab21b3e6af62676acfc4cc31c45527bdb9dfe72a9a270b3f7bfda3e02
7
+ data.tar.gz: 4916e876cf70f3b1b03e8c7554a127019e08e3b6f2e991305c8fe63ce1f6cf399155c43167ff36382c09eb2774792a4783ccbe39109702df6bd4efbe67a1ff53
data/CHANGELOG.md CHANGED
@@ -8,6 +8,50 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.64.0] - 2026-10-13
12
+
13
+ ### Added
14
+
15
+ - `/queue TEXT` during a running turn now works in an attached terminal and the web composer too, as it did in the
16
+ plain REPL: TEXT runs as a turn of its own after the running one, instead of merging into it (`(queued: runs after
17
+ this turn)`). A session whose worker is older than this says to restart it rather than steering the turn. See
18
+ docs/cli.md, "Typing during a turn".
19
+ - `chi send --queue`: the message runs as a turn of its own after the running turn instead of merging into it, and
20
+ `chi send --queue --wait` waits for that turn's own answer (a running turn no longer refuses it; the running turn's
21
+ reply, end and questions are not the answer). Each turn's reply file now carries its message's id. See
22
+ docs/sessions.md, "Sending a message", and docs/sub-agent.md.
23
+ - Plugins: `ctx.sessions.send(id, text, delivery: :queue)` runs the message as a turn of its own after the session's
24
+ running turn; `delivery: :cut` raises (plugins never cut). See docs/plugins.md.
25
+
26
+ ### Changed
27
+
28
+ - Plain REPL (`--no-shared`): the lines a turn left over (a steer it didn't merge, `/queue` lines, image lines, commands
29
+ that wait) run in the order you sent them; before, an unmerged steer ran ahead of a `/queue` line typed earlier.
30
+ - A message with images sent during a running turn no longer holds back the text lines sent after it: they go in at
31
+ the turn's next step as usual, and the image message still runs as a turn of its own after it.
32
+
33
+ ### Fixed
34
+
35
+ - Stopping a session mid-turn (`chi sessions stop`, the web's stop, a parent stopping its delegate) cancels the turn
36
+ with its own reason, `session_stopped`, instead of the user's Stop: the turn's line reads "canceled (session
37
+ stopped)", a command it was running "stopped as the session was stopped" (slow-commands 0.2.2 counts it as
38
+ stopped, not slow), and `chi send --wait --format json` reports `cancel_reason: session_stopped`.
39
+ - A delegate (and any worker a session's own commands start, such as `chi send --new`) reads `hosts:` from config.yml
40
+ as it starts, so it sees a host added or changed after its parent's worker started; before, it got the parent's
41
+ start-time hosts through `SAMAGOTCHI_HOSTS_JSON`, which workers no longer inherit (one set in your shell
42
+ applies to that chi process only; put hosts in config.yml).
43
+ - Web: a step that only called plugin tools with a verb label is titled by the label ("echoing"), not "working
44
+ with echoing"; among other tools such a call is named by its tool ("working with read, echo").
45
+ - Web: a send from a stale tab into a session deleted meanwhile goes back to the start page with a "was not
46
+ found" notice, the line kept in the composer, instead of an alert.
47
+ - `chi send`, the web composer and a plugin's `ctx.sessions.send` now report a message the session's worker refuses
48
+ (a 4xx answer such as a delivery it doesn't run) instead of writing it as an input file the worker would merge
49
+ anyway; a body over the worker's size limit still goes in as a file, as before.
50
+ - `chi answer`, `chi send --wait ID` (no message) and `delegate_result` wait for the reply of the turn they are about
51
+ (the answered turn, the running turn, the follow-up's turn), not a newer one queued behind it; a Continue of the
52
+ step-limit question gets the continued turn's reply, and a Stop ends the wait as `not_continued`. Workers older
53
+ than this keep the old behaviour.
54
+
11
55
  ## [0.63.0] - 2026-10-13
12
56
 
13
57
  ### Added
@@ -2750,7 +2794,8 @@ and long-lived sessions.
2750
2794
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
2751
2795
  hotkey panel that send selected text or the clipboard to your sessions.
2752
2796
 
2753
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.63.0...HEAD
2797
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.64.0...HEAD
2798
+ [0.64.0]: https://github.com/dm1try/samagotchi/compare/v0.63.0...v0.64.0
2754
2799
  [0.63.0]: https://github.com/dm1try/samagotchi/compare/v0.62.0...v0.63.0
2755
2800
  [0.62.0]: https://github.com/dm1try/samagotchi/compare/v0.61.0...v0.62.0
2756
2801
  [0.61.0]: https://github.com/dm1try/samagotchi/compare/v0.60.0...v0.61.0
data/docs/architecture.md CHANGED
@@ -73,6 +73,12 @@ TerminalUI ── delegates ──▶ Engine
73
73
  delegates all core work to an `Engine`.
74
74
  - The `on_event:` seam on `run_turn` exposes raw `KernelLoop` events plus the
75
75
  higher-level turn events, so any new UI can render without terminal coupling.
76
+ - **`ComposerRoute`** (`lib/samagotchi/composer_route.rb`) decides where a line typed at
77
+ either terminal's prompt goes (the REPL's `steer_line` / `run_input_line`, the attached
78
+ `AttachedLoop#submit`): `/cut TEXT` / `/queue TEXT`, a terminal's own word, a session
79
+ command, a typo, a prompt. Each UI keeps what it does with the route. The web's
80
+ `composer.js#routeComposerLine` decides the same; `spec/fixtures/composer_routes.json`
81
+ holds the cases both answer (see [testing.md](testing.md)).
76
82
 
77
83
  ## Request / turn flow
78
84
 
@@ -369,7 +375,11 @@ session every 10 s, so a `kill -9` shows within a second. The summary (`Web::Ses
369
375
  shared with `/api/sessions` and the session view) carries `owner`, `project_root` and
370
376
  `bridge_up` (the sidecar is there *and* the lock is held: the page attaches its stream on it), and
371
377
  from that sidecar `worker_version` (the chi the worker runs) and `worker_features`
372
- (`Bridge::FEATURES`, what a client may ask of it beyond the routes). A worker's Bridge snapshot
378
+ (`Bridge::FEATURES`, what a client may ask of it beyond the routes: `restart`, `task_stop`, `reparent`,
379
+ `delivery`, a worker that queues a message for after the turn; `SessionManager.deliver_turn` and
380
+ the attached TUI refuse a queue to a worker without it, which would steer; `turn_ids`, a worker whose running
381
+ turn's mark, continue turn and Stop carry the turn's `enqueued_id`, which `chi answer`, `chi send --wait` with no
382
+ message and `delegate_result` rely on through `ReplyWait.own_turn`). A worker's Bridge snapshot
373
383
  names its `chi_version` too.
374
384
  `POST/DELETE /api/sessions` and `/stop` rescan the session before answering (`SessionHub#touch`).
375
385
  `GET /api/sessions` is the hub's projection too (its sort, paging and total). `Server` always builds
@@ -448,7 +458,7 @@ stream events (server counts, else a chars/4 estimate). Model lists are `LLM::Mo
448
458
  server (`/props`), then the host's model list, then config.
449
459
 
450
460
  **Keys.** A host's API key comes only from the environment variable its
451
- `api_key_env:` names; it never reaches config.yml, `HOSTS_JSON`, `chi self`,
461
+ `api_key_env:` names; it never reaches config.yml, `SAMAGOTCHI_HOSTS_JSON`, `chi self`,
452
462
  logs or events.
453
463
 
454
464
  ## Other components
data/docs/cli.md CHANGED
@@ -18,7 +18,7 @@
18
18
  - `chi sessions list|stop|restart|archive|unarchive|delete|prune|clean|stats` — manage persisted sessions; `list` shows this git project's, `list --scope=all` every one, a delegated session with `↳ <parent>`, `list --archived` the archived ones too (see [Sessions](sessions.md)). `stats ID` prints a session's cost, tokens and progress (a live worker's `GET stats`, else its saved `analytics.json`; `--format json` one object) without running a model turn. A usage error (an unknown subcommand or flag, a flag missing its value, `stop`, `restart` or `delete` with no ids, a bad `list --format` or `--scope`) exits 2; an unknown or refused session exits 1
19
19
  - `chi note [--source NAME] [-m TEXT] (ID|PREFIX)... | --all` — add a context note (TEXT or stdin) to sessions: background the model sees on its next turn; it starts no turn (see [Sessions: Context notes](sessions.md#context-notes))
20
20
  - `chi context <add|push|ls|show|refresh|rm|mute|unmute> …` — attach live external text to sessions (a command chi runs every so often, or text pushed in); the model gets a short note when it changes and reads it with `context_read`; `chi context add <URL>` attaches a GitHub PR with the github-pr bundle (see [Attached context](context.md))
21
- - `chi send [-m TEXT] [--image PATH]... [--cut] (ID|PREFIX)...` — send a message to sessions as if typed there: a turn starts (or a running one takes it at its next step; `--cut` asks it to cut long thinking for it); piped stdin goes above `-m` as quoted context, and `--image` attaches images (see [Sessions: Sending a message](sessions.md#sending-a-message)); `--new` starts a session with it instead (`--new --continues ID|last:ID` the next link of that session's chain, see [Session chains](sessions.md#session-chains)), and `--wait` prints the answer (`--wait ID` with no message waits for the next reply without sending; `--format json` prints one JSON object instead; exit 3 a question waits, its options on stderr; exit 4 `--timeout` passed with the turn still running; see [Starting a session](sessions.md#starting-a-session))
21
+ - `chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...` — send a message to sessions as if typed there: a turn starts (or a running one takes it at its next step; `--cut` asks it to cut long thinking for it; `--queue` runs it as a turn of its own after the running one, and `--queue --wait` waits for that turn's own answer); piped stdin goes above `-m` as quoted context, and `--image` attaches images (see [Sessions: Sending a message](sessions.md#sending-a-message)); `--new` starts a session with it instead (`--new --continues ID|last:ID` the next link of that session's chain, see [Session chains](sessions.md#session-chains)), and `--wait` prints the answer (`--wait ID` with no message waits for the next reply without sending; `--format json` prints one JSON object instead; exit 3 a question waits, its options on stderr; exit 4 `--timeout` passed with the turn still running; see [Starting a session](sessions.md#starting-a-session))
22
22
  - `chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--format json]` (or `--dismiss`) — answer the question a session waits on (the one `chi send --wait` exited 3 with), then wait and print what comes next as `chi send --wait` does: the reply (0), the next question (3), still running after `--timeout` (4). `--option` is 1-based or the label, repeated on a multi-select question; `--text` is free text, or a Deny's reason; `--dismiss` leaves it unanswered and the model finishes its reply. A question no longer open (the web answered first) isn't answered again: it waits for that turn's reply. An option the question doesn't offer exits 2; a worker that is gone exits 1 (`send the task again: chi send --wait -m "…" ID`). An approval can be denied, not allowed, unless `guardrails.parent_approvals: once` (a convention, not a security boundary; see [Guardrails](guardrails.md#approvals-from-a-parent-agent))
23
23
  - `chi desktop install|upgrade|uninstall|status` — the macOS "Send to chi" helper: a Service and a ⌃⌥⌘N hotkey that send text or images to a session (live, stopped or new) as a message (⏎, a turn runs) or a context note (⌘⏎) (see [Desktop helper](desktop.md))
24
24
  - `chi models [--format text|json] [--timeout S] [TEXT]` — list the models every configured host offers, as the names `--model` takes (see [Listing the models](#listing-the-models))
@@ -298,8 +298,8 @@ and cuts nothing. `/cut TEXT` in a terminal or the web composer (or `chi send --
298
298
  once the model has streamed only thinking for `steer.cut_after` seconds (per host/model: `steer_cut_after`; default
299
299
  20), now or while your message waits, that generation is cut and the step starts again with your message, marked
300
300
  `↪ cut in for your message` (see
301
- [configuration.md](configuration.md#messages-during-a-running-turn-steer-and-cut)). The web
302
- composer has no `/queue` yet (it says so and sends nothing). The
301
+ [configuration.md](configuration.md#messages-during-a-running-turn-steer-and-cut)). `/queue TEXT`
302
+ (terminal or web composer, or `chi send --queue`) keeps TEXT for after the turn: it runs as a turn of its own once the running one ends. The
303
303
  first answer to an `ask_user_question` wins; the other UIs close their widget.
304
304
  An empty answer dismisses the question in every UI. The model is then told
305
305
  not to go ahead with what it asked about, or change anything else, and to wait.
@@ -541,10 +541,18 @@ REPL alike:
541
541
  20 s)` (it waits for the thinking; the step may end first) or `(cutting is
542
542
  off; goes in at the next step)` (`steer.cut_after` is 0, per host/model:
543
543
  `steer_cut_after`, or no generation to cut).
544
- - `/queue TEXT` (plain REPL only) runs TEXT as a turn of its own after this
545
- one: `(queued: runs after this turn)`. An attached terminal says
546
- `(/queue isn't available in an attached session yet)` and sends nothing (the web
547
- composer: `(/queue isn't available in the web yet; …)`).
544
+ - `/queue TEXT` runs TEXT as a turn of its own after this one, in an attached
545
+ terminal, the plain REPL and the web composer alike: `(queued: runs after this
546
+ turn)` (the web draws it as a queued bubble until its turn starts). It never
547
+ merges into the running turn, and a plain line sent after it still does.
548
+ Several queued lines run one turn each, in the order sent; they still run
549
+ after a Ctrl-C or a failed turn, and a queued line drops a continue offer as
550
+ any prompt does. Other terminals show someone else's queued line with the same
551
+ note. A session whose worker is older than queueing would steer it instead, so
552
+ it is not sent: `(/queue needs a newer worker: restart it with chi sessions
553
+ restart, then send again)` (the web shows the refusal under the line). A line
554
+ with images works the same way: it waits for the turn's end, and the lines
555
+ sent after it still go in at the next step.
548
556
  At the open prompt (no turn running) `/cut TEXT` and `/queue TEXT` run TEXT
549
557
  as a normal prompt.
550
558
  - Commands answer, wait or are refused, by what they do:
@@ -836,7 +844,7 @@ Notes:
836
844
  - Without `--default` the command is session-scoped and does not rewrite config files.
837
845
  - With `--default` the new default is written to `~/.config/samagotchi/config.yml` (honoring `XDG_CONFIG_HOME`) and takes effect for all new sessions; the current session's effective model is also updated immediately. Bare aliases and `host:model` are both valid.
838
846
  - `--default` and `--alias` change only their one key's line in `config.yml` (`default.model`, or the alias under `model_aliases:`): comments and the rest of the file stay as written, a symlinked file is written through and keeps its mode. When the edited text wouldn't read back as the expected settings (a flow-style section, anchors), chi writes the whole file out from the parsed data instead, which drops its comments.
839
- - Worker sessions inherit `hosts:` via `SAMAGOTCHI_HOSTS_JSON`.
847
+ - A worker reads `hosts:` from config.yml when it starts (not from the chi that started it).
840
848
  - The idle recap uses the session's current model (a switch counts from the next recap), unless `recap: {host_ref, model}` pins one.
841
849
 
842
850
  ## Session recap
@@ -106,7 +106,10 @@ Behavior:
106
106
  at its start until it is stopped; the web's model picker lists a `hosts:`
107
107
  edit at once). A worker gets the CLI flags of the chi that started it
108
108
  through its environment.
109
- - Workers inherit hosts via `SAMAGOTCHI_HOSTS_JSON` propagated through `SessionManager.spawn_options`.
109
+ - A worker reads `hosts:` from the file as it starts, as every other setting, so a delegate (or any session
110
+ started later) sees a host added after its parent started. `SAMAGOTCHI_HOSTS_JSON` (a JSON object in the shape
111
+ of `hosts:`) replaces the file's `hosts:` for the process it is set on, the way specs use it; no worker inherits
112
+ it (`SessionManager.spawn_options` unsets it).
110
113
 
111
114
  This lets you run `chi` without repeating common defaults such as model
112
115
  and llama host/port on every invocation.
@@ -372,7 +375,7 @@ system prompt stays the same; a server that swaps models between turns goes unno
372
375
  `server (chat_template)`, `name`, `default`); `chi self` shows what config says without asking the server. A chat
373
376
  host (`api: openai`) formats nothing itself: its profile comes from the name and only strips thought tags.
374
377
 
375
- Workers get `hosts:` (with `profile:`) through `SAMAGOTCHI_HOSTS_JSON` and read `models:` from the same config file.
378
+ Workers read `hosts:` (with `profile:`) and `models:` from the config file.
376
379
  `--profile` reaches the worker a chi starts, but a worker that another process wakes later (`chi web`, `--attach`
377
380
  after an idle exit) gets that process's environment, so put a lasting choice in config.
378
381
 
@@ -416,8 +419,8 @@ models:
416
419
 
417
420
  `/model` shows what applies (`sampling: temperature=0.6 presence_penalty=1.5 (hosts.work)`), and each request's
418
421
  `stream` line in the debug log carries a `sampling=` field with what was sent. The fields are read every turn, so a
419
- `/model` switch takes the new model's. A worker gets `hosts:` (with `sampling:`) when it starts, through
420
- `SAMAGOTCHI_HOSTS_JSON`, and reads `models:` from the config file each turn: after changing a host's `sampling:`,
422
+ `/model` switch takes the new model's. A worker reads `hosts:` (with `sampling:`) from the config file when it
423
+ starts, and `models:` each turn: after changing a host's `sampling:`,
421
424
  stop the session's worker (`chi sessions stop`) for it to take effect.
422
425
 
423
426
  ## Prices
@@ -907,7 +910,8 @@ that model's cut wait (`steer.cut_after` seconds, default `20`, `0` = never; cou
907
910
  so the wait for a busy server doesn't count) and still does, and the model starts the step again with the message
908
911
  (`↪ cut in for your message`). One that comes earlier cuts once the thinking passes it, unless the step ends first
909
912
  and the message goes in there. A plain message (from the web composer, `chi send` without `--cut`, a parent agent)
910
- never asks for a cut. Such a cut spends no `retry.empty_answer` attempt and sends no hidden note; only the cut
913
+ never asks for a cut, nor does a queued one (`/queue TEXT`, `chi send --queue`, `ctx.sessions.send(…, delivery:
914
+ :queue)`), which runs as a turn of its own after the running one. Such a cut spends no `retry.empty_answer` attempt and sends no hidden note; only the cut
911
915
  thinking is lost. A generation that has streamed visible text or a tool call is never cut, and a plugin's steer
912
916
  (`ctx.steer`, `ctx.sessions.send`) never cuts.
913
917
 
@@ -931,9 +935,12 @@ models:
931
935
 
932
936
  `/model` shows the resolved wait (`; steer cut: after 60 s (models: qwen)`, `; steer cut: never (hosts.splash)`).
933
937
 
934
- `/queue TEXT` in the plain REPL runs TEXT as a turn of its own after the running one; the attached terminal and the web
935
- don't queue yet, and a request that asks the Bridge or the web for `delivery: "queue"` is refused (400
936
- `delivery_unavailable`).
938
+ `/queue TEXT` (in a terminal or the web composer) runs TEXT as a turn of its own after the running one: the request
939
+ carries `delivery: "queue"`, the input file keeps it, and the worker's drain leaves the file for after the turn. The
940
+ Bridge acks `delivery: "queue"` while a turn runs (to an idle session the message is simply the next turn:
941
+ `next_step`). A worker whose sidecar doesn't name the `delivery` feature (older than queueing) would steer it, so the
942
+ sender refuses it (`409 delivery_unavailable`, "restart it"), and so does a worker that answers the request with a
943
+ 400 `delivery_unavailable`.
937
944
 
938
945
  ## Server errors
939
946
 
@@ -54,8 +54,10 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
54
54
  │
55
55
  ├── complete_turn(turn, result)
56
56
  │ # end_turn(turn, "completed" | "canceled"): under the event lock the session gets the kept
57
- │ # messages, goes IDLE, records last_turn, and :turn_completed {result, turn_summary,
58
- │ # display_pending} or :turn_canceled {cancellation_reason, cancelled_by, duration_ms} is emitted
57
+ │ # messages, goes IDLE, records last_turn (enqueued_id: the turn's input, from its origin;
58
+ │ # a continue turn's origin carries its offer turn's, ContinueOffer#run_continue_turn),
59
+ │ # and :turn_completed {result, turn_summary, display_pending} or :turn_canceled
60
+ │ # {cancellation_reason, cancelled_by, duration_ms} is emitted
59
61
  │ # then the :after_turn hook (status, messages, present: the AnswerDisplay presenter,
60
62
  │ # refs: the AnswerRefs adder)
61
63
  │ # store_answer_display (one replace of the answer: `display` and/or `display_refs`;
@@ -521,7 +523,12 @@ Both loops:
521
523
  - **A cut generation** (`after_cut`): a generation a plugin cut (`stop_generation`) spends the same retry budget
522
524
  with its own nudge; when it is used up, the turn ends cancelled (`:hook`). A steer's cut asks again with the
523
525
  steer and spends nothing.
524
- - **Steering**: queued input goes in at iteration boundaries (`Steer.inject!`, `:pending_input_merged`).
526
+ - **Steering**: queued input goes in at iteration boundaries (`Steer.inject!`, `:pending_input_merged`). A
527
+ worker's drain (`Worker#pending_input_drain`) takes every input file in name order except the ones
528
+ `SessionInbox.waits_for_turn_end?` leaves for a turn of their own (`delivery: queue`, or images): those are
529
+ skipped, not a barrier, so a line sent after them still merges. Only the files it claims reach
530
+ `:input_merged`, the failure hand-back (`@merged_this_turn`) and the checkpoint; the skipped ones run after the
531
+ turn, one turn each, in name order (`Worker#run_input_files`).
525
532
  - **Context status**: an estimate of the window's fill before each request,
526
533
  emitted on every request (`:context_status`) so the UI always has a current
527
534
  number, with a guidance line on the tail only when it rises far enough.
data/docs/plugins.md CHANGED
@@ -675,6 +675,7 @@ llama.cpp serves on a native host too.
675
675
  id = ctx.sessions.fork(messages: ctx.messages + [{ role: "user", content: q }, { role: "model", content: a }],
676
676
  title: "btw: #{q}")
677
677
  ctx.sessions.send(id, "go on from here")
678
+ ctx.sessions.send(id, "then the docs", delivery: :queue) # after its running turn
678
679
  ctx.sessions.read(id) # => {id:, title:, status:, parent_id:, running:, messages:}
679
680
  ctx.sessions.children # => [{id:, short_id:, state:, branch:, last_reply:, reported:, ...}]
680
681
  ctx.sessions.stop(id) # one of this session's own children
@@ -695,6 +696,12 @@ ctx.sessions.stop(id) # one of this session's own children
695
696
  5 s for the session's worker, so call it from an anytime command or a
696
697
  thread of your own, never from a tool or hook of a running turn. A
697
698
  session open in a chi REPL can't take it.
699
+ - A running turn takes it at its next step. `delivery: :queue` runs it as
700
+ a turn of its own after that turn instead (as `/queue TEXT` does; to an
701
+ idle session it simply runs). A plugin never cuts the running turn's
702
+ thinking: `delivery: :cut` raises `Samagotchi::Plugin::Sessions::Error`,
703
+ as does an unknown value. A worker older than queueing refuses a queue
704
+ (it would steer): `Error` with its reason; nothing is sent.
698
705
  - `read(id)` gives a session now: from its worker when one runs (with a
699
706
  running turn so far, `running: true`), else as saved. `messages` has no
700
707
  system prompt.
data/docs/sessions.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/sessions/<uuid>.json` (XDG-aware via `XDG_STATE_HOME`) plus a sidecar dir `<uuid>/` with `input/`/`notes/`/`output/`/`owner.lock`/`bridge.json` (`Session.session_dir`). Besides the messages and turn state, the JSON keeps `used_memory_names` (what the session read), `preloaded_memory_names` (`--memory`) and `muted_memory_names` (`--mute`), the last two written before the worker starts so a respawn builds the same prompt, `prompt_notes`, the model notes the system prompt carried at its last build (`[{name, scope, chars, digest}]`, see [Memory](memory.md#model-notes)), `parent_id`, the session that delegated this one (see *Delegating*; `null` otherwise), and `continues`, the session this one continues (the previous link of a chain, see *Session chains*; `null` otherwise); files from before those keys read as empty lists, no parent and no previous link. `model_name` is the resolved model ref (an alias applied, `host:id` when it names a host), so a resumed session keeps its model when an alias is retargeted; `model_typed` is the name it was given as when that differs (an alias), for the `models:` lookup after a resume (`null` otherwise).
4
4
 
5
+ **Input files:** a message for a session's worker waits in `input/<timestamp>.json` (`SessionInbox.write_input`: `prompt`, `client_id`, `enqueued_id`, and `images`, `no_interrupt` and `delivery` when they apply; `delivery` is written only for `"queue"`). While a turn runs, the worker merges them at each step boundary in name order, except a queued one or one with images: those wait for the turn's end and run one turn each, in name order, and a file after them still merges (`Worker#pending_input_drain`). A queue is sent only to a worker whose `bridge.json` names the `delivery` feature; an older one would steer it, so `SessionManager.deliver_turn` refuses it (409 `delivery_unavailable`), and any other 4xx the Bridge answers is a refusal too, never a fallback file (except a 413, a body over its size limit: that one still goes in as a file, its `delivery` kept). **Reply files:** a turn that ends with visible text leaves its reply in `output/<timestamp>.txt`, or `output/<timestamp>-<enqueued_id>.txt` when the turn ran an input file with an id (`SessionInbox.write_output`), and the session's `last_turn` keeps that `enqueued_id`; so a wait for one queued message (`chi send --queue --wait`, `ReplyWait`'s `turn:`) finds its own turn's reply or end, never another turn's. The names still sort by time, and every reader takes `*.txt`.
6
+
5
7
  **Retention (file-based, opt-out via env):**
6
8
 
7
9
  | Env | Default | Purpose |
@@ -23,9 +25,9 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
23
25
 
24
26
  **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.
25
27
 
26
- **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}`, written as the turn starts, at the loop's first iteration boundary and then at most every 10 s (`updated_at` moves with it). The turn's end saves the real messages without the mark. A worker that died without ending its turn (SIGKILL, a crash of the process) leaves the mark, and loses what came after its last written boundary: at most the last 10 s of steps, plus the step in flight. Each checkpoint write also saves the turn's metrics so far to `analytics.json` (tokens, speeds, timings, its finished tool calls), as the record a killed turn leaves: canceled, `cancellation_reason: worker_stopped`; the turn's own end writes over it. The next worker to start for the session (a `chi send`, `--resume`, `--attach`, the web), or a `--no-shared` REPL resuming it, settles the turn before anything else (`TurnCheckpoint.settle`, log `interrupted_turn_settled`): a note `the previous turn was cancelled (worker-stopped) after Ns` goes at the tail, the status goes idle and the mark goes; `last_turn` stays the previous turn's (a new one saved before the turn a `chi send --wait` woke the worker for would end that wait at once). The session shows the turn's prompt and steps with that note; the dead turn is not run again, and the model reads on its next turn that it was cut.
28
+ **A worker killed mid-turn:** while a turn runs, its worker keeps it on disk (`TurnCheckpoint`): the session file has the turn so far (the prompt from the turn's start, then the finished tool steps and lines merged into it) and `interrupted_turn: {id, started_at, at, iteration, enqueued_id}` (`enqueued_id`: the input the turn runs, so any process can tell which turn is running; a continue turn carries its offer turn's), written as the turn starts, at the loop's first iteration boundary and then at most every 10 s (`updated_at` moves with it). The turn's end saves the real messages without the mark. A worker that died without ending its turn (SIGKILL, a crash of the process) leaves the mark, and loses what came after its last written boundary: at most the last 10 s of steps, plus the step in flight. Each checkpoint write also saves the turn's metrics so far to `analytics.json` (tokens, speeds, timings, its finished tool calls), as the record a killed turn leaves: canceled, `cancellation_reason: worker_stopped`; the turn's own end writes over it. The next worker to start for the session (a `chi send`, `--resume`, `--attach`, the web), or a `--no-shared` REPL resuming it, settles the turn before anything else (`TurnCheckpoint.settle`, log `interrupted_turn_settled`): a note `the previous turn was cancelled (worker-stopped) after Ns` goes at the tail, the status goes idle and the mark goes; `last_turn` stays the previous turn's (a new one saved before the turn a `chi send --wait` woke the worker for would end that wait at once). The session shows the turn's prompt and steps with that note; the dead turn is not run again, and the model reads on its next turn that it was cut.
27
29
 
28
- **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`. A running turn is canceled over the Bridge first (so a command it was running says "stopped by the user"; only a bare signal to the worker makes it "stopped by a signal to chi"); the TERM stops whatever still runs as a Stop with reason `signal` and saves it (a spawned worker traps TERM, HUP and INT: `WorkerSignals`; a turn not ended within 3 s, 0.5 s after a stop or a second signal, is cut and the session saved as it was), and a stopped worker still saves its kept turn, since the marker, not the saved status, says stopped. Any TERM, HUP or INT does the same to a worker outside a stop: it leaves (log `worker stop reason=signal`) and the next send wakes a new one, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's `llm ctx` chip, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
30
+ **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`. A running turn is canceled over the Bridge first, with reason `session_stopped` (the turn's line ends "(session stopped)", its note says `cancelled (session-stopped)`, and a command it was running says "stopped as the session was stopped"; a bare signal to the worker makes it "stopped by a signal to chi"; an older worker takes the reason as `manual`); the TERM stops whatever still runs as a Stop with reason `signal` and saves it (a spawned worker traps TERM, HUP and INT: `WorkerSignals`; a turn not ended within 3 s, 0.5 s after a stop or a second signal, is cut and the session saved as it was), and a stopped worker still saves its kept turn, since the marker, not the saved status, says stopped. Any TERM, HUP or INT does the same to a worker outside a stop: it leaves (log `worker stop reason=signal`) and the next send wakes a new one, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's `llm ctx` chip, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
29
31
 
30
32
  **Deleting a session:** `chi sessions delete ID...`, `/exit --delete` in a terminal and the Web UI's delete all go through `SessionManager.delete_session`: it resolves a unique prefix, removes `<id>.json` and the whole `<id>/` dir, and returns what it removed. A session a plain REPL has open (`owner.lock` kind `tui`) is always refused. One a worker runs is refused unless the caller asks to stop it: then it stops the worker as `chi sessions stop` does and deletes once `owner.lock` is free (`--force` on the CLI, 10 s; the web always, 2 s; `/exit --delete` after the worker agreed to exit, 10 s). A worker that outlives the wait leaves the session in place. The web's route is `DELETE /api/sessions/:id` (200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
31
33
 
@@ -153,21 +155,22 @@ cancel during its thinking, it said it had never been asked.
153
155
  `chi send` is the other half of `chi note`: the text goes in as your message, the same as typing it in the attached terminal or the web composer, so a turn runs.
154
156
 
155
157
  ```sh
156
- chi send [-m TEXT] [--image PATH]... [--cut] (ID|PREFIX)...
158
+ chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...
157
159
  chi send -m "is this the same bug?" 3fa2 # a message
158
160
  chi send --cut -m "stop, wrong file" 3fa2 # cut the running turn's long thinking
161
+ chi send --queue -m "then update the docs" 3fa2 # a turn of its own after the running one
159
162
  chi send --image shot.png -m "why is this red?" 3fa2 # with an image
160
163
  pbpaste | chi send -m "is this the same bug?" 3fa2 # the clipboard quoted above the message
161
164
  pbpaste | chi send 3fa2 # the clipboard is the message
162
165
  ```
163
166
 
164
167
  - With both stdin and `-m`, stdin is context: each line becomes a `> ` quote (as web annotations quote a selection), then a blank line, then the message. With only one, it goes in as is. A terminal on stdin is ignored. Both empty is a usage error; over 16 KiB is refused, never cut.
165
- - It goes through the same path as the web composer (`SessionManager.deliver_turn`): the worker's Bridge when it is up, so every attached UI shows it as your message (client id `cli:send`, shown like any user message); the input file when the worker is on its way out. During a running turn it is merged into that turn at the next step, like a message typed then, and cuts nothing. `--cut` asks for a cut, as `/cut TEXT` in a terminal does: a generation that has streamed only thinking for `steer.cut_after` seconds (per host/model: `steer_cut_after`) is cut (now, or once its thinking passes that), and the step starts again with the message. To an idle session `--cut` sends normally. `--cut --wait` into a running turn is refused like any `--wait` there (`busy: a turn is running`).
168
+ - It goes through the same path as the web composer (`SessionManager.deliver_turn`): the worker's Bridge when it is up, so every attached UI shows it as your message (client id `cli:send`, shown like any user message); the input file when the worker is on its way out. During a running turn it is merged into that turn at the next step, like a message typed then, and cuts nothing. `--cut` asks for a cut, as `/cut TEXT` in a terminal does: a generation that has streamed only thinking for `steer.cut_after` seconds (per host/model: `steer_cut_after`) is cut (now, or once its thinking passes that), and the step starts again with the message. To an idle session `--cut` sends normally. `--cut --wait` into a running turn is refused like any `--wait` there (`busy: a turn is running`). `--queue` (not with `--cut`, a usage error) keeps the message for after the running turn: it runs as a turn of its own then (as `/queue TEXT` does), and a plain message sent after it still goes in at the running turn's next step. To an idle session it runs at once (it still goes as a queue, so a turn another client starts first never takes it as a steer). A worker older than queueing would steer it instead, so it is refused (to an idle session too) (`refused: this session's worker predates queueing, …; restart it`, exit 1) and nothing is sent; `chi sessions restart ID` and send again.
166
169
  - A session with no worker gets one started (like `--attach` or the web composer). A session open in a plain REPL (`--no-shared`) refuses it. Only sessions on this machine: Bridges listen on 127.0.0.1.
167
170
  - Fire and forget: it returns once the message is queued and never prints the answer; that shows in whatever is attached. A guardrail "ask" waits for a UI to answer it, so with nothing attached the turn stalls there until one attaches (`chi --attach ID`).
168
171
  - `--image PATH` (repeatable, up to 20) sends images with the message, as the web composer's chips do. Each file is read once before anything is sent (bmp, tiff and heic converted to png, large ones downscaled, as for `@path`); a missing file or one that isn't an image stops the send (exit 1; with `--wait --format json`, an `error` line) and nothing is sent; more than 20 is a usage error (exit 2). Each session gets its own copy in `<session>/images/`. Text is still required (`-m` or stdin; context alone counts), also with `--wait`. The session's model must see images: one known not to (the host's model list says text-only, `vision: false` in `models:` or `hosts:`, a native llama.cpp without a vision model) is refused before anything is sent, with the reason (`<id> refused: gemma can't take images (…); send text only or switch the model (/model)`); the other sessions of the same send still get it, and the exit is 1. With `--new` nothing is started. When chi can't tell (a native host whose `/props` doesn't answer, a list that doesn't say), the message is sent as before and a text-only model fails the turn in the session. A running turn doesn't take images mid-turn: the message runs as the next turn, and the line says `(runs after the current turn)`. With `--new` the session starts idle with the message as its preview, then the message goes in as its first turn once its worker is up (`<id> started with 1 image`); if the worker doesn't come up in 5 s the session is kept, with its id on the `failed:` line.
169
172
  - A session command runs as the command, as typed in a terminal: `chi send -m "/model qwen" 3fa2` switches the model (`sent as a session command`), and the model never sees the line. What counts is what the session's worker knows (the built-ins, `!cmd`, its bundles' commands); an unknown `/word` (`/usr/bin/env is missing`) goes in as a message. A UI-only command (`/stats`, `/exit`, `/recap`, …) is refused instead: the worker doesn't run it, so the line would reach the model as a prompt (`<id> refused: /stats is a terminal and web command; use chi sessions stats <id8>`; exit 1, and nothing is sent). With `--new` a line starting with `/` or `!` starts the session idle and goes to its worker once it is up, which decides. With `--wait` a command has no reply to wait for: exit 0 at once, `<id> sent as a session command: no reply to wait for …` on stderr (`{"status":"command",…,"detail":…}` with `--format json`); its output shows where the session is open.
170
- - One line per session: `sent`, `sent as a session command`, `sent with 2 images`, `sent (goes in at the running turn's next step)`, with `--cut` `sent (cut in now)`, `sent (cuts in once the thinking passes 20 s)` or `sent (cutting is off; goes in at the next step)`, `sent (started its worker)`, `refused: …` or `failed: …`. Exit 0 when all were sent, 1 when any was refused, failed or not found, 2 for a usage error. There is no `--all`.
173
+ - One line per session: `sent`, `sent as a session command`, `sent with 2 images`, `sent (goes in at the running turn's next step)`, with `--cut` `sent (cut in now)`, `sent (cuts in once the thinking passes 20 s)` or `sent (cutting is off; goes in at the next step)`, with `--queue` `sent (queued: runs after the current turn)`, `sent (the running turn had ended: it runs now)` or `sent (nothing running: it runs now)`, `sent (started its worker)`, `refused: …` or `failed: …`. Exit 0 when all were sent, 1 when any was refused, failed or not found, 2 for a usage error. There is no `--all`.
171
174
 
172
175
  The Automator action above works for messages too: swap its last line for `pbpaste | "$chi" send -m "what do you make of this?" $(print -r -- "$picked" | cut -f1)`.
173
176
 
@@ -190,10 +193,10 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
190
193
  (`SAMAGOTCHI_THINKING_LEVEL=low chi send --new …`) only reaches the worker this call spawns: when the session's
191
194
  worker is woken later (by `chi web`, a delivery) without that env, the level is gone. Use `--thinking`.
192
195
  - `--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.
193
- - `--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.
196
+ - `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; wait or attach`, exit 1): the message would merge into it, and its reply would come back as the answer. `--queue --wait` is the way in: the message runs as a turn of its own after the running one, and the wait is for that turn alone (the ack's `enqueued_id` names its reply file and its `last_turn`), so the running turn's reply and end, and a question it asks meanwhile, are not the answer (it keeps waiting through them; its `--timeout` still applies); a question its own turn asks is (exit 3). Two queued waits each get their own turn's answer. A session waiting for an answer is still refused. `--format json` adds `delivery`: `queue`, or `next_step` when the running turn ended before the message went in (it then ran at once, and the answer is still its own).
194
197
  - 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.
195
198
  - `--format json` (with `--wait`) prints one JSON object on stdout however the wait ends, for a script or a parent agent (see [chi as a sub-agent](sub-agent.md)): `{"status":"answered","session_id":…,"text":…}`; `{"status":"question",…,"question":{"id","kind","header","text","options","multi_select","allow_freeform","approval"},"answer_with":"chi answer ID --question QID --option N"}`, where `kind` is `question` (the model's), `hook` or `approval` (with the tool, command, cwd, rule, reason and offered scopes); `{"status":"running",…,"detail":…}` (exit 4, and on Ctrl-C); and `failed`, `canceled`, `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1); `failed` adds `error_kind` and `retryable`, `canceled` adds `cancel_reason` and `stopped_by`, when known. A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
196
- - `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. A running session is fine (that is the point), so after exit 3, 4 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
199
+ - `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. With a turn running, that turn's reply (its `interrupted_turn` mark names it), not a turn's queued behind it; with the step-limit question standing, the reply of the turn its Continue runs. With neither (or a worker whose `bridge.json` lacks `turn_ids`), the next reply of any turn. A running session is fine (that is the point), so after exit 3, 4 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
197
200
  - `--continues ID` (with `--new`) starts the next link of that session's chain instead (see *Session chains*): in its folder, on its model, LLM context and thinking level, so `--dir`, `--model`, `--llm-context*` and `--thinking` are refused beside it; `ID` may be a prefix or `last:ID`, the chain's latest link. Without a message the new link starts idle; `--wait` then has nothing to wait for (a usage error). It prints `<full id> started (continues 3fa2c1d0)`, with `; 2 delegates moved` inside the parentheses when open delegates moved to it; a refusal (continued already, naming the next link; an open delegate that can't move; a turn running) is `chi send: refused: …`, exit 1.
198
201
  - The 16 KiB cap applies: `git diff | chi send --new …` on a big diff is refused; name the branch in the message instead and let the session read it.
199
202
  - A session nobody attaches to stalls at its first question or guardrail ask until someone answers it (`chi answer`, the web, `chi --attach`), and its worker stays up meanwhile: it doesn't idle-exit while a question waits (`chi sessions list` shows it as `waiting`; `chi sessions stop ID` frees it). Otherwise it idle-exits after `session.idle_exit_minutes` like any worker. These are ordinary sessions: delete them like any other.
@@ -220,7 +223,7 @@ A session can **continue** an earlier one: "the next day of the same routine" (a
220
223
 
221
224
  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 (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; `delegated by` in its info bar; 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.
222
225
 
223
- - `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.
226
+ - `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.
224
227
  - `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.
225
228
  - `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.
226
229
  - A wait that timed out or was canceled still gets its delegate report: neither moves the parent's cursor, so the child's reply comes as a report when its turn ends. The result says so (`chi brings its reply here by itself as a delegate report …`; with `queue`, that it joins the next step or turn) and that `delegate_result` waits again only if the turn needs the reply now. A parent that gets no reports (`session.delegate_reports: off`, a `--no-shared` REPL, `--non-interactive`) is told `delegate_result <id> waits again` instead, and `wait: false`'s result says `delegate_result waits for its reply`.
data/docs/sub-agent.md CHANGED
@@ -24,12 +24,17 @@ and Claude's answer comes back as attached context.
24
24
  ```sh
25
25
  chi send --new --wait --format json [--dir D] [--model M] [--timeout S] -m "task"
26
26
  chi send --wait --format json [--timeout S] [-m "follow-up"] ID
27
+ chi send --queue --wait --format json [--timeout S] -m "follow-up" ID
27
28
  chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--format json]
28
29
  chi answer ID --question QID --dismiss [--timeout S] [--format json]
29
30
  ```
30
31
 
31
32
  `chi answer` waits after answering, the same way `chi send --wait` does, so the
32
- next question comes back as exit 3 again. `--option` is 1-based or the label,
33
+ next question comes back as exit 3 again. What it waits for is the answered
34
+ turn: the turn that asked (or, for the step-limit question, the turn it
35
+ continues), so its reply comes back, not that of a turn queued behind it.
36
+ A worker older than this (its `bridge.json` doesn't name `turn_ids`) gets
37
+ the session's next reply instead. `--option` is 1-based or the label,
33
38
  repeated on a multi-select question. `--text` is free text (where the question
34
39
  allows it) or the reason for a Deny. `--dismiss` leaves the question
35
40
  unanswered: the model doesn't do what it asked about and finishes its reply.
@@ -43,7 +48,7 @@ Exit codes, both commands:
43
48
  | 0 | `not_continued` | (`chi answer`) a Stop answered the step-limit question; the turn's work so far stays |
44
49
  | 3 | `question` | chi waits for an answer: `question`, `answer_with` |
45
50
  | 4 | `running` | `--timeout` passed; the turn goes on, wait again |
46
- | 1 | `failed`, `canceled`, `limit`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened (`limit`: the turn ran out of steps and nobody can be asked, e.g. an older worker; `chi send ID -m '/continue yes'` continues it). `failed` adds `error_kind` (the provider error's kind: `credits`, `credits_held`, `rate_limited`, `server`, `auth`, `connection`, …) and `retryable` when known; `canceled` adds `cancel_reason` (`user`: a Stop in the web or `chi sessions stop`; `hook`; `ctrl_c`; `manual`) and `stopped_by` (the hook that stopped it, e.g. `loop-guard`) when known |
51
+ | 1 | `failed`, `canceled`, `limit`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened (`limit`: the turn ran out of steps and nobody can be asked, e.g. an older worker; `chi send ID -m '/continue yes'` continues it). `failed` adds `error_kind` (the provider error's kind: `credits`, `credits_held`, `rate_limited`, `server`, `auth`, `connection`, …) and `retryable` when known; `canceled` adds `cancel_reason` (`user`: a Stop in the web; `session_stopped`: a session stop, `chi sessions stop` or the web's; `hook`; `ctrl_c`; `manual`) and `stopped_by` (the hook that stopped it, e.g. `loop-guard`) when known |
47
52
  | 2 | (no JSON) | usage error, or an option the question doesn't offer |
48
53
  | 130 | `running` | Ctrl-C; the turn goes on |
49
54
 
@@ -139,8 +144,18 @@ answers from any local process.
139
144
  isn't sent: it exits 1 with the `chi answer` command for the question. The
140
145
  step-limit question is the exception: it waits between turns, so a message
141
146
  goes in, drops it and starts a new turn.
147
+ - A follow-up to a session whose turn still runs (`busy: a turn is running`,
148
+ exit 1, say you waited on with `--timeout` and got exit 4) goes in with
149
+ `chi send --queue --wait -m "…" ID`: it runs as a turn of its own after the
150
+ running one, and the wait is for that turn's answer alone, never the running
151
+ turn's (`--format json` adds `"delivery": "queue"`). A question the running
152
+ turn asks meanwhile isn't yours: the wait goes on through it (until your
153
+ `--timeout`); one the queued turn asks comes back as exit 3. `chi answer`
154
+ on the running turn's question gets that turn's reply, not your queued one.
142
155
  - `chi send --wait ID` with no message waits past a question already reported
143
- (the step-limit question too), until someone answers it.
156
+ (the step-limit question too), until someone answers it. It waits for the
157
+ turn running when it starts (or the one the step-limit question continues),
158
+ and prints that turn's reply, not a queued one's after it.
144
159
  - An answer to a question that is no longer open (you answered it in the web
145
160
  first) isn't sent; `chi answer` says so and waits for what comes next. Its
146
161
  JSON then has `"answered_here": false` (absent when the answer went in), so
@@ -187,6 +202,8 @@ Start a task: `chi send --new --wait --format json --timeout 500 -m "<task>"`
187
202
  Run each chi command on its own, nothing chained after it: the exit code comes
188
203
  back with the output. Talk to chi only through these commands; never call its
189
204
  Bridge or web API directly. Follow-ups in the same session:
190
- `chi send --wait --format json --timeout 500 -m "…" <session_id>`.
205
+ `chi send --wait --format json --timeout 500 -m "…" <session_id>`; if that says
206
+ `busy` (a turn still runs), add `--queue`: the follow-up runs after that turn
207
+ and you get its own answer.
191
208
  Keep `--timeout` under your shell tool's limit (Claude Code: 600 s per call).
192
209
  ```
data/docs/testing.md CHANGED
@@ -27,6 +27,12 @@ A select bound with `.value` instead reads right in both, but lit sets it before
27
27
  `llm_context_chip.js`'s `renderForm` draws twice then. Assert a kept node with `assert.ok(a === b)`: a failing
28
28
  `assert.equal` on two DOM nodes inspects the whole DOM (huge output, can stall the run).
29
29
 
30
+ Where a composer line goes is decided twice, in Ruby (`ComposerRoute`, both terminals) and in the web
31
+ (`composer.js#routeComposerLine`). `spec/fixtures/composer_routes.json` holds the cases both answer: rspec
32
+ (`spec/composer_route_spec.rb`) and `npm test` (`spec/web/public/composer.test.js`) run every row. A change to
33
+ either router adds its row there; a row where a UI differs on purpose (or a found divergence) carries a `web` or
34
+ `repl` override and a `note`.
35
+
30
36
  ## While you work: affected specs
31
37
 
32
38
  ```sh
@@ -112,6 +112,10 @@ module Samagotchi
112
112
  # wakes, and it must not come back as exit 3.
113
113
  cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
114
114
  baseline = ReplyWait.baseline_of(session, question_id: qid)
115
+ # The turn the answer is about: the one asking (running), or the one
116
+ # whose continue offer stands. Its own reply is the answer's, not a
117
+ # turn queued behind it; nil (an older worker): the next reply.
118
+ turn = ReplyWait.own_turn(session, session_dir: Session.session_dir(id, state_dir: @state_dir))
115
119
 
116
120
  if pending && pending[:id].to_s == qid
117
121
  return failure(not_dismissable(pending)) if options[:dismiss] && ParentReport.continue?(pending)
@@ -129,7 +133,7 @@ module Samagotchi
129
133
  end
130
134
  # Answered here, or no longer open (answered elsewhere, or a newer
131
135
  # question): what comes next either way.
132
- wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout])
136
+ wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn)
133
137
  end
134
138
 
135
139
  # The labels to send. A number is an option's place (1-based); an
@@ -226,6 +226,7 @@ module Samagotchi
226
226
  when :turn_enqueued
227
227
  queued = { enqueued_id: event[:enqueued_id], client_id: event[:client_id], prompt: event[:prompt] }
228
228
  queued[:images] = event[:images] if event[:images]
229
+ queued[:delivery] = event[:delivery] if event[:delivery]
229
230
  @queued << queued
230
231
  when :recap_ready
231
232
  # One collected just as a turn started describes the chat before it.
@@ -29,6 +29,7 @@ require_relative "version"
29
29
  require_relative "worker_sidecar"
30
30
  require_relative "relay_verifier"
31
31
  require_relative "relay_watcher"
32
+ require_relative "reply_wait"
32
33
  require_relative "tools/task_runtime"
33
34
 
34
35
  module Samagotchi
@@ -52,8 +53,9 @@ module Samagotchi
52
53
  DEFAULT_RING_CAPACITY = 256
53
54
  DEFAULT_HEARTBEAT_INTERVAL = 15.0
54
55
  # Cancel reasons a client may name (the web sends user, an attached TUI
55
- # ctrl_c); anything else is :manual, so client input never mints symbols.
56
- CANCEL_REASONS = %w[manual user ctrl_c].freeze
56
+ # ctrl_c, a session stop session_stopped: SessionManager.stop_session);
57
+ # anything else is :manual, so client input never mints symbols.
58
+ CANCEL_REASONS = %w[manual user ctrl_c session_stopped].freeze
57
59
  # How long #stop waits for requests it is answering (not open streams).
58
60
  REQUEST_GRACE_SECONDS = 1.0
59
61
  # How long #stop waits for each killed RelayWatcher thread to end.
@@ -495,7 +497,11 @@ module Samagotchi
495
497
  # task_stop: POST tasks/stop (#handle_task_stop), the web's stop-task button.
496
498
  # reparent: the worker reads Session::PARENT_FILE (its rings, each turn's
497
499
  # start), so a continue may move it to the chain's new link (ChildMove).
498
- FEATURES = %w[restart task_stop reparent].freeze
500
+ # delivery: POST /turn runs every Delivery value; a queue waits for the
501
+ # turn's end (Delivery::QUEUE_FEATURE). A worker without it would steer.
502
+ # turn_ids: the running turn's checkpoint mark, a continue turn and a
503
+ # Stop's last_turn carry the turn's enqueued_id (ReplyWait.own_turn).
504
+ FEATURES = ["restart", "task_stop", "reparent", Delivery::QUEUE_FEATURE, ReplyWait::TURN_IDS_FEATURE].freeze
499
505
 
500
506
  # The routes, by method and what follows /session/:id/: a handler takes
501
507
  # the session id and the request body and returns [headers, status, body].
@@ -868,6 +874,10 @@ module Samagotchi
868
874
  enqueued_id = SecureRandom.uuid
869
875
  # Before it is queued: a drain after this may have taken it (#cut_for_steer).
870
876
  epoch = @engine.input_epoch
877
+ # Whether a queue waits for a running turn: read with the write under
878
+ # the event log, so no :turn_started or :turn_completed falls between.
879
+ # To an idle worker the queued file simply runs as the next turn.
880
+ queued_behind_turn = false
871
881
  # Write and announce with the event log held: the worker can't emit
872
882
  # this turn's :turn_started (or merge it mid-turn) before
873
883
  # :turn_enqueued, and a failed write announces nothing.
@@ -875,12 +885,14 @@ module Samagotchi
875
885
  raise IntakeClosed if stopped?
876
886
  next :expired if expired?("turn_expired", deadline, sid: sid, client_id: client_id)
877
887
 
888
+ queued_behind_turn = Delivery.queue?(delivery) && !@accumulator.current_turn.nil?
878
889
  enqueue_turn(prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
879
890
  no_interrupt: no_interrupt, images: images, delivery: delivery).tap do |ok|
880
891
  next unless ok
881
892
 
882
893
  enqueued_event = { type: :turn_enqueued, enqueued_id: enqueued_id, client_id: client_id, prompt: prompt.to_s }
883
894
  enqueued_event[:images] = images unless images.empty?
895
+ enqueued_event[:delivery] = Delivery::QUEUE if queued_behind_turn
884
896
  @engine.announce(enqueued_event)
885
897
  @on_input&.call
886
898
  end
@@ -896,7 +908,9 @@ module Samagotchi
896
908
  # the running turn's next step.
897
909
  cut = nil
898
910
  cut = @engine.cut_for_steer(Steer.source_for_client(client_id), epoch: epoch) if images.empty? && Delivery.cut?(delivery)
899
- ack = { status: "accepted", enqueued_id: enqueued_id, session_id: @session_id, delivery: delivery }
911
+ # A queue to an idle worker is a turn now: the ack says next_step.
912
+ ack_delivery = Delivery.queue?(delivery) && !queued_behind_turn ? Delivery::NEXT_STEP : delivery
913
+ ack = { status: "accepted", enqueued_id: enqueued_id, session_id: @session_id, delivery: ack_delivery }
900
914
  ack[:cut] = cut.to_s if cut
901
915
  ack[:cut_after] = @engine.steer_cut_after if cut == :waits
902
916
  [{}, 202, ack]
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: slow-commands
3
- version: 0.2.1
3
+ version: 0.2.2
4
4
  scope: system
5
5
  description: "Remembers commands that ran past the execute limit in a project; the next identical execute is denied once a turn with advice to run it with task_create and task_wait; /slow-commands lists and forgets them"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:cc8b9829ae77565d8d47969377e91124cf4c785164e417d3d323daeec1295269
9
+ sha256: sha256:6bf5da2501a6404cda01c95975b005fce179f756c56222771c5ee857d7ba4b98
10
10
  # Execute::HANDED_OVER, read when the plugin loads (0.59.0); after_tool_call's
11
11
  # call:, elapsed_ms: and result: (0.57.0)
12
12
  requires_chi: ">= 0.59.0"
@@ -75,9 +75,11 @@ class Plugin
75
75
  TIMED_OUT = /\A#{Regexp.escape(Samagotchi::Tools::Execute::TIMEOUT_PREFIX)} (\d+)s/
76
76
  # Still running at the limit, handed over as a task (chi >= 0.59.0).
77
77
  HANDED_OVER = /\A#{Regexp.escape(Samagotchi::Tools::Execute::HANDED_OVER)} (\d+)s limit/
78
- # A stopped command's first words: the user's Stop, or a signal to chi
79
- # (chi >= 0.62.0; an older chi has no such text).
80
- STOPPED = %i[STOPPED_BY_USER NOT_RUN_ON_STOP STOPPED_BY_SIGNAL NOT_RUN_ON_SIGNAL].filter_map do |name|
78
+ # A stopped command's first words: the user's Stop, a signal to chi
79
+ # (chi >= 0.62.0) or a session stop (chi >= 0.64.0); an older chi has no
80
+ # such text.
81
+ STOPPED = %i[STOPPED_BY_USER NOT_RUN_ON_STOP STOPPED_BY_SIGNAL NOT_RUN_ON_SIGNAL
82
+ STOPPED_BY_SESSION_STOP NOT_RUN_ON_SESSION_STOP].filter_map do |name|
81
83
  Samagotchi::Tools::Execute.const_get(name) if Samagotchi::Tools::Execute.const_defined?(name)
82
84
  end.freeze
83
85
  LEX = Samagotchi::Guardrails::ShellLex