samagotchi 0.83.0 → 0.85.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -1
  3. data/bin/chi +27 -2
  4. data/docs/architecture.md +10 -1
  5. data/docs/cli.md +17 -1
  6. data/docs/configuration.md +2 -0
  7. data/docs/hooks.md +1 -1
  8. data/docs/internals/bundles.md +1 -1
  9. data/docs/internals/engine.md +3 -2
  10. data/docs/plugins.md +31 -5
  11. data/docs/sessions.md +15 -2
  12. data/docs/sub-agent.md +5 -1
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
  14. data/lib/samagotchi/bridge.rb +54 -1
  15. data/lib/samagotchi/bridge_client.rb +12 -0
  16. data/lib/samagotchi/bundle_session_settings.rb +212 -0
  17. data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -2
  18. data/lib/samagotchi/bundles/cost-cap/plugin.rb +12 -5
  19. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  20. data/lib/samagotchi/engine.rb +1 -1
  21. data/lib/samagotchi/kernel_loop.rb +5 -1
  22. data/lib/samagotchi/llm/chat_loop.rb +5 -1
  23. data/lib/samagotchi/log_subscriber.rb +1 -0
  24. data/lib/samagotchi/memory_bundle/builder.rb +3 -2
  25. data/lib/samagotchi/memory_bundle/installed_bundle.rb +6 -3
  26. data/lib/samagotchi/memory_bundle/installer.rb +2 -1
  27. data/lib/samagotchi/memory_bundle/manifest.rb +24 -2
  28. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  29. data/lib/samagotchi/parent_report.rb +5 -2
  30. data/lib/samagotchi/plugin/context.rb +36 -2
  31. data/lib/samagotchi/reply_wait.rb +8 -1
  32. data/lib/samagotchi/send_command.rb +24 -7
  33. data/lib/samagotchi/session.rb +16 -1
  34. data/lib/samagotchi/session_commands.rb +7 -4
  35. data/lib/samagotchi/session_inbox.rb +68 -0
  36. data/lib/samagotchi/session_setup.rb +14 -6
  37. data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -5
  38. data/lib/samagotchi/terminal_ui/attached_loop.rb +89 -1
  39. data/lib/samagotchi/terminal_ui.rb +10 -2
  40. data/lib/samagotchi/testing/bundle.rb +31 -5
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +41 -4
  43. data/lib/samagotchi/web/public/app.js +17 -0
  44. data/lib/samagotchi/web/public/bubbles.js +9 -0
  45. data/lib/samagotchi/web/public/commands.js +1 -0
  46. data/lib/samagotchi/web/public/data.js +11 -0
  47. data/lib/samagotchi/web/public/index.html +9 -0
  48. data/lib/samagotchi/web/public/stage_view.js +6 -0
  49. data/lib/samagotchi/web/public/stream_handlers.js +3 -0
  50. data/lib/samagotchi/web/public/turn_events.js +4 -0
  51. data/lib/samagotchi/web/public/turn_html.js +9 -1
  52. data/lib/samagotchi/web/public/unqueue.js +56 -0
  53. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9b7fb93e6e4741b5380cfaeaa2a27d37dd326099f2d3cf568b659941e9c34930
4
- data.tar.gz: bc53710bd1f4dab4d2d5d4e1eb23814f367fc47833b930fd6bd7ee3a1411423b
3
+ metadata.gz: d533709ed93dac5a1a65a9c023e83a50aef691542faffb1cdeefe6bfca51b5f1
4
+ data.tar.gz: 90cbb0c9256e751f64a36b854d6049098b694a8824a34230895e83eb383f8f74
5
5
  SHA512:
6
- metadata.gz: 882ac69395d70f9b27e8b9922b704c1b1818ad400eeb4551dac38c905df7822d17ac43450f71b5c5a0653b0af9b45470098981f9ee25e36dc2f45a1bb2a9eeee
7
- data.tar.gz: 1e982daa8adf0881d87ea82256e96565cceb09ab4d6793325a87ce0214df3355a5798c3da75f9afab30126aba7bd5f7f9facf7dfd1f032e3c2cfd9a5e4aaeed4
6
+ metadata.gz: 90149bcdee28e2c2aa74c011d361aee78b652f4a0ef03c966a2fa11324181b67b8a926e3a1fe94d6d7cc8b92e62edf5d7fc67f444ebec0923fe58f7338c3c65d
7
+ data.tar.gz: 8884e65ab2e6a0506e15dab8ea701dda33f3a149dc6168e770ad2b885dadf7789f0d4cf2df8c22a258239b74f3da8d384c356cd72db2c66ff8154eb31819acea
data/CHANGELOG.md CHANGED
@@ -8,6 +8,34 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.85.0] - 2026-10-18
12
+
13
+ ### Added
14
+
15
+ - A message queued for after the running turn can be taken back before it runs: the × on its bubble in the web,
16
+ `/unqueue` in an attached terminal (its newest queued line). Its text comes back into the input to change and send
17
+ again. Every UI drops it, and a `chi send --queue --wait` on it ends with `removed` (exit 1). `/queue` is now listed
18
+ in the attached terminal's Tab and `/help` too.
19
+
20
+ ### Fixed
21
+
22
+ - On a phone, a queued message's text gets the bubble's full width, with its origin and after-turn badge above it.
23
+ - A plugin's `ctx.settings` reads a session's `--set` values again after a failed read, instead of keeping config.yml's
24
+ alone until the worker restarts.
25
+ - A `before_generation` hook now runs before the generation starts (`:generation_started`) in both loops: a question
26
+ it asks (the cost-cap's) no longer counts in the generation's time or leaves an empty generation on Stop.
27
+
28
+ Update with `chi update` (the system bundle 0.85.0), then restart `chi web`.
29
+
30
+ ## [0.84.0] - 2026-10-18
31
+
32
+ ### Added
33
+
34
+ - `--set BUNDLE.KEY=VALUE` on `chi` and `chi send --new`: a bundle setting for this session, saved with it and kept
35
+ by forks and continued links. A bundle lists the keys a session may set in its manifest's `session_settings:`;
36
+ its plugin reads them in `ctx.settings`, over config.yml's. `POST /api/sessions` takes `bundle_settings`.
37
+ - The cost-cap bundle takes a session's own cap: `chi send --new --set cost-cap.cap=3 -m "…"` (cost-cap 0.1.1).
38
+
11
39
  ## [0.83.0] - 2026-10-18
12
40
 
13
41
  ### Added
@@ -3380,7 +3408,9 @@ and long-lived sessions.
3380
3408
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3381
3409
  hotkey panel that send selected text or the clipboard to your sessions.
3382
3410
 
3383
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.83.0...HEAD
3411
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.85.0...HEAD
3412
+ [0.85.0]: https://github.com/dm1try/samagotchi/compare/v0.84.0...v0.85.0
3413
+ [0.84.0]: https://github.com/dm1try/samagotchi/compare/v0.83.0...v0.84.0
3384
3414
  [0.83.0]: https://github.com/dm1try/samagotchi/compare/v0.82.0...v0.83.0
3385
3415
  [0.82.0]: https://github.com/dm1try/samagotchi/compare/v0.81.0...v0.82.0
3386
3416
  [0.81.0]: https://github.com/dm1try/samagotchi/compare/v0.80.0...v0.81.0
data/bin/chi CHANGED
@@ -82,7 +82,7 @@ ARGV.each do |arg|
82
82
  base = arg.split("=", 2).first
83
83
  # If dashed version is a known flag (registry or hardcoded), reject underscore variant
84
84
  dashed = base.tr("_", "-")
85
- known = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --memory --mute --append-system-prompt --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token] + Samagotchi::Config.cli_entries.map(&:cli_flag)
85
+ known = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --memory --mute --append-system-prompt --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token] + Samagotchi::Config.cli_entries.map(&:cli_flag)
86
86
  if known.include?(dashed) || known.include?(base)
87
87
  warn "Unknown option: #{arg} (did you mean #{dashed}?)"
88
88
  exit 1
@@ -125,6 +125,11 @@ parser = OptionParser.new do |opts|
125
125
  # --no-tool is taken out of ARGV before parse! (OptionParser would read it
126
126
  # as --tool's negation); this is its help line.
127
127
  opts.separator " --no-tool NAME Leave this tool out of the toolkit (repeatable; a comma list too)"
128
+ opts.on("--set BUNDLE.KEY=VALUE", "A bundle setting for this session, over config.yml's bundles: (repeatable; a key the " \
129
+ "bundle's manifest lists in session_settings:; numbers, true, false and off are typed, the rest is text; " \
130
+ "saved in the session: --set cost-cap.cap=3)") do |v|
131
+ (options[:sets] ||= []) << v
132
+ end
128
133
  opts.on("--memory NAME", "Preload a memory entry into the system prompt (repeatable; a comma list too)") { |m| (options[:memories] ||= []) << m }
129
134
  opts.on("--append-system-prompt FILE", "Put FILE's text into the system prompt, after AGENTS.md (repeatable; saved in the session; " \
130
135
  "config append_system_prompt: is the baseline)") { |f| (options[:append_system_prompt] ||= []) << f }
@@ -225,6 +230,10 @@ if options[:new_token] && !web
225
230
  warn "Error: --new-token goes with chi web (chi web --new-token)"
226
231
  exit 1
227
232
  end
233
+ if options[:sets] && web
234
+ warn "Error: --set is a new session's (chi --set, chi send --new --set), not chi web's"
235
+ exit 2
236
+ end
228
237
 
229
238
  # A scratch session is new, runs here and goes when it ends.
230
239
  if scratch
@@ -372,6 +381,20 @@ if options[:toolkit] || options[:tools] || options[:no_tools]
372
381
  remove: options[:no_tools])
373
382
  end
374
383
 
384
+ # --set BUNDLE.KEY=VALUE: a new session's bundle settings, checked here
385
+ # against the installed bundles (a typo isn't saved).
386
+ if options[:sets]
387
+ require "samagotchi/bundle_session_settings"
388
+ begin
389
+ options[:bundle_settings] = Samagotchi::BundleSessionSettings.from_flags(options[:sets])
390
+ rescue Samagotchi::BundleSessionSettings::Error => e
391
+ warn "Error: #{e.message}"
392
+ exit 2
393
+ end
394
+ # An existing session's run names it among the flags it ignores.
395
+ options[:toolkit_flags] = (options[:toolkit_flags] || []) + ["--set"]
396
+ end
397
+
375
398
  chi_update_hint.call
376
399
  require "samagotchi/launch_mode"
377
400
  launch, launch_note = if scratch
@@ -408,7 +431,8 @@ if launch == :attached
408
431
  thinking: options[:thinking],
409
432
  appended_prompt_files: options[:appended_prompt_files] || [],
410
433
  toolkit: options[:toolkit_value],
411
- toolkit_flags: options[:toolkit_flags] || [])
434
+ toolkit_flags: options[:toolkit_flags] || [],
435
+ bundle_settings: options[:bundle_settings] || {})
412
436
  rescue Samagotchi::TerminalUI::AttachLauncher::Error => e
413
437
  warn "Error: #{e.message}"
414
438
  exit 1
@@ -430,6 +454,7 @@ begin
430
454
  appended_prompt_files: options[:appended_prompt_files] || [],
431
455
  toolkit: options[:toolkit_value],
432
456
  toolkit_flags: options[:toolkit_flags] || [],
457
+ bundle_settings: options[:bundle_settings] || {},
433
458
  non_interactive: options[:non_interactive],
434
459
  model_name: options[:model],
435
460
  scratch: scratch
data/docs/architecture.md CHANGED
@@ -281,6 +281,15 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
281
281
  saved messages, or the running turn's accumulator); answers once the
282
282
  task is gone: `200 {status, stop_reason, task_id}`, `404 task_not_found`, `409 not_running`.
283
283
  The worker names it in its sidecar features (`task_stop`).
284
+ - `POST /session/:id/turn/dequeue` — `{enqueued_id, client_id?}`: take back a queued message
285
+ (one that waits for the running turn's end: `--queue`, Alt+Enter, `/queue TEXT`, or a message
286
+ with images) before a turn takes it (web route `POST /api/sessions/:id/turn/:enqueued_id/dequeue`,
287
+ the × on a queued bubble; `/unqueue` in the attached TUI). Under the event log, like its enqueue:
288
+ `SessionInbox.take_queued_input` claims the file as the drain does (one rename wins), deletes it,
289
+ leaves a tombstone (`input/removed/<enqueued_id>`, for `ReplyWait`'s `turn:`), and the Bridge
290
+ announces `:turn_dequeued`. `200 {status: "removed", enqueued_id, prompt}`, `409 already_started`
291
+ (a turn took it), `409 not_queued` (a next-step message: a drain may merge it any moment), `404
292
+ unknown_enqueued_id`. It never wakes the worker. The worker names it in its sidecar features (`unqueue`).
284
293
  - `POST /session/:id/relay` — the approval relay, on a delegated child's worker
285
294
  (`{action, relay_id, question_id}`): `opened` / `closed` (with a `reason`) set or clear the
286
295
  pending question's `relayed_to` mark (`QuestionDesk#annotate`, `:question_relay`); `answered`
@@ -404,7 +413,7 @@ session every 10 s, so a `kill -9` shows within a second. The summary (`Web::Ses
404
413
  shared with `/api/sessions` and the session view) carries `owner`, `project_root` and
405
414
  `bridge_up` (the sidecar is there *and* the lock is held: the page attaches its stream on it), and
406
415
  from that sidecar `worker_version` (the chi the worker runs) and `worker_features`
407
- (`Bridge::FEATURES`, what a client may ask of it beyond the routes: `restart`, `task_stop`, `reparent`,
416
+ (`Bridge::FEATURES`, what a client may ask of it beyond the routes: `restart`, `task_stop`, `reparent`, `unqueue`,
408
417
  `delivery`, a worker that queues a message for after the turn; `SessionManager.deliver_turn` and
409
418
  the attached TUI refuse a queue to a worker without it, which would steer; `turn_ids`, a worker whose running
410
419
  turn's mark, continue turn and Stop carry the turn's `enqueued_id`, which `chi answer`, `chi send --wait` with no
data/docs/cli.md CHANGED
@@ -204,6 +204,7 @@ controls exit behavior (`--non-interactive`); `--resume` composes with both.
204
204
  | `--model NAME` | Use this model for the run (overrides the configured default and a resumed session's model). |
205
205
  | `--thinking LEVEL` | This session's own thinking level: `off`, `low`, `medium` or `high`, saved in the session and first in the order (`default` unsets it). On `--resume` or `--attach` it is set at once, through a `/thinking` on an attached worker. With `chi web` it is instead the default for the sessions its workers run, below a session's own (as env `SAMAGOTCHI_THINKING_LEVEL` is). See "Thinking" in configuration.md. |
206
206
  | `--toolkit NAME`, `--tool NAME`, `--no-tool NAME` | A new session's toolkit, the tools its prompts declare: `full` (every built-in), `lean`, `child`, one of config's `toolkit.kits` or a bundle's (without `--toolkit`: config's `toolkit.default`), with tools declared on top of it (`--tool`) or left out (`--no-tool`); both repeatable, a comma list too. Saved in the session as given; a respawned worker, a fork and a continue keep it. Ignored with `--attach`/`--resume` (one line, as `--memory`); `/toolkit` changes it. Not on `chi web` (config's `toolkit.default` covers its sessions). See [Toolkits](configuration.md#toolkits). |
207
+ | `--set BUNDLE.KEY=VALUE` | A bundle setting for a new session (repeatable; a later one of the same key wins), laid over config.yml's `bundles: <bundle>:` in what the bundle's plugin reads (`ctx.settings`): `--set cost-cap.cap=3`. The value is typed simply: an integer (`3`, `-1`) or a decimal (`2.5`) is a number, `true` is true, `false` and `off` are false, anything else stays the text as given (`010`, `0x10`, `3:30`, `No`, `null`). For cost-cap, `0`, a negative number or `off` is no cap. Only a key the installed bundle's manifest lists in `session_settings:` is taken: a bundle that isn't installed (or whose plugin doesn't load), a key it doesn't list (a record installed before the bundle listed it says to run `chi update` or `chi bundle upgrade NAME`), or another shape (`--set foo`, `--set a.b.c=1`) is an error naming what it takes, exit 2, nothing started. Saved in the session; a respawned worker, a fork and a continue keep it, a delegate child doesn't. Ignored with `--attach`/`--resume` (one line, as `--toolkit`); refused with `chi web`. See [Per-session settings](plugins.md#per-session-settings). |
207
208
  | `--profile NAME` | Prompt profile (`qwen36` or `gemma4`) for every model in this run, over config and the server's template (same as `--model-profile`, env `SAMAGOTCHI_MODEL_PROFILE`). See "Prompt profile" in configuration.md. |
208
209
  | `--memory NAME` | Preload a memory entry into the system prompt (repeatable; a comma list too). Merged under the config.yml `memories:` baseline. Works attached: the list is stored on the session, so its worker builds the same prompt on every respawn. |
209
210
  | `--append-system-prompt FILE` | Put `FILE`'s text into the system prompt, after AGENTS.md and before the memory indexes (repeatable; relative to the shell's directory). Added after the config.yml `append_system_prompt:` list. A missing or unreadable file is an error, exit 1. Saved in the session, so a respawned worker, a fork and a continue keep it; ignored with `--attach`/`--resume` (one line, as `--memory`). See [Appended prompt files](configuration.md#appended-prompt-files). |
@@ -606,6 +607,17 @@ REPL alike:
606
607
  sent after it still go in at the next step.
607
608
  At the open prompt (no turn running) `/cut TEXT` and `/queue TEXT` run TEXT
608
609
  as a normal prompt.
610
+ - `/unqueue` (attached terminal) takes back the newest line this terminal
611
+ queued that hasn't started: `removed: <its first 60 characters>`, and its text
612
+ goes back into the input to change and send again (not over typed text: then
613
+ it is in the input history, ↑; not with input from a pipe; its images don't
614
+ come back), then the one before it on the next `/unqueue`; `(nothing queued: …)` when none waits,
615
+ `(not removed: it already started)` when its turn won the race, `(not removed:
616
+ it is already gone)` when another client removed it. Other terminals that
617
+ showed it say `(a queued message was removed)`; the web drops its bubble. A
618
+ worker older than it is not asked: `(/unqueue needs a newer worker: …)`. In
619
+ the plain REPL it answers with a note (its own queue is not touched), and the
620
+ web's composer points at the × on the bubble.
609
621
  - **The web composer** has a row above the text field in a session (always
610
622
  there, so the composer never changes height): the keys while idle, and while
611
623
  the turn runs the delivery line, where a message sent now goes in (`↳ goes in after this
@@ -615,7 +627,11 @@ REPL alike:
615
627
  **Alt+Enter** (Option+Enter) or `queue for after the turn` queues it, like
616
628
  `/queue TEXT`, and the bubble is dashed (`after this turn`), below the
617
629
  running turn's card with the commands queued for its end, until its turn
618
- starts; `cut in now` cuts, like `/cut TEXT` (by click only: it throws the
630
+ starts; its **×** (`Remove from queue`, once the worker acked it) takes it
631
+ back before it runs, in every tab (a toast says why when it can't: it
632
+ already started, it is gone, no live worker), and puts its text back into an
633
+ empty composer to change and send again (a draft stays: the text is then in
634
+ the toast; images don't come back); `cut in now` cuts, like `/cut TEXT` (by click only: it throws the
619
635
  thinking away). Cut is enabled only while the step has streamed nothing but
620
636
  thinking and cutting is on for the model (the line says `cuts at about 0:25
621
637
  if you choose cut in now` until the thinking passes `steer_cut_after`, and
@@ -1145,6 +1145,8 @@ Bridge acks `delivery: "queue"` while a turn runs (to an idle session the messag
1145
1145
  `next_step`). A worker whose sidecar doesn't name the `delivery` feature (older than queueing) would steer it, so the
1146
1146
  sender refuses it (`409 delivery_unavailable`, "restart it"), and so does a worker that answers the request with a
1147
1147
  400 `delivery_unavailable`.
1148
+ Until its turn starts, a queued message can be taken back: `/unqueue` in an attached terminal, the × on its bubble
1149
+ in the web ([sessions.md](sessions.md), *Removing a queued message*).
1148
1150
 
1149
1151
  ## Server errors
1150
1152
 
data/docs/hooks.md CHANGED
@@ -52,7 +52,7 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
52
52
  | `:session_start` | The first turn this chi process runs for the session (so again after `--resume`, a worker that idle-exited and woke, or a restart) | `{ type: :session_start, session_id: "..." }` |
53
53
  | `:before_turn` | Before each turn starts | `{ type: :before_turn, session_id: "...", prompt: "..." (nil on a continue), resumed: true \| false (true on a step-limit Continue, which resumes the run before it; false on every other turn, a reminder's or a wake's too), messages: [...] (the history before this turn) }` |
54
54
  | `:after_turn` | After a turn completed or was stopped once the model was asked (not after one that failed, was cut short by an interrupt signal (SIGINT), or was stopped before its `:before_turn` hooks) | `{ type: :after_turn, status: "completed" \| "canceled", present: (see [Presenting the answer](#presenting-the-answer-display-only)), refs: (see [Refs on the answer](#refs-on-the-answer-display-only)), messages: [...] (the conversation the turn stored; a cancelled or empty turn ends it with a `kind: turn_note` system message, and a context line is `kind: context`, see [sessions.md](sessions.md#notes-a-turn-leaves-for-the-model)) }` |
55
- | `:before_generation` | Before each LLM API call (both loops) | `{ type: :before_generation, iteration: N }` |
55
+ | `:before_generation` | Before each LLM API call (both loops), before its `:generation_started`: the hook's time (a question it waits on) isn't the generation's | `{ type: :before_generation, iteration: N }` |
56
56
  | `:after_generation` | After LLM returns (both loops); not after a generation a hook cut (`stop_generation`) | `{ type: :after_generation, iteration: N, response: "...", messages: [...] (the conversation as sent) }` |
57
57
  | `:generation_progress` | While the response streams, in batches (see [Watching the stream](#watching-the-stream)) | `{ type: :generation_progress, iteration: N, thinking: "..." (new since the last fire), text: "..." (new visible text), thinking_chars: N, text_chars: N (this generation so far), elapsed_ms: N, restarted: true (only on the first fire after a dropped stream was asked again) }` |
58
58
  | `:before_tool_call` | Before tool dispatch (and before `tool_call_started`) | `{ type: :before_tool_call, iteration: N, call: {...}, params: "...", guardrail: Verdict, context: {...}, targets: {...}, blocked: false, block_reason: nil }` |
@@ -27,7 +27,7 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
27
27
  | `SourceIdentity` (source.rb) | what a source is, for comparing two installs of one name: `shipped:<dir>` (any gem or checkout), `git:<url>` (no `#ref`, trailing `/` or `.git`), `path:<realpath>`; nil when it can't place one |
28
28
  | `PreparedSource` (source.rb) | one source normalized once (dir, owned, git commit): `BundleCommand` reads the bundle's name from its `manifest.yml` (the name for every kind of source, a git URL too) and hands it to the Installer (`prepared:`), so a source is cloned or extracted once per command |
29
29
  | `MemoryFiles` | which top-level `*.md` files of a bundle are its memories (`.select`: the `files:` list, else all but README/LICENSE and the like), `.unshipped` (listed, not shipped) and `.symlinks` (skipped: a symlink is never a memory) |
30
- | `Manifest` | parses/validates `manifest.yml` (`name:` is the bundle's name and a directory under `.bundles/`, so a path such as `../x`, `a/b`, `.` or `..` is refused: `name: … is not a bundle name`; YAML that doesn't parse or isn't a mapping is a `ValidationError` too; files, hooks as `BundleHook`, plugin, scripts, needs, requires_chi, includes, context_providers) and writes it (`Manifest.write`, for Builder). It ignores `requires_chi_next`, a shipped bundle's release-time marker (`script/release/release_tools.rb`, docs/releasing.md) |
30
+ | `Manifest` | parses/validates `manifest.yml` (`name:` is the bundle's name and a directory under `.bundles/`, so a path such as `../x`, `a/b`, `.` or `..` is refused: `name: … is not a bundle name`; YAML that doesn't parse or isn't a mapping is a `ValidationError` too; files, hooks as `BundleHook`, plugin, scripts, needs, requires_chi, includes, context_providers, session_settings: the keys a session's `chi --set` may give, recorded at install in `manifest.json` for `BundleSessionSettings` to check) and writes it (`Manifest.write`, for Builder). It ignores `requires_chi_next`, a shipped bundle's release-time marker (`script/release/release_tools.rb`, docs/releasing.md) |
31
31
  | `Installer` | one install/upgrade/dry run of one bundle (below) |
32
32
  | `AssetInstaller` | the parts that live in `.bundles/<name>/`: hooks, guardrail rules, plugin, scripts; returns `AssetInstaller::Installed` (the paths the record keeps) |
33
33
  | `Merger` | 3-way classification per file: `install`, `fast_forward`, `keep`, `noop`, `conflict` |
@@ -179,7 +179,7 @@ KernelLoop#run(messages, max_iterations:, on_stream_event:, cancel_controller:,
179
179
  │ 1. Queued input (user lines, plugin steers) goes in at the boundary (Steer.inject!)
180
180
  │ 2. Format the conversation as prompt text (Prompt.format_with_images); a context
181
181
  │ guidance line goes on the tail when the window fills past a threshold
182
- │ 3. Stream one generation (:generation_started, :generation_chunk, :generation_completed)
182
+ │ 3. Fire :before_generation, then stream one generation (:generation_started, :generation_chunk, :generation_completed)
183
183
  │ 4. Parse tool calls (ToolCallParser for the profile):
184
184
  │ → Gemma 4: <|tool_call>call:NAME{params}<tool_call|>
185
185
  │ → Qwen 3.6: <tool_call><function=NAME><parameter=KEY>VALUE</parameter></function></tool_call>
@@ -466,7 +466,8 @@ persistent observers (`Engine#subscribe`), which get a copy with a monotonic `ev
466
466
  metrics, the debug log, the thinking tails, and in a worker the Bridge, which streams to the web and the attached
467
467
  TUI. Errors in the sink or an observer are caught; they never break the turn.
468
468
 
469
- `Engine#announce` puts an event that belongs to no turn (`ANNOUNCEABLE_EVENTS`: `turn_enqueued`, `card`,
469
+ `Engine#announce` puts an event that belongs to no turn (`ANNOUNCEABLE_EVENTS`: `turn_enqueued`, `turn_dequeued`
470
+ (a queued message taken back before it ran, `{enqueued_id, client_id}`: the remover's), `card`,
470
471
  `hook_notice`, `guardrail_warning`, `plugin_init_started`, and more) to the observers only.
471
472
 
472
473
  Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
data/docs/plugins.md CHANGED
@@ -28,6 +28,7 @@ plugin:
28
28
  requires_chi: ">= 0.1.28" # optional: a gem-style requirement (">= 0.1.28, < 0.2"); the bundle's hooks/ check it too
29
29
  # requires_chi_next: true # shipped bundles only, ignored by chi: rake release:bump raises requires_chi
30
30
  # to the chi being released (docs/releasing.md)
31
+ session_settings: [cap] # optional: the settings a session may set (chi --set my-bundle.cap=3)
31
32
  needs: [gh] # optional: outside commands it relies on (see memory.md#bundles-that-need-outside-commands)
32
33
  ```
33
34
 
@@ -70,7 +71,9 @@ of [anytime](#anytime-true) and the `save_note` tool (see `chi.tool` below). Ins
70
71
 
71
72
  Settings work as they do for hooks ([hooks.md](hooks.md#settings)). An
72
73
  `initialize` that takes an argument gets the bundle's section of config.yml
73
- `bundles:`, as one Hash with string keys.
74
+ `bundles:`, as one Hash with string keys. It runs once, when the plugin loads, before any session: it never
75
+ sees a session's own `--set` values. A value a session may set is read from `ctx.settings` in the handler or
76
+ command that uses it (see [Per-session settings](#per-session-settings)).
74
77
 
75
78
  ## The API: `register(chi)`
76
79
 
@@ -731,7 +734,7 @@ full label, `plugin.rb (bundle my-bundle)`.
731
734
  | `ctx.model` | the model the session runs on now: its resolved ref (`host:id`), right after `/model` too |
732
735
  | `ctx.model_key` | that model's memory overlay key (`<name>.<key>.md`, what `memory_write current_model_only` writes) |
733
736
  | `ctx.cost` | the session's spend so far, read now, as a frozen value: `total` (USD, reported + estimated, side requests included; 0.0 before any), `reported` (by providers, `usage.cost`), `estimated` (from `hosts.<name>.models` prices; a generation is one or the other, never both), `side` (recap, `/btw`, `ask_model`: their share of `total`) and `known?` (false while generations or side requests ran but none was reported or priced, so a limit on it can't be enforced; a session with some priced generations reads known, the unpriced ones counting 0). It includes the running turn's finished generations and what earlier workers of the session spent. Use its methods, not its class. Needs chi 0.83.0 |
734
- | `ctx.settings` | the bundle's settings, frozen |
737
+ | `ctx.settings` | the bundle's settings, frozen: config.yml's `bundles: <bundle>:` with the session's own `--set` values laid over it ([Per-session settings](#per-session-settings)); the config alone until the session is bound (in `register` and `chi.ctx`) |
735
738
  | `ctx.data_dir` | `$XDG_STATE_HOME/samagotchi/plugins/<bundle>/`, created on first use. A session's own state goes in `sessions/<id>.json` or `sessions/<id>/` there: chi removes it when the session is deleted, discarded or pruned |
736
739
  | `ctx.log` | `ctx.log.info(:event, key: value)`: debug-log records tagged `plugins`, with `bundle=<bundle>` |
737
740
  | `ctx.messages` | the conversation, as a frozen copy, without the system prompt. While a turn runs, a session worker's (attached, web) adds that turn so far: its prompt, the model's text and the lines merged into it (no tool calls or thinking); the REPL's is the conversation before that turn |
@@ -754,6 +757,19 @@ full label, `plugin.rb (bundle my-bundle)`.
754
757
 
755
758
  The Engine itself is never handed to a plugin.
756
759
 
760
+ ### Per-session settings
761
+
762
+ A bundle's settings are config.yml's, for every session. A setting a session may have of its own (a child's cap,
763
+ say) is one the manifest lists in `session_settings:`; `chi --set BUNDLE.KEY=VALUE` and `chi send --new --set …`
764
+ then give it to a new session, saved with it (a respawn, a fork and a continued link keep it; a delegate child
765
+ doesn't). chi refuses a bundle that isn't installed (or whose plugin doesn't load) and a key the manifest doesn't
766
+ list, so a typo isn't saved. Values are typed simply: an integer (`3`, `-1`) or a decimal (`2.5`) is a number,
767
+ `true` is true, `false` and `off` are false, and anything else stays the text as given (`010`, `0x10`, `3:30`,
768
+ `No`, `null`); `POST /api/sessions` `bundle_settings` types its strings the same way and keeps JSON numbers and
769
+ booleans. `ctx.settings` is the config's section with the session's values laid over it (one level: a session's
770
+ `cap` replaces the config's `cap`). Read such a key from `ctx.settings` where it is used, not in
771
+ `initialize(settings)`: that runs once at load, before any session, and gets the config only.
772
+
757
773
  ## Cards
758
774
 
759
775
  A card is a small framed message with buttons: a title, a body and
@@ -1147,6 +1163,7 @@ bundle.sessions.on(:children) { |all:| [] } # ctx.sessions, scripte
1147
1163
  bundle.http.stub(:post, "http://127.0.0.1:9000/classify", json: { label: "safe" }) # ctx.http, never the network
1148
1164
  bundle.set(running: false) # the session's values: cwd:, messages:, cancelled: …
1149
1165
  bundle.set(cost: 2.5) # ctx.cost: reported USD, or { reported:, estimated:, side:, known: }
1166
+ bundle.set(session_settings: { "cap" => 1 }) # the session's --set values (Bundle.load takes session_settings: too)
1150
1167
  bundle.shutdown
1151
1168
  ```
1152
1169
 
@@ -1173,6 +1190,11 @@ bundle.shutdown
1173
1190
  - **`ctx.cost`** is zero and known unless the test sets it: `bundle.set(cost: 2.5)` (reported USD), a Hash of the
1174
1191
  parts (`reported:`, `estimated:`, `side:`, `known:`; `total` is reported + estimated) or a callable giving either,
1175
1192
  called at each read (a counting lambda shows whether the bundle read it).
1193
+ - **`session_settings:`** (on `Bundle.load` and `bundle.set`) are the session's own `--set` values for the bundle,
1194
+ keys its manifest lists in `session_settings:` (another raises `ArgumentError`, as chi refuses it). `ctx.settings`
1195
+ lays them over `settings:` through chi's own `Context#settings`; the plugin's `initialize(settings)` gets
1196
+ `settings:` only. `bundle.set(session_settings:)` moves the session to a new id (`test-session-2`, or the
1197
+ `session_id:` given beside it): chi never changes them within a session, and builds `ctx.settings` once per id.
1176
1198
  - **A hook that raises fails the call** (`Testing::HookFailed`, with the hook's error) where chi would log it and
1177
1199
  go on; `bundle.lenient { … }` collects them in `bundle.errors` instead.
1178
1200
  - **Sandbox.** A test runs with temp config, state, data and cache homes (config.yml, memories, `ctx.data_dir`, the
@@ -1574,8 +1596,10 @@ bundles:
1574
1596
  Continue raises it to $6.00.` with **Continue** (another cap's worth: $3 → $6 → $9, as many steps as it takes to
1575
1597
  pass the spend, saved for the session) and **Stop** (the turn stops, `canceled` with `stopped_by: cost-cap`, and a
1576
1598
  card shows the spend, reported, estimated and side). With no one to ask (`--non-interactive`) it stops.
1577
- - **The cap of a session:** the one `/cap` or a Continue set for it, else `child:` for a delegate child, else
1578
- `default:`. Delegates and continued links each have their own cap; there is no family cap.
1599
+ - **The cap of a session:** the one `/cap` or a Continue set for it, else its own `cap:` (`chi --set
1600
+ cost-cap.cap=3`, `chi send --new --set cost-cap.cap=3`; `--set cost-cap.cap=off`, `0` or a negative number for none), else `child:` for a
1601
+ delegate child, else `default:`. Delegates and continued links each have their own cap (a continued link keeps
1602
+ the `--set` cap, not a `/cap` or Continue); there is no family cap.
1579
1603
  - **A host it can't watch:** a model that reports no cost and has no configured price (a local server, a free
1580
1604
  model that reports 0) reads unknown: one notice, `the $3.00 cap can't be enforced: <model> reports no cost and has
1581
1605
  no price`, and it never stops for an unknown cost. A session with some priced generations counts the unpriced
@@ -1589,7 +1613,9 @@ bundles:
1589
1613
  spend is allowed: the next generation asks.
1590
1614
  - `/cap off` (or `/cap 0`): no cap for this session, over the config's `default:`.
1591
1615
 
1592
- A parent agent starts a session with a cap by sending `/cap N` as its first message (`chi send --new -m '/cap 3'`).
1616
+ A parent agent starts a session with a cap of its own with `--set` (`chi send --new --set cost-cap.cap=3 -m "task"`;
1617
+ `cost-cap.cap` is the bundle's one session setting), or by sending `/cap N` as a first message
1618
+ (`chi send --new -m '/cap 3'`).
1593
1619
  At the cap, `chi send --wait` exits 3 with a question of kind `hook` (header "Cost cap"); `chi answer ID --question
1594
1620
  Q --option Continue` (or `--option 1`) goes on, `--option Stop` stops the turn ([sub-agent.md](sub-agent.md)). A
1595
1621
  parent may Continue: nothing restricts it, as with the step limit. A background delegate child at its cap waits on
data/docs/sessions.md CHANGED
@@ -4,6 +4,8 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
4
4
 
5
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
6
 
7
+ **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/`), a message in a stopped worker's inbox (the next worker runs it), and the plain REPL's (`--no-shared`) own queue.
8
+
7
9
  **Retention (file-based, opt-out via env):**
8
10
 
9
11
  | Env | Default | Purpose |
@@ -207,9 +209,10 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
207
209
  - `--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.
208
210
  - `--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).
209
211
  - 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.
210
- - `--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.
212
+ - `--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`, `removed` (a `--queue --wait` message taken back before it ran), `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.
211
213
  - `--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 an idle session whose last turn already ended, that ended turn is the answer: its reply, or its end when it left no reply — a turn that finished in the gap between waits is reported, not waited for. With none of those (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).
212
- - `--continues ID` (with `--new`) starts the next link of that session's chain instead (see *Session chains*): in its folder, on its model (or the one `--model` names), LLM context, thinking level and toolkit, so `--dir`, `--llm-context*`, `--thinking`, `--toolkit`, `--tool` and `--no-tool` 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, and a `--model` it can't start on is `chi send: could not start a session: …`, exit 1, nothing archived. A fallback to the default model (below) prints `chi send: warning: …`.
214
+ - `--set BUNDLE.KEY=VALUE` (with `--new`, repeatable) gives the new session a bundle setting of its own, as `chi --set` does (see *Bundle settings* below): `chi send --new --set cost-cap.cap=3 -m "…"` starts it with a $3 cap. A bundle or key the installed bundles don't offer is a usage error (exit 2), as is `--set` without `--new`.
215
+ - `--continues ID` (with `--new`) starts the next link of that session's chain instead (see *Session chains*): in its folder, on its model (or the one `--model` names), LLM context, thinking level, toolkit and bundle settings, so `--dir`, `--llm-context*`, `--thinking`, `--toolkit`, `--tool`, `--no-tool` and `--set` 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, and a `--model` it can't start on is `chi send: could not start a session: …`, exit 1, nothing archived. A fallback to the default model (below) prints `chi send: warning: …`.
213
216
  - 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.
214
217
  - 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.
215
218
 
@@ -243,6 +246,16 @@ declares every tool (full). The file stores only the name: a kit's tools and pro
243
246
  `/toolkit` changes it, saved at once, from the next turn's start. See
244
247
  [Toolkits](configuration.md#toolkits).
245
248
 
249
+ ## Bundle settings
250
+
251
+ A session file's `bundle_settings` (`{"cost-cap": {"cap": 3}}`, `{}` for none and for a session from before them)
252
+ holds the bundle settings it was created with: `chi --set BUNDLE.KEY=VALUE`, `chi send --new --set …`, or `POST
253
+ /api/sessions` `bundle_settings` (an object of the same shape, checked the same way; `400 invalid_bundle_settings`
254
+ otherwise). They never change after: a respawned or woken worker reads the same, and a continue (`continue →`,
255
+ `chi send --new --continues`) and a fork (`ctx.sessions.fork`) copy them, as they copy the toolkit. A delegate
256
+ child starts without (a bundle has its own setting for children, such as cost-cap's `child:`). A bundle's plugin
257
+ reads them in `ctx.settings`, over config.yml's `bundles:` ([Per-session settings](plugins.md#per-session-settings)).
258
+
246
259
  ## Delegating
247
260
 
248
261
  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-guardrails.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; `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.
data/docs/sub-agent.md CHANGED
@@ -36,6 +36,10 @@ chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--f
36
36
  chi answer ID --question QID --dismiss [--timeout S] [--format json]
37
37
  ```
38
38
 
39
+ A child with a bundle setting of its own, such as a $ cap (the [cost-cap bundle](plugins.md#the-cost-cap-bundle)):
40
+ `chi send --new --wait --format json --set cost-cap.cap=3 -m "task"`. At the cap the wait exits 3 with the cost-cap
41
+ question; `chi answer ID --question QID --option Continue` (or `Stop`) answers it.
42
+
39
43
  `chi answer` waits after answering, the same way `chi send --wait` does, so the
40
44
  next question comes back as exit 3 again. What it waits for is the answered
41
45
  turn: the turn that asked (or, for the step-limit question, the turn it
@@ -55,7 +59,7 @@ Exit codes, both commands:
55
59
  | 0 | `not_continued` | (`chi answer`) a Stop answered the step-limit question; the turn's work so far stays |
56
60
  | 3 | `question` | chi waits for an answer: `question`, `answer_with` |
57
61
  | 4 | `running` | `--timeout` passed; the turn goes on, wait again |
58
- | 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 |
62
+ | 1 | `failed`, `canceled`, `limit`, `removed`, `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; `removed`: the `--queue` message waited for was taken back before it ran, no turn ran for 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 |
59
63
  | 2 | (no JSON) | usage error, or an option the question doesn't offer |
60
64
  | 130 | `running` | Ctrl-C; the turn goes on |
61
65
 
@@ -232,6 +232,9 @@ module Samagotchi
232
232
  queued[:images] = event[:images] if event[:images]
233
233
  queued[:delivery] = event[:delivery] if event[:delivery]
234
234
  @queued << queued
235
+ # Taken back before it ran (Bridge#handle_dequeue_turn).
236
+ when :turn_dequeued
237
+ dequeue([{ enqueued_id: event[:enqueued_id] }])
235
238
  when :recap_ready
236
239
  # One collected just as a turn started describes the chat before it.
237
240
  @recap = event[:recap] unless @turn
@@ -517,7 +517,10 @@ module Samagotchi
517
517
  # turn's end (Delivery::QUEUE_FEATURE). A worker without it would steer.
518
518
  # turn_ids: the running turn's checkpoint mark, a continue turn and a
519
519
  # Stop's last_turn carry the turn's enqueued_id (ReplyWait.own_turn).
520
- FEATURES = ["restart", "task_stop", "reparent", Delivery::QUEUE_FEATURE, ReplyWait::TURN_IDS_FEATURE].freeze
520
+ # unqueue: POST turn/dequeue (#handle_dequeue_turn) takes a queued
521
+ # message back before it runs.
522
+ FEATURES = ["restart", "task_stop", "reparent", Delivery::QUEUE_FEATURE, ReplyWait::TURN_IDS_FEATURE,
523
+ SessionInbox::UNQUEUE_FEATURE].freeze
521
524
 
522
525
  # The routes, by method and what follows /session/:id/: a handler takes
523
526
  # the session id and the request body and returns [headers, status, body].
@@ -528,6 +531,7 @@ module Samagotchi
528
531
  %w[POST answer] => :handle_answer,
529
532
  %w[POST question/dismiss] => :handle_dismiss_question,
530
533
  %w[POST turn] => :handle_post_turn,
534
+ %w[POST turn/dequeue] => :handle_dequeue_turn,
531
535
  %w[POST command] => :handle_command,
532
536
  %w[POST exit] => :handle_exit_request,
533
537
  %w[POST recap] => :handle_recap,
@@ -932,6 +936,55 @@ module Samagotchi
932
936
  [{}, 202, ack]
933
937
  end
934
938
 
939
+ # POST /session/:id/turn/dequeue {enqueued_id, client_id?, deadline?}:
940
+ # take back a queued message (one that waits for the running turn's
941
+ # end: a queue or an image message) before a turn takes it. With the
942
+ # event log held, as its enqueue was, so :turn_dequeued always follows
943
+ # its :turn_enqueued; the file is claimed as the drain claims it (one
944
+ # rename wins), deleted, and its tombstone left for a wait on its turn
945
+ # (SessionInbox.take_queued_input). It never wakes the worker.
946
+ # 200 {status: "removed", enqueued_id, prompt}; 409 already_started (a
947
+ # turn took it, or the snapshot still lists it with no file left: taken,
948
+ # not announced yet), 409 not_queued (a next-step message: a drain may
949
+ # merge it any moment); 404 unknown_enqueued_id (never not_found: that is
950
+ # the unrouted path's, what a worker older than the route answers);
951
+ # 408 past its +deadline+ (see #handle_post_turn), nothing removed.
952
+ def handle_dequeue_turn(session_id, body)
953
+ parsed = parse_json(body)
954
+ return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }] unless parsed.is_a?(Hash)
955
+
956
+ id = fetched(parsed, "enqueued_id").to_s.strip
957
+ return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "enqueued_id required" }] if id.empty?
958
+
959
+ deadline = fetched(parsed, "deadline")
960
+ return BAD_DEADLINE unless deadline_valid?(deadline)
961
+
962
+ client_id = fetched(parsed, "client_id")
963
+ taken, input = @engine.synchronize_events do
964
+ raise IntakeClosed if stopped?
965
+ next [:expired, nil] if expired?("dequeue_expired", deadline, sid: session_id, enqueued_id: id)
966
+
967
+ SessionInbox.take_queued_input(session_dir, id).tap do |status, _|
968
+ @engine.announce(type: :turn_dequeued, enqueued_id: id, client_id: client_id) if status == :removed
969
+ end
970
+ end
971
+ dequeue_reply(taken, id, input)
972
+ end
973
+
974
+ def dequeue_reply(taken, id, input)
975
+ case taken
976
+ when :removed then [{}, 200, { status: "removed", enqueued_id: id, prompt: input.prompt.to_s, session_id: @session_id }]
977
+ when :expired then deadline_passed("removal")
978
+ when :not_queued
979
+ [{}, 409, { error: "not_queued", detail: "only a message queued for after the turn can be removed", enqueued_id: id }]
980
+ when :claimed then [{}, 409, { error: "already_started", enqueued_id: id }]
981
+ else
982
+ return [{}, 409, { error: "already_started", enqueued_id: id }] if @accumulator.queued.any? { |q| q[:enqueued_id] == id }
983
+
984
+ [{}, 404, { error: "unknown_enqueued_id", enqueued_id: id }]
985
+ end
986
+ end
987
+
935
988
  # Read-only snapshot surface. AC #4: too-old reconnects re-derive state
936
989
  # from here.
937
990
  def handle_state(_session_id, _body = nil)
@@ -132,6 +132,18 @@ module Samagotchi
132
132
  post("question/dismiss", { id: id, deadline: deadline }, read_body: true)
133
133
  end
134
134
 
135
+ # POST /session/:id/turn/dequeue: take back a queued message (one that
136
+ # waits for the running turn's end) before it runs. 200 {status:
137
+ # "removed", enqueued_id, prompt}; 409 already_started (a turn took
138
+ # it) or not_queued (a next-step message); 404 unknown_enqueued_id; a
139
+ # 404 not_found: a worker older than the route (its sidecar lacks
140
+ # "unqueue"); 408 = too late.
141
+ # @param client_id [String, nil] the removing UI, in its :turn_dequeued
142
+ # @return [Response]
143
+ def dequeue(enqueued_id:, client_id: nil)
144
+ post("turn/dequeue", { enqueued_id: enqueued_id, client_id: client_id, deadline: deadline }, read_body: true)
145
+ end
146
+
135
147
  # POST /session/:id/turn. 202 = queued (body carries the enqueued_id the
136
148
  # Bridge also announced in :turn_enqueued), 408 deadline_passed = the
137
149
  # Bridge read it too late and dropped it (see DEADLINE_SHARE).