samagotchi 0.138.0 → 0.139.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -1
  3. data/docs/architecture.md +33 -7
  4. data/docs/cli.md +5 -1
  5. data/docs/configuration.md +57 -4
  6. data/docs/internals/background-tasks.md +2 -2
  7. data/docs/internals/engine.md +2 -1
  8. data/docs/internals/tool-guardrails.md +1 -1
  9. data/docs/internals/web-push.md +20 -0
  10. data/docs/sessions.md +2 -1
  11. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  12. data/lib/samagotchi/config.rb +4 -0
  13. data/lib/samagotchi/engine.rb +4 -2
  14. data/lib/samagotchi/engine_peers.rb +2 -1
  15. data/lib/samagotchi/reply_wait/poll.rb +9 -2
  16. data/lib/samagotchi/reply_wait.rb +9 -3
  17. data/lib/samagotchi/task_finish_note.rb +8 -5
  18. data/lib/samagotchi/task_watch.rb +11 -6
  19. data/lib/samagotchi/tool_activity.rb +3 -3
  20. data/lib/samagotchi/tool_declarations.rb +2 -2
  21. data/lib/samagotchi/tools/builtins.rb +2 -1
  22. data/lib/samagotchi/tools/delegate_wait.rb +23 -2
  23. data/lib/samagotchi/tools/execute.rb +51 -9
  24. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  25. data/lib/samagotchi/version.rb +1 -1
  26. data/lib/samagotchi/web/app.rb +30 -158
  27. data/lib/samagotchi/web/auto_update.rb +282 -0
  28. data/lib/samagotchi/web/public/app.js +3 -3
  29. data/lib/samagotchi/web/public/task_stop.js +3 -3
  30. data/lib/samagotchi/web/public/update_notice.js +10 -4
  31. data/lib/samagotchi/web/public/update_sheet.js +3 -2
  32. data/lib/samagotchi/web/push/notifier.rb +66 -5
  33. data/lib/samagotchi/web/server.rb +45 -1
  34. data/lib/samagotchi/web/session_access_routes.rb +1 -1
  35. data/lib/samagotchi/web/session_hub.rb +20 -3
  36. data/lib/samagotchi/web/update_check.rb +56 -10
  37. data/lib/samagotchi/web/update_ops.rb +240 -0
  38. data/lib/samagotchi/worker_task_wakes.rb +1 -1
  39. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 57a409114b8194ae2d17416055b7f52c468e6a2ab5a650d8f36a4b6a0e0b0b3f
4
- data.tar.gz: 78f20cbc62ab65e54c300bad75ec2478388810c1328679c3c48d3842f80cc0a3
3
+ metadata.gz: 4acefa9a1809c64422fa70d4cc0603318d37ed5405ba73729b6f1c6e98c79c54
4
+ data.tar.gz: 5f4f9b8181e0490272402c570f6bd1c70f72e6432fc380a383b6ff5d83a3e6fb
5
5
  SHA512:
6
- metadata.gz: e285adda4382592d1cca2811e05eebdc5999d5808fc0f23a0ff7f73a87db6b4cbb504cbc43647feab56db006b66c7439d075398ef2a8eae6339bae3d42824887
7
- data.tar.gz: c796ce3b9121c74bf838959f4f1ea97b9213930bd449922f42f617afab92c8eb38f685b7098f9c67c4d20ab1622e51b7fbed99de24a71964159fa892ecec827f
6
+ metadata.gz: 0aaf6e6d8ee1cdfd0b3001beb44f884614cf45373a1cf8c0f566d7419407b01818ae3155bcfe368f485f657985fd5177aa897174567df9c0a7efcb8ba44b0504
7
+ data.tar.gz: d53d479d947de0f63b78dfd5c67fecc41cf6cface32d6ffad6cb64a743fb9e0ba08dc5115746e6513d0bb7f11fac21b3c350215dbc9ba8544385b7606de19acf
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and commands may change between minor versions. How releases are made:
7
7
  [docs/releasing.md](docs/releasing.md).
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.139.0] - 2026-10-30
11
+
12
+ ### Added
13
+
14
+ - Unattended updates (opt-in): `update.auto: install` makes chi web install a new release by itself once no turn runs
15
+ and no question waits anywhere (one try per release). With `update.restart: idle` (the default) it then restarts
16
+ workers whose sessions have been idle for 10 minutes, then chi web itself after checking the new chi starts; the
17
+ phone gets "chi updated to X", or a push saying what failed. See docs/configuration.md (Unattended updates).
18
+
19
+ ### Changed
20
+
21
+ - A message you send while a turn waits on a delegate (`delegate_result`, `delegate` with wait) ends that wait; the
22
+ child keeps running and its reply still comes.
23
+ - A message you send while `execute` runs a command hands the command over as a background task after 3 seconds,
24
+ instead of at its 120 s limit (never in pair turns or `!cmd`).
25
+
26
+ Update with `chi update`.
27
+
10
28
  ## [0.138.0] - 2026-10-30
11
29
 
12
30
  ### Changed
@@ -5285,7 +5303,8 @@ and long-lived sessions.
5285
5303
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
5286
5304
  hotkey panel that send selected text or the clipboard to your sessions.
5287
5305
 
5288
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.138.0...HEAD
5306
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.139.0...HEAD
5307
+ [0.139.0]: https://github.com/dm1try/samagotchi/compare/v0.138.0...v0.139.0
5289
5308
  [0.138.0]: https://github.com/dm1try/samagotchi/compare/v0.137.0...v0.138.0
5290
5309
  [0.137.0]: https://github.com/dm1try/samagotchi/compare/v0.136.1...v0.137.0
5291
5310
  [0.136.1]: https://github.com/dm1try/samagotchi/compare/v0.136.0...v0.136.1
data/docs/architecture.md CHANGED
@@ -152,7 +152,7 @@ home's Ruby is gone, when no `samagotchi-*.gemspec` is under its gem path, and f
152
152
  |---|---|---|
153
153
  | `SessionManager.worker_command`: every worker (attach, `chi send`, chi web, delegate and Verify-fresh children, `resume_session`, `:restart`) | the home's ruby, `Gem.paths`, `>= 0.a` with the fallback pin (`ChiHome.fallback_version`: the spawner's version when the home has it, else the home's newest; a home older than the spawner is warned once); a chi that still can't load writes one `boot_failed` line to `SAMAGOTCHI_LOG_FILE` and exits 1 | `RbConfig.ruby -e` with `>= 0.a` (a checkout: `-I lib`) |
154
154
  | `Web::Server.reexec_command` (chi web's restart) | `CHI_BOOT -- web …`; the `:no_wrapper` refusal never applies; a home whose newest chi is older than this one is passed over (logged `restart_home_older`) | `[RbConfig.ruby, wrapper, …]` (`$PROGRAM_NAME` in a checkout) |
155
- | `Web::App#update_argv` (the **Update** button's child) | `CHI_BOOT -- update --to V --no-desktop --json` | `[RbConfig.ruby, wrapper, update, …]` |
155
+ | `Web::UpdateOps#update_argv` (the **Update** button's child) | `CHI_BOOT -- update --to V --no-desktop --json` | `[RbConfig.ruby, wrapper, update, …]` |
156
156
  | `UpdateCommand` | on another Ruby than the home (`Record#running_here?`): exec `CHI_BOOT -- update …` once (`SAMAGOTCHI_UPDATE_IN_HOME=1`: an older home chi ignores it); the install (`GemUpdate`) runs `ChiHome.gem_bin` with `ChiHome.install_env` (the home's GEM_HOME/GEM_PATH for that child, `mise`'s dir on PATH); the hand-over execs `CHI_BOOT -- update --no-gem --gem-from …` | this Ruby's `gem`, the wrapper |
157
157
  | `BootstrapCommand` (commonmarker) | `ChiHome.gem_bin` + `install_env`; installed? = in the home's specifications | this Ruby's `gem` |
158
158
  | `Desktop::MacOS#launch_config` | argv `CHI_BOOT + ["--"]`; `launch_ok?` / `launch_problem` check only absolute argv entries | `[ruby, wrapper or bin/chi]` |
@@ -606,23 +606,30 @@ reload in a toast, since an open tab keeps its old JS across a `chi web` upgrade
606
606
  `installed`, the newest chi on this machine (`InstalledVersions`: the highest `samagotchi-<v>.gemspec`
607
607
  in RubyGems' specification dirs, or a checkout's `version.rb` on disk; looked at again on each full
608
608
  probe, every 10 s). A `chi` frame and the `GET /api/events` snapshot carry the full state
609
- `{version, installed, available, update_blocked}`; `/api/info` and the session-command wake response
610
- carry the same fields. `SessionHub#update_available` publishes the checker result under the hub's
609
+ `{version, installed, available, update_blocked, auto}` (`auto`: `update.auto`, `off` or `install`, which the page's
610
+ "available" wording follows); `/api/info` and the session-command wake response carry the same fields. `SessionHub#update_available` publishes the checker result under the hub's
611
611
  Monitor, but fetching never holds it.
612
612
 
613
- `Web::UpdateCheck` runs its own thread beside the hub. When `update.notify: on` (the default), its
613
+ `Web::UpdateCheck` runs its own thread beside the hub. When `update.notify: on` (the default) or
614
+ `update.auto: install` (then with `notify: off` the hub's `available` stays nil: nothing is shown or pushed, only
615
+ AutoUpdate reads `UpdateCheck#available`), its
614
616
  first RubyGems check is about 30 seconds after chi web starts, then it checks at `update.check_hours`
615
617
  intervals. A stable release is available only after `update.min_age_hours` and when newer than both
616
618
  the running and newest installed chi. Its atomic, mode-0600
617
619
  `$XDG_STATE_HOME/samagotchi/update_check.json` keeps the check cooldown and versions already
618
- announced across restarts. `update.notify: off` starts no checker thread and makes no network
619
- request. `update_blocked` names why the local gem cannot be updated in place (checkout, Bundler, or
620
+ announced across restarts, and AutoUpdate's record (`auto`, `auto_failed`; `save_auto`). `update.notify: off`
621
+ with `update.auto: off` starts no checker thread and makes no network request. `update_blocked` names why the local gem cannot be updated in place (checkout, Bundler, or
620
622
  an unwritable gem directory). The page tells the owner to run `chi update`; a newer `installed`
621
623
  version than the server itself still means restart chi web (its workers already run the newest chi,
622
624
  [Sessions](sessions.md)). `GET /api/info` also names `newest_workers`; next to `cwd` (the folder chi
623
625
  web runs in) it names `start_dir`, the folder it starts in (`web.start_dir`, `--start-dir`;
624
626
  `Web::StartDir`), which a second `chi web` given another one mentions.
625
627
 
628
+ The three update operations live in `Web::UpdateOps` (`web/update_ops.rb`), which `App`'s routes ask for
629
+ (`handle_update`, `handle_restart_workers` and `handle_restart_web` only validate, ask and map `:started` /
630
+ `:busy` / a refusal to 202 / 409 / 503) and `Web::AutoUpdate` asks for too; the one-at-a-time rule is its mutex.
631
+ `Server.start` hands `app.update_ops` to AutoUpdate and sets its restart lambda through `App#restart_web=`.
632
+
626
633
  The owner's update routes publish progress and results as `chi_update` events with an
627
634
  `operation` (`install` or `workers`), `state`, and `rows`; install events also name the target
628
635
  `version` and may include a progress line. The hub retains the latest event for a reconnecting
@@ -632,7 +639,7 @@ it holds with `update.notify: off`; a child that prints no rows fails with its o
632
639
  done install refreshes the hub's installed version (`SessionHub#refresh_installed`) before its `done`.
633
640
  Worker restarts target every worker older than the version installed now (refreshed the same way)
634
641
  and publish per-session results. Only one of an install, a worker restart pass and a web restart runs
635
- at a time (409 `update_running`). `POST /api/update/restart_workers` (`handle_restart_workers`) starts the worker pass
642
+ at a time (409 `update_running`). `POST /api/update/restart_workers` starts the worker pass
636
643
  by itself. `POST /api/update/restart_web` (409 `restart_unavailable` without a
637
644
  chi wrapper to exec) schedules WEBrick shutdown only after its response is sent; after the hub and
638
645
  listener stop, `Server` execs the RubyGems chi wrapper with every flag chi web was started with, as
@@ -642,6 +649,24 @@ listener stop, `Server` execs the RubyGems chi wrapper with every flag chi web w
642
649
  themselves are detached), and adopts an exact Tailscale Serve handler. A failed exec, or a restarted
643
650
  chi web that doesn't start for any reason, turns off only that exact adopted handler.
644
651
 
652
+ `Web::AutoUpdate` (`web/auto_update.rb`; its own thread, started and stopped by `Server.start` beside the checker)
653
+ is `update.auto: install`. Every 5 minutes (the first step 60 s after start) it looks at `UpdateCheck#available`
654
+ and installs it through `UpdateOps#install(wait: true, auto: true)` when the install isn't blocked, no operation
655
+ runs, no hub summary has `status: running` or a `pending_question`, and the version isn't in `auto_failed` or
656
+ already in the `auto` record (one automatic try per version). Its frames carry `auto: true` (the page: "Automatic
657
+ chi update in progress", "(automatic)" in the sheet's title). Its record is `update_check.json`'s `auto`:
658
+ `{version, from, at, kept, phase: installed|workers|web|done|failed, workers: {restarted, left}, detail, pushed}`.
659
+ With `update.restart: never` the run ends at the install (`chi_notice` `installed`). With `idle` it goes on
660
+ (`phase` `installed` → `workers` → `web` → `done`): `UpdateOps#restart_workers(select:)` over the stale workers whose
661
+ summary is idle with no question and `updated_at` at least 10 minutes old (`QUIET`; the others are `skipped` rows;
662
+ the worker's own atomic refusal still applies), the left ones retried each step for 24 h (`RETRY_FOR`); then, with no
663
+ turn running, `Server.restart_preflight` (the restart's command with `--version`, 20 s, killed on a timeout; it must
664
+ print a chi at least the release) and, if it passes, the record's `phase: web` is written **before**
665
+ `UpdateOps#restart_web`. A failed preflight, or a refused restart, publishes `restart_failed` and ends the run
666
+ (`failed`). The restarted chi web's AutoUpdate (first step after 5 s) finds `phase: web`, checks it runs the
667
+ release, counts the workers once more and publishes `updated`, then `done`. Results reach the phone as `chi_notice`
668
+ hub events (`SessionHub#publish_notice`), see [internals/web-push.md](internals/web-push.md).
669
+
645
670
  The page's scope is in its URL, and the server picks the default: `?dir=<folder>` is that folder's
646
671
  project (the list, the events stream and a new chat follow it), `?scope=all` every session, and a bare
647
672
  `/` the start folder's project when that folder is in a repo (`App#index_data_attributes` writes
@@ -862,6 +887,7 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
862
887
  | Review sessions | `review_start.rb` (`ReviewStart`), `session_review.rb` (`SessionReview`, the session's `review` field), `review_command.rb` (`ReviewCommand`, `chi review`), `review_apply.rb` (`ReviewApply`) | A session made to be shared: idle, its subjects project files checked by `Guardrails::ReviewPaths`, a fork of a working session from its recap (`--from`); Apply quotes a participant's review turn into the owner's composer ([sessions.md](sessions.md#review-sessions), [internals/participants.md](internals/participants.md)). |
863
888
  | chi web's routes | `web/app.rb` (`Web::App`: the gate, `ROUTES`, the shared helpers), `web/message_display.rb` (`Web::MessageDisplay`: saved messages and cards as display items, `POST /api/render`), `web/session_show.rb` (`Web::SessionShow`: `GET /api/sessions/:id` with its `cards`, `tail` and `timing` re-reads, `GET …/output`, the timing payload), `web/session_create.rb` (`Web::SessionCreate`: `POST /api/sessions`, a continue, a review fork), `web/session_control.rb` (`Web::SessionControl`: answer, command, turn and images, cancel, stop, restart, archive, delete), `web/push_routes.rb` (`Web::PushRoutes`: `/api/push`, its subscriptions, prefs, test and presence) | The handlers `App`'s `ROUTES` name, in the file that holds their kind; each mixin is included in `App` and reads its helpers and ivars, so `app.send(:handle_show, …)` and the specs that stub `App` methods work as before. |
864
889
  | Web Push | `web/push.rb` (`Web::Push`: `Runtime`, `preflight!`, the `web.push.*` settings), `web/push/crypto.rb` (`Push::Crypto`: RFC 8291 aes128gcm, RFC 8292 VAPID), `web/push/store.rb` (`Push::Store`, `Store::Sub`: `web-push/vapid.pem`, `subscriptions.json`), `web/push/client.rb` (`Push::Client`, `Client::Result`), `web/push/attention.rb` (`Push::Attention`: notify.js's rules in Ruby, shared cases in `spec/fixtures/attention_cases.json`), `web/push/presence.rb` (`Push::Presence`: the pages' "in front" reports), `web/push/notifier.rb` (`Push::Notifier`, a hub subscriber with a sender thread), `web/push_routes.rb` (`Web::PushRoutes`), `web/private_files.rb` (`Web::PrivateFiles`, shared with the grants), `public/sw.js`, `public/push.js`, `public/manifest.webmanifest` | chi web pushes what the tab notifies for (approvals, questions, step-limit questions, cards, failed turns; done turns to a device that asks) to the owner's Home Screen app over an https public URL: the Notifier turns the hub's new attentions into show pushes after a hold, unless a chi web page is in front, at most 30 an hour, with one retry and a restart catch-up (`sent.json`) (each carries the keys still open, and the badge; no close push: every push shows a notification); the bell (`notifications.js`) subscribes the device, and the service worker and the page close the answered ones. See [internals/web-push.md](internals/web-push.md). |
890
+ | chi web's updates | `web/update_check.rb` (`Web::UpdateCheck`), `web/update_ops.rb` (`Web::UpdateOps`), `web/auto_update.rb` (`Web::AutoUpdate`) | The RubyGems checker and its `update_check.json`; the install, worker-restart and chi-web-restart operations (one at a time) the owner's routes and AutoUpdate share; `update.auto: install`'s thread (install when no turn runs, then idle workers and chi web). See [configuration](configuration.md#unattended-updates-updateauto). |
865
891
  | Behind an auth proxy | `web/public_url.rb` (`Web::PublicUrl`: `web.public_url`, `web.proxy.identity_header`, `web.proxy.owner`), `web/proxy_gate.rb` (`Web::ProxyGate`, mixed into App; `RawHeaderNames`, prepended to WEBrick's request by `Web::Server`), `web/link_codes.rb` (`Web::LinkCodes`: the links' one-time codes), `proxy_trace.rb` (`ProxyTrace`), `web/public/token_fragment.js`, `web/public/auth_lost.js`, `web/tunnel.rb` + `web/tunnel/tailscale.rb` (`Web::Tunnel`, `Web::Tunnel::Tailscale`: `chi web --tunnel tailscale`, Tailscale Serve set for one run and the PublicUrl it makes) | Experimental: chi web behind an auth proxy. Every request needs the token; a proxied one's owner also needs the identity header (one clean value under one name); the `__Host-` cookie, an absolute `Location` from the public URL, https links with a one-time code in the fragment; the page's lost-sign-in banner. Bridges refuse proxy traces ([configuration.md](configuration.md#behind-an-auth-proxy)). |
866
892
  | Participants | `participants.rb` (`Participants`, the `participants.json` sidecar), `share_command.rb` (`ShareCommand`, `chi share`), `session_participants.rb`, `participants_watch.rb`, `web/grants.rb` (`Web::Grants`), `web/principal.rb` (`Web::Principal`), `web/participant_access.rb` (`Web::ParticipantAccess`: the participant's gate and routes), `web/participants_admin.rb` (`Web::ParticipantsAdmin`: the owner's invite, list, revoke, cap and Apply routes), `web/participant_review.rb` + `web/participant_view.rb` (`Web::ParticipantReview`, `Web::ParticipantView`), `web/participant_stream_filter.rb`, `guardrails/review_boundary.rb` + `guardrails/review_paths.rb` | The people or agents an owner invites to review one session through chi web: their grants and gate, what they read, their read-only review turns ([internals/participants.md](internals/participants.md), [guardrails.md](guardrails.md)). |
867
893
  | Analytics | `analytics_file.rb` (`AnalyticsFile`), `session_metrics/totals.rb` (`SessionMetrics::Totals`), `session_metrics/model_segment.rb` (`SessionMetrics::ModelSegment`) | A session's `analytics.json` (`SessionMetrics#persist`); the sums over its turn records (`Totals`: tokens, cost, speeds, kept as running counters) and their split per model after a `/model` switch (`ModelSegment`: a run of turns on one model, the `model_segments` rows); `session_metrics/cost.rb` (`SessionMetrics::Cost`): the spend `ctx.cost` reads; `session_metrics/usage.rb` (`SessionMetrics::Usage`): the work `ctx.usage` reads. `session_metrics/side_totals.rb` (`SessionMetrics::SideTotals`) and `side_usage.rb` (`SideUsage`): the side requests' sums, apart from the turns'. |
data/docs/cli.md CHANGED
@@ -256,6 +256,10 @@ done
256
256
  `update.min_age_hours` tune the interval and release age (see
257
257
  [configuration](configuration.md#all-settings)). If the gem can't be updated
258
258
  in place, the notice gives the blocker and its command instead.
259
+ With `update.auto: install` chi web installs the release by itself once no
260
+ turn runs anywhere, then restarts idle workers and itself (`update.restart`),
261
+ and the notice reads "installs by itself" with no **Update** button; see
262
+ [Unattended updates](configuration.md#unattended-updates-updateauto).
259
263
 
260
264
  - **The chi home.** With a [chi home](configuration.md#the-chi-home), a `chi
261
265
  update` started on another Ruby first says `updating the chi home (<ruby>)`
@@ -462,7 +466,7 @@ of UIs at once: the Web UI and attached terminals (`chi`, `--resume`,
462
466
  `--attach`). They all see the same turns as they happen, and any of them can send
463
467
  a prompt, also while a turn runs (it merges into that turn as steering: the model
464
468
  is told it is a steer and who sent it). Steering waits for the model's next step
465
- and cuts nothing; a `task_wait` the model is blocked in ends early for it (the task keeps running). `/cut TEXT` in a terminal or the web composer (or `chi send --cut`) asks for a cut:
469
+ and cuts nothing; a `task_wait` or `delegate_result` the model is blocked in ends early for it (the task or child keeps running), and a running `execute` command moves to a background task once it has run 3 s. `/cut TEXT` in a terminal or the web composer (or `chi send --cut`) asks for a cut:
466
470
  once the model has streamed only thinking for `steer.cut_after` seconds (per host/model: `steer_cut_after`; default
467
471
  20), now or while your message waits, that generation is cut and the step starts again with your message, marked
468
472
  `↪ cut in for your message` (see
@@ -179,7 +179,7 @@ or `chi web` restart.
179
179
  | `history.file` | Next history access | The TUI and web composer resolve its path when they read or append history. |
180
180
  | `broadcast.*` | Next `chi broadcast` | Each broadcast invocation reads its targeting and triage settings. |
181
181
  | `update.gem`, `update.bundles`, `update.desktop` | Next `chi update` | The command reads which components to update when it runs. |
182
- | `update.notify`, `update.check_hours`, `update.min_age_hours` | `chi web` start | The update checker starts with chi web; restart it to apply changes. |
182
+ | `update.notify`, `update.check_hours`, `update.min_age_hours`, `update.auto`, `update.restart` | `chi web` start | The update checker starts with chi web; restart it to apply changes. |
183
183
  | `kitty.*`, `tmux.*` | `chi desktop upgrade` or desktop update | These settings are copied into the desktop helper's launch file. |
184
184
  | `model.profile`, `reasoning_history` | Not from config.yml | These are CLI/environment settings; `models:` and `hosts:` provide their model- and host-specific forms. |
185
185
 
@@ -1143,8 +1143,13 @@ thinking is lost. A generation that has streamed visible text or a tool call is
1143
1143
  (`ctx.steer`, `ctx.sessions.send`) never cuts.
1144
1144
 
1145
1145
  A message that would go in at the next step (any but a queued one, one with images or a participant's) also ends a
1146
- `task_wait` the model is blocked in, within half a second: the task keeps running, the model is told a message waits,
1147
- reads it at once and decides whether to wait again. A plugin's steer and a delegate child's report don't end one.
1146
+ `task_wait` or a `delegate_result` (and `delegate`'s own wait) the model is blocked in, within half a second: the task
1147
+ or the child keeps running, the model is told a message waits, reads it at once and decides whether to wait again. It
1148
+ also moves a running `execute` command to a background task early, once the command has run 3 seconds (a command that
1149
+ ends sooner just returns its result): the result says `Still running at the 4s mark: a message from the user is
1150
+ waiting …` and names the `task_id`, and `task_wait` waits for it as for any hand-over. With `execute.on_timeout:
1151
+ kill`, in a participant's pair turn and for the `!cmd` bang an `execute` is never handed over early (they kill at the
1152
+ limit). A plugin's steer and a delegate child's report don't end any of them.
1148
1153
 
1149
1154
  The wait resolves per model, in this order: env `SAMAGOTCHI_STEER_CUT_AFTER`, then
1150
1155
  `models.<key>.steer_cut_after`, `hosts.<name>.steer_cut_after`, `steer.cut_after` in config.yml, then 20. `0` means
@@ -2033,6 +2038,9 @@ there; the new subscription replaces the dead one.
2033
2038
  approval, which its parent may relay instead); one push each. A delegated child's failed or finished turn is its
2034
2039
  parent's to report. **Done turns** (10 s or longer, or one nobody started: a delegate's report, a context change, a
2035
2040
  finished task) are **off** by default, per device: the **Long turns done** switch in the devices sheet turns them on.
2041
+ - **Updates**: a new chi release ("chi X is available", once per version) and, with
2042
+ [`update.auto: install`](#unattended-updates-updateauto), how the automatic update went ("chi updated to X", "chi X
2043
+ didn't install", …): kept 24 h for an offline phone, `normal` urgency, never the file names.
2036
2044
  - **Quiet while you look**: nothing is pushed while any chi web page (a tab on the Mac, or the app) is in front and
2037
2045
  was touched in the last 2 minutes; what came meanwhile is never pushed later (the page has it). A page left in
2038
2046
  front untouched stops quieting the phone after about 2 minutes.
@@ -2052,6 +2060,49 @@ there; the new subscription replaces the dead one.
2052
2060
  (it shows the "needs chi web's access token" page; paste the token there). Pushes keep coming meanwhile.
2053
2061
  - chi web not running: no pushes (Serve answers 502; a tap couldn't open the page anyway).
2054
2062
 
2063
+ ## Unattended updates (`update.auto`)
2064
+
2065
+ By default chi web only tells you a release is out (`update.notify`, [CLI: Updating](cli.md#updating)). With
2066
+ `update.auto: install` in `config.yml` it also installs the release its checker found, aged by `update.min_age_hours`
2067
+ (24 h by default, so a release is never installed the day it ships), and goes on to `update.restart`:
2068
+
2069
+ ```yaml
2070
+ update:
2071
+ auto: install # off (default): a notice only
2072
+ restart: idle # idle (default): restart workers and chi web onto it; never: install only
2073
+ ```
2074
+
2075
+ Both are read when chi web starts (restart it to apply a change). What it does, in order:
2076
+
2077
+ 1. **Waits for a quiet moment.** The install starts when no turn runs and no question waits for an answer in any
2078
+ session (checked every 5 minutes), no other update operation runs (the **Update** button, a restart) and the
2079
+ install isn't blocked (a source checkout, Bundler, a RubyGems directory chi can't write: those stay a notice, with
2080
+ the command to run). `chi update` also merges the shipped bundles that live sessions read, hence "no turn anywhere".
2081
+ 2. **Installs** exactly the release the notice named, as the **Update** button does (the system bundle and shipped
2082
+ bundles are merged three ways; files you edited are kept and counted in the push). The page's sticky toast says
2083
+ "Automatic chi update in progress" and its sheet shows the rows. A failed install is tried **once per version**:
2084
+ it pushes the failed row's reason and stays quiet until a newer release (or you, with the button) tries again.
2085
+ 3. **Restarts** (`update.restart: idle`). Stale workers first, only those whose session is idle and untouched for
2086
+ 10 minutes (so nobody who is about to type loses their worker), and each worker still refuses by itself when
2087
+ something holds it ([sessions](sessions.md)); the ones left are tried again for a day, then wait for their own
2088
+ idle exit or the session's **Restart**. Then chi web itself, once no turn runs anywhere: it first runs the
2089
+ restart's own command with `--version` (20 s at most) and restarts only if the newest chi answers with the
2090
+ release's version; otherwise chi web stays up and the phone hears "chi X is installed; chi web didn't restart".
2091
+ The restart is the **Restart chi web** one: same port, same token, same Tailscale Serve handler, the open page's
2092
+ streams reconnect in a few seconds and a Reload toast follows. It doesn't wait for a page in front: the page isn't
2093
+ reloaded by itself, so a draft in the composer stays. A **Send** or **Allow** pressed in those few seconds fails
2094
+ with its error and leaves the text in the composer: press it again.
2095
+ 4. **Says so.** The restarted chi web pushes "chi updated to X" ("chi web and 3 sessions restarted; 1 restarts when
2096
+ idle. Kept your edits in 2 files."). With `update.restart: never` the push is "chi X is installed" and chi web and
2097
+ the workers move over when you restart them (the page's toast offers it). The push needs the phone set up
2098
+ ([Phone notifications](#phone-notifications-web-push)); without it the page's toasts and
2099
+ `$XDG_STATE_HOME/samagotchi/update_check.json` (`auto`) are the record.
2100
+
2101
+ There is no watchdog: chi web restarts into the newest chi it just proved can start, and nothing brings an old one
2102
+ back by itself. To roll back, install the older gem with `gem install samagotchi -v X` and restart chi web
2103
+ (`chi update --to` refuses a version that isn't newer than the running chi). The desktop helper isn't rebuilt (that
2104
+ stays a manual `chi update`).
2105
+
2055
2106
  ## All settings
2056
2107
 
2057
2108
  Every setting below takes the three forms described in
@@ -2165,6 +2216,8 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
2165
2216
  | `update.bundles` | `true` | | `false`: `chi update` leaves the shipped bundles to `chi bundle upgrade` (`--no-bundles`). |
2166
2217
  | `update.desktop` | `true` | | `false`: `chi update` leaves the desktop helper alone (`--no-desktop`). |
2167
2218
  | `update.notify` | `on` | | `on`: chi web checks RubyGems once a day and shows aged stable releases newer than both this chi and the newest installed chi; `off`: no thread or network check. |
2219
+ | `update.auto` | `off` | | `install`: chi web installs the release its checker found (aged by `update.min_age_hours`) once no turn runs and no question waits in any session, as the **Update** button would (shipped bundles merged, your edits kept and reported), then pushes how it went; `update.restart` says what follows. An install that fails is tried once per version (a newer release, or the **Update** button, tries again). The check runs even with `update.notify: off` (which then only silences the page's and the phone's "available"). An install that can't run here (a checkout, Bundler, an unwritable gem dir) stays a notice. `off`: notice only. See [Unattended updates](#unattended-updates-updateauto). |
2220
+ | `update.restart` | `idle` | | With `update.auto: install`: `idle` restarts the stale workers whose session is idle and untouched for 10 minutes, then chi web itself, once no turn runs anywhere and the newest chi has answered `chi --version`; it pushes "chi updated to X". `never`: the install only (the page's toast offers the restarts, workers also exit on their own when idle). |
2168
2221
  | `update.check_hours` | `24` | | Hours between RubyGems checks (at least 1). The first check starts about 30 seconds after chi web starts. |
2169
2222
  | `update.min_age_hours` | `24` | | A release must be this old before chi web offers it; `0` disables the age delay. |
2170
2223
  | `kitty.listen_on` | none | | The desktop helper's kitty socket, copied from `kitty.conf`; unset = no kitty targets. See "Desktop helper: agents in kitty". |
@@ -2182,7 +2235,7 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
2182
2235
  | `execute.telemetry_threshold_pct` | `80` | yes | |
2183
2236
  | `execute.cut_head_chars` | `1000` | yes | An `execute` result over `max_tool_output_chars` keeps at most this many characters of its start (and never more than a quarter of the cap); its end gets the rest. |
2184
2237
  | `execute.timeout_sec` | `120` | yes | Seconds one `execute` command may run before `execute.on_timeout` applies. |
2185
- | `execute.on_timeout` | `task` | yes | What happens to a command still running at that limit: `task` keeps it running as a background task (the result names its `task_id`; `task_wait` waits for it, `task_stop` stops it), `kill` stops it (`Error: command timed out after …s`). The `!cmd` bang always kills. See [Background tasks](internals/background-tasks.md). |
2238
+ | `execute.on_timeout` | `task` | yes | What happens to a command still running at that limit: `task` keeps it running as a background task (the result names its `task_id`; `task_wait` waits for it, `task_stop` stops it; also early, after 3 s, when a message from the user waits for the turn), `kill` stops it (and never hands over early) (`Error: command timed out after …s`). The `!cmd` bang always kills. See [Background tasks](internals/background-tasks.md). |
2186
2239
  | `execute.description` | `true` | | `execute` offers the model an optional `description` (a few words on what the command does), shown as the tool row's title in `chi web` and in place of the command in the TUI's tool line. `false`: the parameter isn't declared, and rows show the command's first step. |
2187
2240
  | `web.port` | `4567` | `--port` | `chi web`'s port. See [CLI](cli.md). |
2188
2241
  | `web.host` | `127.0.0.1` | yes | `127.0.0.1`, `::1` or `localhost`; `lan` (this machine's private IPv4 address) or one of its IPv4 addresses also opens `chi web` to the network, with an access token, asked of this machine's 127.0.0.1 too (its `Local:` link carries a one-time code; `chi web --new-token` replaces the token). Anything else binds `127.0.0.1` with a warning. See [CLI: chi web on your phone](cli.md#chi-web-on-your-phone). |
@@ -25,7 +25,7 @@ Behavior:
25
25
  - `execute` runs its command with stdout and stderr in two logs under `$XDG_STATE_HOME/samagotchi/execute/<id>/` (always an absolute path: a relative `XDG_STATE_HOME` is taken from the directory chi started in; outside the workspace: `git status` and `rm -rf tmp` never see them), stdin `/dev/null` and the same `exit_code` wrapper as a task. Its own copies of the logs hold a shared `flock` across the spawn: while a fresh exclusive `flock` fails, some process the command started still has them open. When the shell exits, a background child still holding them (`server &`) gets 1.5 s, then its group is stopped; one holding none (`nohup server >log 2>&1 &`) is left alone. A holder that left the group (setsid) is out of the stop's reach: it runs on, the result ends with the "left running" hint, and its dir stays for it to write to (the sweep removes it once no one holds the logs). Otherwise a finished command's dir is removed. Known gap on Linux: a command that writes through `/dev/stdout` or `/dev/stderr` (`echo b >/dev/stdout`) reopens the log file itself with `O_TRUNC`, so the output before it is lost (macOS dups the descriptor; the fix would be a relay pipe). The check needs the state dir on a local file system: over NFS, `flock` is emulated with `fcntl` locks, which children don't inherit.
26
26
  - The exit code: the command runs after an EXIT trap that writes the shell's status to the `exit_code` file (`TaskRuntime.wrapped_shell_command`, shared with `task_create`), so `exit N`, a `set -e` failure and a trailing `\` or `|` in the command all record what the shell exited with. The trap runs wherever the shell is when it exits (a `task_create` `cwd`, or a `cd` in the command), so `task_create`'s `exit_code_path` is absolute; its `output_path` stays relative (`tmp/tasks/<id>/output.log`, opened by chi in the worker's cwd before the spawn). A command that sets its own EXIT trap replaces it, and a shell killed by a signal skips it (zsh's sh mode, on TERM, INT and KILL): `execute` still reports the shell's status, and a `task_create` task or a handed-over execute gets its code from chi's waiter while the chi that started it runs (`TaskRuntime.record_exit_status`): the exit status, or `128 + the signal` (TERM → 143), with no new `stop_reason`. A record a refresh already finished without a code in between (`failed`, `process_ended_without_exit_code` or the not-chi's-process reason) is corrected, its `finished_at` kept; a stopped task (its stop flag on disk) is never touched. `process_ended_without_exit_code` is left only when the shell wrote nothing and the chi that started it was gone too.
27
27
  - A stop of an `execute` (the user's Stop, the limit in kill mode) is done once the shell is reaped and nothing holds the logs: a child that ignores TERM after its shell died gets the KILL.
28
- - A command still running at `execute.timeout_sec` is handed over as a task (`execute.on_timeout: task`, the default; `kill` stops it instead, and the `!cmd` bang always kills): the result starts `Still running at the 120s limit: moved to a background task, not killed.`, then `task_id: …` and the tails of both logs so far. A timeout during the 1.5 s background grace is a kill (the shell is gone, nothing to hand over). If the record can't be written, the command is killed. `TaskRuntime.adopt` writes a record for the process execute already started, with `handed_over_by: "execute"`. Its logs stay where execute wrote them, so the record's paths are absolute: `output_path` (stdout), `stderr_path` (stderr, a log of its own) and `exit_code_path` (a `task_create` record's `exit_code_path` is absolute too; its `output_path` is relative). `started_at` (and `created_at`) is the spawn time, not the hand-over's; `pid_started` is read at the hand-over, while execute's detach waiter is still running. From there it is an ordinary task: `task_wait`, `task_get`, `task_list` and `task_stop` treat it as any other. Its dir gets a `task` back-pointer file holding the record's absolute path. Once per process, the first `execute` sweeps `…/execute/`: a dir whose logs no process holds is removed when it has no back-pointer and is a day old (chi died mid-command), when its record is gone, or when it is 14 days old. The records live in each project's `tmp/tasks`, which the state dir can't list: hence the back-pointer.
28
+ - A command still running at `execute.timeout_sec` is handed over as a task (`execute.on_timeout: task`, the default; `kill` stops it instead, and the `!cmd` bang always kills): the result starts `Still running at the 120s limit: moved to a background task, not killed.`, then `task_id: …` and the tails of both logs so far. A command is also handed over early when a person's message waits for the turn's next step (the `message_waiting:` probe, the same `Engine#input_waiting?` as `task_wait`'s): after `Execute::EARLY_HAND_OVER_FLOOR_SEC` (3 s: a short command ends first and its result is the better answer), in the next 0.2 s slice. The first line then reads `Still running at the 4s mark: a message from the user is waiting, so the command moved to a background task, not killed.` (the seconds it had run), and the line after `task_id:` starts `Read the message first.`; the rest is the same hand-over, and the task stays watched. Never in `kill` mode (`execute.on_timeout: kill`, a participant's pair turn, the `!cmd` bang): there the probe is not even read and the command runs to its limit and is killed. A command that can't be adopted at that moment (a record that can't be written) isn't killed early: it runs on to its limit. Everything that matches the first line builds on `Execute::HANDED_OVER` (`Still running at the`) and `Execute::HANDED_OVER_AFTER` (`120s limit:` or `4s mark:`): `TaskRuntime.handed_over_pattern`, `ToolActivity::HANDED_OVER_START`, `TaskWatch` (`limit_sec` for the limit's form, `early_sec` for the mark's, which the finish note words as `handed over by execute after 4s: a message from the user was waiting`) and the web's `task_stop.js`; the slow-commands bundle reads only the limit's form, which is the only one that is a timeout. A timeout during the 1.5 s background grace is a kill (the shell is gone, nothing to hand over). If the record can't be written, the command is killed. `TaskRuntime.adopt` writes a record for the process execute already started, with `handed_over_by: "execute"`. Its logs stay where execute wrote them, so the record's paths are absolute: `output_path` (stdout), `stderr_path` (stderr, a log of its own) and `exit_code_path` (a `task_create` record's `exit_code_path` is absolute too; its `output_path` is relative). `started_at` (and `created_at`) is the spawn time, not the hand-over's; `pid_started` is read at the hand-over, while execute's detach waiter is still running. From there it is an ordinary task: `task_wait`, `task_get`, `task_list` and `task_stop` treat it as any other. Its dir gets a `task` back-pointer file holding the record's absolute path. Once per process, the first `execute` sweeps `…/execute/`: a dir whose logs no process holds is removed when it has no back-pointer and is a day old (chi died mid-command), when its record is gone, or when it is 14 days old. The records live in each project's `tmp/tasks`, which the state dir can't list: hence the back-pointer.
29
29
  - `task_wait` and `task_get` show `stderr_path` when the record has one; `task_wait`'s output tail is followed by a `stderr_tail:` of at most 10 lines when that log isn't empty. A `task_create` task has one log (stdout and stderr together) and no `stderr_path`. A `task_wait` result longer than `max_tool_output_chars` keeps its header and the tail's end, with a gap line naming `output_path`, where the whole log already is (and `stderr_path`, when the record has one) (`ToolOutputCut`'s `:head_tail_ref`; nothing is copied; see [Tool output guardrails](tool-guardrails.md#the-runners-cut-max_tool_output_chars)).
30
30
  - Which tasks are a session's own (a cancelled turn's note, a worker's restart hold, the finish wake below, the web's stop-task button): the ids its `task_create` results and its handed-over executes name (`TaskRuntime.created_ids_in`, `created_id`). An execute's id is read only after its first line (`Execute::HANDED_OVER`, through `TaskRuntime.handed_over_id`; the web has a JS twin in `task_stop.js`), not from a `task_id:` line inside a run, which the command can print. (A command that prints the `---` separator chi joins a turn's results with, then a hand-over line, still fakes one: a known gap.) Either result may start with the `ran as: …` line of a gate-corrected call.
31
31
  - `task_get` returns metadata and `output_path`; use `read` for output contents.
@@ -40,7 +40,7 @@ Behavior:
40
40
  (`--no-shared`) a line in its steering queue. The probe only peeks: the drain still merges the message at the
41
41
  next boundary. The result says `note: a message from the user is waiting; the task is STILL RUNNING.`, how to wait
42
42
  again or stop it (`TaskWait::MESSAGE_WAITING_NOTE`), and `waited: Ns`; the task keeps running and stays watched
43
- (below). A task that finished meanwhile gives its finished result, as after a Stop. A `chi -p` turn has no probe.
43
+ (below). A task that finished meanwhile gives its finished result, as after a Stop. A `chi -p` turn has no probe. The same probe ends a blocking `delegate_result` (and `delegate`'s wait) early; see [Sessions: Delegating](../sessions.md).
44
44
  - `task_create` accepts `env` as a JSON object string for deterministic overrides such as `PATH`; use an absolute interpreter path when that is simpler. Ruby/Bundler isolation variables remain protected.
45
45
  - `execute` and `task_create` both spawn with chi's own Ruby/Bundler setup scrubbed (`TaskRuntime.sanitized_spawn_env`): `RUBYOPT` loses its `-r…bundler/setup` flags (others stay) and `RUBYLIB`, `BUNDLE_GEMFILE`, `BUNDLE_BIN_PATH`, `BUNDLER_VERSION` are unset, so a project's `ruby -e` or `bundle exec` uses its own Gemfile when chi runs under `bundle exec`. `task_create` refuses those keys in the model's `env`; `execute`'s `env` comes from chi's callers (`Builtins`, `!cmd`), not the model, and is set over the scrub. An `execute` handed over at its limit keeps the process it spawned, so the task runs with the same env.
46
46
  - `execute` and `task_create` export chi's own markers into the command, set over the model's `env` (which can't set them: `Error: env key is reserved`): `SAMAGOTCHI_PARENT_SESSION` (the session's id, `chi` without one; a chi started there answers as a parent agent), `SAMAGOTCHI_SESSION_MODEL` (the resolved ref the session runs on, read at each call so it follows `/model`; unset when unknown) `SAMAGOTCHI_SESSION_THINKING` (the session's own thinking level; unset when it has none) and `SAMAGOTCHI_SESSION_REASONING_HISTORY` (its own reasoning_history level, the same way). `chi self` reads the last three for its model, thinking level and reasoning history. The four are `ChildEnv::SESSION_MARKERS` (`TaskRuntime::MARKER_ENV_KEYS`), which a spawned worker unsets (`ChildEnv.unset`, merged into its spawn env). A session's worker never inherits them from whoever started it (a `chi send` run by another session's `execute`): its other commands (a user's `!cmd`, an MCP server, a context command) have none.
@@ -23,7 +23,8 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
23
23
  │ # turn.resume_from = resume_from (its run's start; nil: it starts cold))
24
24
  │ # @session = session, session.status = RUNNING, used memories absorbed,
25
25
  │ # TurnState#begin!(controller:, sink:, input_waiting:) (a new CancellationController when none
26
- │ # is given; input_waiting: the caller's peek, Engine#input_waiting?, which task_wait polls),
26
+ │ # is given; input_waiting: the caller's peek, Engine#input_waiting?, which task_wait,
27
+ │ # delegate_result's wait and execute's early hand-over poll),
27
28
  │ # a recap in flight invalidated, GuardrailWiring#begin_turn(origin)
28
29
  │ # Returns a Turn {session, prompt (nil on a continue), continue, on_event, controller,
29
30
  │ # origin, started_at, id (the caller's id:, else a new uuid), resumed, resume_from,
@@ -48,7 +48,7 @@ Configuration (config.yml `execute.*` keys and CLI flags too, as for `read`):
48
48
  - `SAMAGOTCHI_EXECUTE_PREVIEW_BYTES` (default `12288`): total preview budget split across head and tail.
49
49
  - `SAMAGOTCHI_EXECUTE_TELEMETRY_THRESHOLD_PCT` (default `80`): include estimated output token impact only when threshold is crossed.
50
50
  - `SAMAGOTCHI_EXECUTE_TIMEOUT_SEC` (`execute.timeout_sec`, default `120`): what a command still running then gets is `execute.on_timeout`.
51
- - `SAMAGOTCHI_EXECUTE_ON_TIMEOUT` (`execute.on_timeout`, default `task`): `task` hands the command over as a background task, not killed; the result starts `Still running at the 120s limit: moved to a background task, not killed.` (`Execute::HANDED_OVER`), then its `task_id:` and the tails of stdout and stderr so far, and `task_wait` waits for it. `kill` stops it and answers `Error: command timed out after …s` (`Execute::TIMEOUT_PREFIX`) with the hint to use `task_create`. The `!cmd` bang always kills. See [Background tasks](background-tasks.md).
51
+ - `SAMAGOTCHI_EXECUTE_ON_TIMEOUT` (`execute.on_timeout`, default `task`): `task` hands the command over as a background task, not killed; the result starts `Still running at the 120s limit: moved to a background task, not killed.` (`Execute::HANDED_OVER`), then its `task_id:` and the tails of stdout and stderr so far, and `task_wait` waits for it. `kill` stops it and answers `Error: command timed out after …s` (`Execute::TIMEOUT_PREFIX`) with the hint to use `task_create`. The `!cmd` bang always kills. With `task`, a message from the user waiting for the turn hands a command over early, after 3 s, with the first line `Still running at the 4s mark: a message from the user is waiting, …` (same `Execute::HANDED_OVER` start; never under `kill`). See [Background tasks](background-tasks.md).
52
52
 
53
53
  The env a command runs in (the session's project env, `ProjectEnv.tools_env`, then the Ruby/Bundler scrub and chi's markers) is the same for `execute`, `task_create`, `!cmd` and context sources: see [Background tasks](background-tasks.md).
54
54
 
@@ -169,6 +169,26 @@ each `session` / `session_gone` event into a `Show` (with its text) or a `Close`
169
169
  Queue. A `chi` event marked `update_notice` becomes a `chi:update:<version>` show for the bare page; the checker
170
170
  sets that marker only for a newly selected version not already in its persistent `notified` list. It uses the same
171
171
  Presence skip and hourly CAP as session shows, and the 24-hour TTL lets an offline phone receive it later.
172
+ With `update.auto: install` (and an install that is not blocked) that marker is ignored: the page says the release
173
+ installs by itself, and the phone hears the result instead.
174
+
175
+ **The automatic update's shows.** `Web::AutoUpdate` says how a run went with `SessionHub#publish_notice`, a
176
+ `chi_notice` hub event (`{kind, version, …notes}`): not a `chi_update` frame, which is the page's (it would repeat
177
+ in a reconnect's snapshot and toast), and not kept. The Notifier turns it into a show with reason `update` (24 h TTL,
178
+ `Urgency: normal`, counted in CAP, skipped with the key marked handled while a page is in front, shown for the bare
179
+ page `/`), once per key in its process (`@updates_seen`; AutoUpdate's own record in `update_check.json` keeps a run
180
+ from saying it twice across restarts). The key's `chi:` prefix keeps sw.js from treating it as a waiting key:
181
+
182
+ | `kind` | key | title / body |
183
+ |---|---|---|
184
+ | `installed` (`update.restart: never`) | `chi:installed:<X>` | "chi X is installed" / "chi web and its sessions switch over when you restart them." + kept edits |
185
+ | `failed` | `chi:update_failed:<X>` | "chi X didn't install" (or "installed with a problem" when the gem row was fine) / the first failed row's status, "Open chi web for details." |
186
+ | `updated` | `chi:updated:<X>` | "chi updated to X" / "chi web and 3 sessions restarted; 1 restarts when idle." + kept edits |
187
+ | `restart_failed` | `chi:restart_failed:<X>` | "chi X is installed; chi web didn't restart" / why (a failed `--version` preflight, no wrapper), "Restart chi web in its terminal." |
188
+
189
+ `updated` is sent by the **restarted** chi web (its AutoUpdate finds the run at `phase: web`): the exec ends the
190
+ old process, and `Notifier#stop` does not flush the shows still in their hold, so a push queued just before it would
191
+ be lost. Sending from the new process also proves it came up.
172
192
 
173
193
  **Seeding**: `Notifier#start(hub)` subscribes with `snapshot: true` and takes the summaries as the seed, so what is
174
194
  open already never pushes. The hub's projection is empty before its first scan, so `Server.start` runs `hub.scan`
data/docs/sessions.md CHANGED
@@ -8,7 +8,7 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
8
8
 
9
9
  **Revise stacks:** a web Revise turn (POST /turn `revises: {turn_id}`, the answer it rewrites) stamps its opening message (its prompt) with `revises: {turn_id}` at the turn's end, only when the turn completed with an answer (`AnswerTail`): a failed, canceled or empty turn revised nothing (`Engine#stamp_opening`). A draft no prompt carries (a stale page, a rolled-back turn) is no target: no stamp. A draft a stamped turn already revised is re-targeted to its stack's top, the newest version, so a stack is a chain `v1 ← v2 ← v3`. Like `verify`, it is metadata only, never added to the prompt (no change to a request's bytes); chi web's history passes it on the opening message (and on a wake turn's note), and `:turn_completed` carries the stamped `revises` (the participant view's too). The history also marks a Verify turn's opening `verify: true`, and so does its live end event (`:turn_completed`'s `verify: true`, with its `turn_id`): the page offers no Revise on its answer. The web page draws the stacks from these stamps (docs/cli.md, Revise). Like a Verify turn's, a Revise message waits for the running turn's end (`SessionInbox.turn_end_data?`; the Bridge takes either as `delivery: queue`, so it never cuts and its ack and `:turn_enqueued` say it queues), is sent only to a worker whose `bridge.json` names the `revise` feature (else 409 `revise_unavailable`), and a failed turn gives it back (`:prompt_restored`'s `revises`). Participants can't Revise: chi web and the Bridge drop it from their turns. Both anchors travel as one `TurnAnchors` inside chi; on the wire and in an input file each keeps its own key (`verify`, `revises`).
10
10
 
11
- **Input files:** a message for a session's worker waits in `input/<timestamp>.json` (`SessionInbox.write_input`: `prompt`, `client_id`, `enqueued_id`, and `images`, `no_interrupt` and `delivery` when they apply; `delivery` is written only for `"queue"`). While a turn runs, the worker merges them at each step boundary in name order, except a queued one or one with images: those wait for the turn's end and run one turn each, in name order, and a file after them still merges (`Worker#pending_input_drain`). Such a file, with a prompt, from the user, `chi send` or a delegating parent, also ends a running `task_wait` (`SessionInbox.steer_waiting?`, a peek the worker's turn polls through `Engine#input_waiting?`; never in a participant's turn): the file stays for the drain. A queue is sent only to a worker whose `bridge.json` names the `delivery` feature; an older one would steer it, so `SessionManager.deliver_turn` refuses it (409 `delivery_unavailable`), and any other 4xx the Bridge answers is a refusal too, never a fallback file (except a 413, a body over its size limit: that one still goes in as a file, its `delivery` kept). A session command recognized in a file runs without a turn; its outcome is kept at `input/settled/<enqueued_id>` (`completed`, `failed` for a command error, `canceled` with `cancel_reason` `busy` or `dropped` for one refused mid-turn or dropped as the worker left; `origin: command`) so `chi send --wait` ends instead of waiting for a turn that will not run, its detail saying `the command failed` or `the command was refused (busy)` rather than a turn's words. **Reply files:** a turn that ends with visible text leaves its reply in `output/<timestamp>.txt`, or `output/<timestamp>-<enqueued_id>.txt` when the turn ran an input file with an id (`SessionInbox.write_output`), and the session's `last_turn` keeps that `enqueued_id`; so a wait for one queued message (`chi send --queue --wait`, `ReplyWait`'s `turn:`) finds its own turn's reply or end, never another turn's. The names still sort by time, and every reader takes `*.txt`.
11
+ **Input files:** a message for a session's worker waits in `input/<timestamp>.json` (`SessionInbox.write_input`: `prompt`, `client_id`, `enqueued_id`, and `images`, `no_interrupt` and `delivery` when they apply; `delivery` is written only for `"queue"`). While a turn runs, the worker merges them at each step boundary in name order, except a queued one or one with images: those wait for the turn's end and run one turn each, in name order, and a file after them still merges (`Worker#pending_input_drain`). Such a file, with a prompt, from the user, `chi send` or a delegating parent, also ends a running `task_wait` or `delegate_result` and hands a running `execute` over as a task after 3 s (`SessionInbox.steer_waiting?`, a peek the worker's turn polls through `Engine#input_waiting?`; never in a participant's turn): the file stays for the drain. A queue is sent only to a worker whose `bridge.json` names the `delivery` feature; an older one would steer it, so `SessionManager.deliver_turn` refuses it (409 `delivery_unavailable`), and any other 4xx the Bridge answers is a refusal too, never a fallback file (except a 413, a body over its size limit: that one still goes in as a file, its `delivery` kept). A session command recognized in a file runs without a turn; its outcome is kept at `input/settled/<enqueued_id>` (`completed`, `failed` for a command error, `canceled` with `cancel_reason` `busy` or `dropped` for one refused mid-turn or dropped as the worker left; `origin: command`) so `chi send --wait` ends instead of waiting for a turn that will not run, its detail saying `the command failed` or `the command was refused (busy)` rather than a turn's words. **Reply files:** a turn that ends with visible text leaves its reply in `output/<timestamp>.txt`, or `output/<timestamp>-<enqueued_id>.txt` when the turn ran an input file with an id (`SessionInbox.write_output`), and the session's `last_turn` keeps that `enqueued_id`; so a wait for one queued message (`chi send --queue --wait`, `ReplyWait`'s `turn:`) finds its own turn's reply or end, never another turn's. The names still sort by time, and every reader takes `*.txt`.
12
12
 
13
13
  **Removing a queued message:** a message queued for after the running turn (`chi send --queue`, the web's Alt+Enter, `/queue TEXT` in a terminal), or one with images (it waits for the turn's end too), can be taken back while it still waits: the worker's Bridge (`POST /session/:id/turn/dequeue {enqueued_id}`, the `unqueue` feature) deletes its input file and every UI drops it (`:turn_dequeued`). It is a race with the turn's end, decided by the same rename the worker uses to take the file: once a turn took it the answer is `409 already_started` (it runs), and an id the worker no longer knows is `404 unknown_enqueued_id`. A plain message sent without `--queue` is not removable (`409 not_queued`): a running turn's next step may take it at any moment. A wait for its turn (`chi send --queue --wait`, a delegate follow-up's wait) ends at once with `removed` (the tombstone `input/removed/<enqueued_id>` says so), and a `chi -p` run whose queued prompt went ends with exit 1. The `200` carries the removed `prompt`: the web and the attached terminal put it back into the composer to edit (not its images). The images a removed message named stay in the session's `images/`. Not removable: a queued session command (`/queue /model x`: it waits in the worker, not in `input/`), a message in a stopped worker's inbox (the next worker runs it), and the plain REPL's (`--no-shared`) own queue.
14
14
 
@@ -428,6 +428,7 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
428
428
  - `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.
429
429
  - `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.
430
430
  - `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.
431
+ - A wait also ends early when a person's message waits for the parent's turn's next step (the `task_wait` rule: `Engine#input_waiting?`, `SessionInbox.steer_waiting?`, never in a participant's turn): `ReplyWait`'s `message_waiting:` probe gives `:message_waiting`, checked after every outcome the child can arrive at (a reply, a question, an end, a gone worker) and a cancel, before the deadline, and the result says `no reply yet: a message from the user is waiting; the child keeps running. Read the message first.` with the same last line as a timeout's. The child is never touched, the cursor doesn't move (the reply still comes, as a report or at the next `delegate_result`), a relayed approval's outcome line stays in the result, and a reserved report reads as for a timeout. A delegate child's report doesn't end a wait. This holds for `delegate`'s own wait too (both go through `DelegateWait`).
431
432
  - 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`.
432
433
  - What a wait returns, `session: <id>` then `status:` and the text, with the status words `chi send --wait --format json` uses: `answered` with the reply after a `---` line; `question` as soon as the child opens a question (the model's own, or a hook's), with the question's text, options and the `chi answer` command for it (nothing is answered for it: `chi answer`, attach or use the web, then wait again); `failed`, `canceled` or `no_answer` when the child's turn ended with nothing to say (its session shows what happened); `error` (its worker crashed) or `stopped` (`chi sessions stop`) at once; `worker_gone` when the child has had no worker for 15 seconds (it crashed without saying so, or idle-exited): `delegate` with `session:` starts it again; `running` on timeout, or when the parent's own turn is canceled (the child keeps running; the next turn can wait again). A reply saved before the child's worker idle-exited is returned at once.
433
434
  - **A child's step-limit question goes back to the parent model**, not to its user: `delegate_result` returns `status: question` with the `chi answer` commands for Continue and Stop, and says the model may instead send a narrower follow-up (`delegate session:`, which drops the question) or report back. A parent's `chi answer` follows `guardrails.parent_answers`, or the child's own when stricter (`hard`: Stop only; see [chi as a sub-agent](sub-agent.md#the-step-limit)).
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.138.0
3
+ version: 0.139.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -293,6 +293,10 @@ module Samagotchi
293
293
  Entry.new(key: "update.bundles", yaml_path: %w[update bundles], type: :bool, default: true, expose: %i[env config], lifecycle: :next_update),
294
294
  Entry.new(key: "update.desktop", yaml_path: %w[update desktop], type: :bool, default: true, expose: %i[env config], lifecycle: :next_update),
295
295
  Entry.new(key: "update.notify", yaml_path: %w[update notify], type: :enum, default: "on", expose: %i[env config], enum_values: %w[on off], lifecycle: :web_start),
296
+ # Unattended installs (chi web, Web::AutoUpdate): install: the release the checker found, once no turn runs anywhere;
297
+ # update.restart then says whether idle workers and chi web restart onto it (idle) or it waits for the owner (never).
298
+ Entry.new(key: "update.auto", yaml_path: %w[update auto], type: :enum, default: "off", expose: %i[env config], enum_values: %w[off install], lifecycle: :web_start),
299
+ Entry.new(key: "update.restart", yaml_path: %w[update restart], type: :enum, default: "idle", expose: %i[env config], enum_values: %w[idle never], lifecycle: :web_start),
296
300
  Entry.new(key: "update.check_hours", yaml_path: %w[update check_hours], type: :integer, default: 24, expose: %i[env config], lifecycle: :web_start,
297
301
  check: ->(value) { "at least 1" unless value >= 1 }),
298
302
  Entry.new(key: "update.min_age_hours", yaml_path: %w[update min_age_hours], type: :integer, default: 24, expose: %i[env config], lifecycle: :web_start,
@@ -671,7 +671,8 @@ module Samagotchi
671
671
  end
672
672
 
673
673
  # Whether a message a person sent waits for the running turn's next
674
- # boundary (run_turn's input_waiting: probe): a task_wait returns on it
674
+ # boundary (run_turn's input_waiting: probe): a task_wait or delegate wait
675
+ # returns on it, an execute hands its command over early
675
676
  # (PeerView#input_waiting?). False outside a turn, for a turn given no
676
677
  # probe, and when the probe raises (warned once a turn; the tool never
677
678
  # fails for it).
@@ -2242,7 +2243,8 @@ module Samagotchi
2242
2243
  # by the agentic loop at iteration boundaries (see KernelLoop#run).
2243
2244
  # @param input_waiting [#call, nil] optional probe: true while a message
2244
2245
  # a person sent waits for the next boundary (it peeks, never drains);
2245
- # a running task_wait returns on it (#input_waiting?). nil: never.
2246
+ # a running task_wait or delegate wait returns on it, a running execute
2247
+ # hands its command over (#input_waiting?). nil: never.
2246
2248
  # @param continue [Boolean] resume the conversation without appending a
2247
2249
  # user prompt (continue after the iteration limit, reminder turns);
2248
2250
  # +prompt+ is ignored and :turn_started carries `continue: true`
@@ -9,7 +9,8 @@ module Samagotchi
9
9
  # is the running turn's cancel, for a tool that waits (delegate_result):
10
10
  # the controller is set from another thread and a tool gets no other
11
11
  # way to see it. input_waiting? is whether a person's message waits for
12
- # the running turn's next boundary (task_wait returns on it).
12
+ # the running turn's next boundary (task_wait and the delegate tools' waits
13
+ # return on it, execute hands its command over early).
13
14
  PeerView = Struct.new(:engine) do
14
15
  def session_id = engine.session&.id
15
16
  def cwd = engine.session&.working_directory
@@ -16,12 +16,13 @@ module Samagotchi
16
16
  # them. ReplyWait.call's parameters say what each means.
17
17
  class Poll
18
18
  def initialize(id, state_dir:, cursor:, timeout:, poll_interval:, cancelled:, baseline:, owner_grace:, interject:,
19
- turn:, queued:, ended_before:, running_look: nil)
19
+ turn:, queued:, ended_before:, running_look: nil, message_waiting: -> { false })
20
20
  @id = id
21
21
  @state_dir = state_dir
22
22
  @cursor = cursor
23
23
  @poll_interval = poll_interval
24
24
  @cancelled = cancelled
25
+ @message_waiting = message_waiting
25
26
  @baseline = baseline
26
27
  @owner_grace = owner_grace
27
28
  @interject = interject
@@ -80,7 +81,7 @@ module Samagotchi
80
81
  follow_merge(session)
81
82
  reply(session) || settled_turn(session) || status_end(session) || question(session) || held(session) || canceled ||
82
83
  ended_before_reply(session) || ended_before_turn(session) || turn_end(session) || owner_gone(session) ||
83
- timed_out
84
+ message_waiting || timed_out
84
85
  end
85
86
 
86
87
  def look_at_input
@@ -189,6 +190,12 @@ module Samagotchi
189
190
 
190
191
  def canceled = @cancelled.call ? Result.new(status: :canceled) : nil
191
192
 
193
+ # A person's message waits for the caller's turn (call's
194
+ # message_waiting:): after every outcome the child can arrive at (a
195
+ # reply, a question, an end, a gone worker) and a cancel, before the
196
+ # deadline. The child isn't touched.
197
+ def message_waiting = @message_waiting.call ? Result.new(status: :message_waiting) : nil
198
+
192
199
  # The turn that ended before this wait (ended_before: chi send --wait
193
200
  # with no message, on a session that went idle in the gap between
194
201
  # waits): its last_turn still stands, the session is idle, so its end
@@ -27,7 +27,8 @@ module Samagotchi
27
27
  TURN_IDS_FEATURE = "turn_ids"
28
28
 
29
29
  # @!attribute status [Symbol] :done, :waiting_for_answer, :no_reply,
30
- # :error, :stopped, :canceled, :timeout or :worker_gone
30
+ # :error, :stopped, :canceled, :message_waiting, :timeout or
31
+ # :worker_gone
31
32
  # @!attribute text [String, nil] the reply (:done) or the worker's error
32
33
  # (:error)
33
34
  # @!attribute file [String, nil] the reply's filename, the next cursor
@@ -159,14 +160,19 @@ module Samagotchi
159
160
  # @param running_look [RunningLook, nil] the running turn an earlier
160
161
  # call of the same wait saw (its :timeout result's running_look): its
161
162
  # settle ends this call too, as if this call had seen it running
163
+ # @param message_waiting [#call] true once a person's message waits for
164
+ # the caller's turn (Engine#input_waiting?): ends the wait early
165
+ # (:message_waiting) when nothing else has, after a cancel, before the
166
+ # deadline. The session waited for is never touched.
162
167
  # @return [Result]
163
168
  # @raise [ArgumentError] no such session
164
169
  def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
165
170
  baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false, ended_before: false,
166
- running_look: nil)
171
+ running_look: nil, message_waiting: -> { false })
167
172
  Poll.new(id, state_dir: state_dir, cursor: cursor, timeout: timeout, poll_interval: poll_interval,
168
173
  cancelled: cancelled, baseline: baseline, owner_grace: owner_grace, interject: interject, turn: turn,
169
- queued: queued, ended_before: ended_before, running_look: running_look).run
174
+ queued: queued, ended_before: ended_before, running_look: running_look,
175
+ message_waiting: message_waiting).run
170
176
  end
171
177
 
172
178
  # The session as it was before a message went in (call's baseline:):
@@ -21,24 +21,27 @@ module Samagotchi
21
21
 
22
22
  # @param record [Tools::TaskRecord] the finished task
23
23
  # @param limit_sec [Integer, nil] the limit an execute handed it over at
24
+ # @param early_sec [Integer, nil] the seconds an execute had run when a
25
+ # message from the user handed it over before its limit
24
26
  # @param wake [Boolean] the wake variant
25
27
  # @param cap [Integer] max_tool_output_chars: task_wait's text is cut to
26
28
  # it as ToolRunner cuts the tool's result
27
29
  # @return [Hash] the note (ContextNote.message's keys)
28
- def note(record, limit_sec:, wake:, cap:, turn_id: nil, now: Time.now)
30
+ def note(record, limit_sec:, wake:, cap:, early_sec: nil, turn_id: nil, now: Time.now)
29
31
  note = { note_id: "task-#{record.id}", source: "task #{short_id(record.id)}", header: ContextNote::TASK_HEADER,
30
- text: text(record, limit_sec: limit_sec, wake: wake, cap: cap), created_at: now.iso8601 }
32
+ text: text(record, limit_sec: limit_sec, early_sec: early_sec, wake: wake, cap: cap), created_at: now.iso8601 }
31
33
  wake ? note.merge(turn_start: true, turn_id: turn_id) : note
32
34
  end
33
35
 
34
- def text(record, limit_sec:, wake:, cap:)
35
- [headline(record, limit_sec), "command: #{record.command}", body(record, cap), (WAKE_TEXT if wake)].compact.join("\n")
36
+ def text(record, limit_sec:, wake:, cap:, early_sec: nil)
37
+ [headline(record, limit_sec, early_sec), "command: #{record.command}", body(record, cap), (WAKE_TEXT if wake)].compact.join("\n")
36
38
  end
37
39
 
38
- def headline(record, limit_sec)
40
+ def headline(record, limit_sec, early_sec = nil)
39
41
  seconds = duration(record)
40
42
  outcome = record.exit_code.nil? ? record.status.to_s : "exit #{record.exit_code}"
41
43
  handed = " (handed over by execute at its #{limit_sec}s limit)" if limit_sec
44
+ handed ||= " (handed over by execute after #{early_sec}s: a message from the user was waiting)" if early_sec
42
45
  "Background task #{short_id(record.id)} finished: #{outcome}#{" after #{duration_text(seconds)}" if seconds}#{handed}."
43
46
  end
44
47