samagotchi 0.116.0 → 0.118.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -2
  3. data/docs/cli.md +28 -1
  4. data/docs/configuration.md +6 -0
  5. data/docs/internals/background-tasks.md +1 -1
  6. data/docs/sessions.md +3 -3
  7. data/lib/samagotchi/bridge.rb +88 -14
  8. data/lib/samagotchi/bridge_client.rb +13 -1
  9. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  10. data/lib/samagotchi/config.rb +19 -3
  11. data/lib/samagotchi/hold_stop.rb +89 -0
  12. data/lib/samagotchi/prompt_warmup.rb +8 -3
  13. data/lib/samagotchi/session_access/web_bridge_client.rb +9 -4
  14. data/lib/samagotchi/session_manager.rb +3 -2
  15. data/lib/samagotchi/task_watch.rb +23 -5
  16. data/lib/samagotchi/terminal_ui/attached_loop.rb +130 -8
  17. data/lib/samagotchi/version.rb +1 -1
  18. data/lib/samagotchi/web/app.rb +2 -0
  19. data/lib/samagotchi/web/public/app.js +17 -1
  20. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  21. data/lib/samagotchi/web/public/composer_size.js +14 -7
  22. data/lib/samagotchi/web/public/data.js +14 -0
  23. data/lib/samagotchi/web/public/favicon.svg +1 -1
  24. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  25. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  26. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  27. data/lib/samagotchi/web/public/index.html +28 -2
  28. data/lib/samagotchi/web/public/manifest.webmanifest +1 -1
  29. data/lib/samagotchi/web/public/session_actions.js +39 -6
  30. data/lib/samagotchi/web/public/stop_sheet.js +126 -0
  31. data/lib/samagotchi/web/public/tokens.css +2 -2
  32. data/lib/samagotchi/web/session_access_routes.rb +9 -6
  33. data/lib/samagotchi/web/session_control.rb +40 -3
  34. data/lib/samagotchi/worker.rb +57 -5
  35. data/lib/samagotchi/worker_idle_exit.rb +111 -9
  36. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e86ef8d1890939908a44f84be426b6132992e9483af840e9438ad2d59829da9
4
- data.tar.gz: adce5380c4f88f152c7aab62c26176d38b6ee6f1e268abe24b7d4b8850c1bb4e
3
+ metadata.gz: 06322a414267628aff61367b71dc4a6ecb76d4789673d594aff987a8da678d94
4
+ data.tar.gz: 12cc40d770cfd7072ccb1a64543033da5619f19d472ae69e7db880ae9a7f867f
5
5
  SHA512:
6
- metadata.gz: c794e9f67ee56a67ec85fa0ec801919d3d0917b62b477491b12ca6475d0678402833b1191d973f791edfb7345c0cbee16509ecb8dcf3be1a5345ead097dfeb0a
7
- data.tar.gz: 7d3da8e7edf45917785b14bf521eaee4559a829c631570a0336a4fcce00953b62a46a7e8f28ae4ffcb8130bae6b42b944e0783b8dbde35f918489a4504937ee8
6
+ metadata.gz: 0db2e221e335c74dcb3770ec17d92a2ded1b4e92589e958e33f95008e0ab5a9e13d72aed2ebb3bc2875fa6800e5cba6e19898b68515fda951a1709b5fc864a09
7
+ data.tar.gz: 7d8e40e0f7966a75d0914229a6e7a94b626ed9a045451ab65c154eef2eea3a5e8ba81eb20fc90cff703986e063fbfbef55225919a4be47e92b3b6b28ae611bbf
data/CHANGELOG.md CHANGED
@@ -5,9 +5,49 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
5
  versions follow [Semantic Versioning](https://semver.org/); before 1.0, config
6
6
  and commands may change between minor versions. How releases are made:
7
7
  [docs/releasing.md](docs/releasing.md).
8
-
9
8
  ## [Unreleased]
10
9
 
10
+ ## [0.118.0] - 2026-10-26
11
+
12
+ ### Changed
13
+
14
+ - chi's icon (the browser tab and the Home Screen app) is flat: a system-blue χ on a graphite tile, no gradient. On a
15
+ phone, remove the Home Screen app and add it again to see it: iOS keeps the old icon.
16
+ - chi web's blues match the icon: links, the active card and the Send button use Apple's system blue in both themes,
17
+ and the page background keeps only its faint blue glow (the purple one is gone).
18
+
19
+ ### Fixed
20
+
21
+ - chi web's session bar stops squeezing its meta row into a narrow column in a small window: the bar's own actions
22
+ (continue, archive, stop session, delete) take a line of their own when they cannot sit beside the row, the folder
23
+ takes at most a third of it and a chip at most 30% (cut with "…", the full text in the tooltip), so a session with a
24
+ long model name and a long folder reads two lines at 760 px instead of five. Phones (≤ 600 px) scroll the row as
25
+ before.
26
+
27
+ Update with `chi update` (the system bundle 0.118.0), then restart `chi web`.
28
+
29
+ ## [0.117.0] - 2026-10-26
30
+
31
+ ### Added
32
+
33
+ - `/exit` in an attached terminal **asks what to do with what still runs** when only things you can stop keep the
34
+ worker up (reminders, a watched background task, a delegate with a live worker): keep running in the background,
35
+ stop them and exit, or go back. Anything else that holds the worker (a running turn, queued prompts, another UI)
36
+ still refuses as before, and a delegate never holds the idle exit. See [cli.md](docs/cli.md#attached-terminal).
37
+ - The web's `stop session` asks the same first: a sheet (a bottom sheet on a phone) counts what still runs and offers
38
+ **Stop all**, **Stop session only** (the stop as it was: background tasks and delegates keep running) or
39
+ **Cancel**; with nothing to stop it is one tap as before. See [Sessions](docs/sessions.md).
40
+
41
+ ### Fixed
42
+
43
+ - An unquoted `on`/`off` in `config.yml` now means that word for a setting that takes it
44
+ (`guardrails.system_one.mode: on`, `session.task_wakes: off`) instead of silently falling back to the default
45
+ (YAML 1.1 reads them as booleans). See [Configuration](docs/configuration.md).
46
+ - chi web's composer grip keeps the message box inside the window it is shown in: a height saved in a bigger window
47
+ is held to half of a smaller one (a rotated phone, a resized window) and comes back whole when the room is back.
48
+
49
+ Update with `chi update` (the system bundle 0.117.0), then restart `chi web`.
50
+
11
51
  ## [0.116.0] - 2026-10-25
12
52
 
13
53
  ### Added
@@ -4405,7 +4445,9 @@ and long-lived sessions.
4405
4445
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
4406
4446
  hotkey panel that send selected text or the clipboard to your sessions.
4407
4447
 
4408
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.116.0...HEAD
4448
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.118.0...HEAD
4449
+ [0.118.0]: https://github.com/dm1try/samagotchi/compare/v0.117.0...v0.118.0
4450
+ [0.117.0]: https://github.com/dm1try/samagotchi/compare/v0.116.0...v0.117.0
4409
4451
  [0.116.0]: https://github.com/dm1try/samagotchi/compare/v0.115.0...v0.116.0
4410
4452
  [0.115.0]: https://github.com/dm1try/samagotchi/compare/v0.114.0...v0.115.0
4411
4453
  [0.114.0]: https://github.com/dm1try/samagotchi/compare/v0.113.0...v0.114.0
data/docs/cli.md CHANGED
@@ -434,7 +434,34 @@ In an attached terminal:
434
434
  doesn't wait out the idle timeout. It stays up while something still needs
435
435
  it, and the detach line says what: a turn is running (Ctrl-C cancels it
436
436
  first), prompts are queued, a continue offer is pending, another UI is
437
- attached, or reminders are set. A web tab you just closed can count as
437
+ attached, or reminders are set. When every hold is something you can stop —
438
+ reminders, a background task it watches for a [finish wake](internals/background-tasks.md#finish-wake),
439
+ a delegate with a live worker — it asks instead of refusing, even when that
440
+ is all there is:
441
+
442
+ ```
443
+ This session still has:
444
+ • 2 reminders: api_health (every 20m), git push (every 5m)
445
+ • 1 background task: rails s (2m)
446
+ • 1 delegate: c1c1c1c1 "Fix the composer" (running a turn)
447
+ 1. Keep running in the background (detach)
448
+ 2. Stop them and exit
449
+ 3. Back to the session
450
+ ```
451
+
452
+ 1 is today's detach. 2 cancels the reminders, stops the tasks the same way
453
+ `task_stop` does and stops the delegates the same way `chi sessions stop ID`
454
+ does, then exits as `/exit` does; the line names what it stopped (a child a
455
+ terminal owns, or a task that can't be stopped, is left running and logged).
456
+ Each entry says what it is: a reminder its schedule (`every 20m`, `every 1h`,
457
+ with `, due now` when its next turn has already come), a task how long it has
458
+ run, a child its title and what it is doing. 3 keeps the prompt open, and the
459
+ next `/exit` asks again; Ctrl-D, `/detach` and a line the ask doesn't
460
+ understand are not answers to it (Ctrl-D detaches, a stray line is re-asked).
461
+ A delegate counts only for this ask: it never holds the idle exit, so 1 leaves
462
+ it running and the worker idles out as usual. `/exit --delete` and `/archive`
463
+ don't ask — with nothing else holding the worker, a running child does not
464
+ stop them, as before. A web tab you just closed can count as
438
465
  attached for about 30 s. When the worker exits, `chi --resume ID` or
439
466
  `chi --attach ID` starts a new one with the conversation. Before it exits
440
467
  (here and on the idle exit) the worker writes the session's recap if
@@ -97,6 +97,12 @@ Behavior:
97
97
  - Top level is a YAML mapping of sections (`default:`, `server:`, `recap:`, …)
98
98
  and the maps described below. Keys are lower `snake_case`, one level per
99
99
  dot: `server.read_timeout` is `server: {read_timeout: 600}`.
100
+ - Each key takes its own type: a number, a boolean (`true`/`false`, and
101
+ `yes`/`on`/`1`, `no`/`off`/`0`), text, or one of the words the setting
102
+ allows (`auto`, `audit`, `on`, …). YAML 1.1 reads an unquoted `on`, `off`,
103
+ `yes` or `no` as a boolean, so for a setting whose values are `on` and
104
+ `off` an unquoted `off` is the same as `"off"` (`guardrails.system_one.mode: off`,
105
+ `session.task_wakes: off`).
100
106
  - A key chi doesn't read warns at start, with the closest known key:
101
107
  `config: unknown key 'default.modle' (did you mean 'default.model'?)`.
102
108
  Names you choose under the maps below (host names, model ids) don't warn.
@@ -56,7 +56,7 @@ A task the model leaves running (it ends its turn: "I'll check when it's finishe
56
56
  - **The wake turn**: the first finished task runs a continue turn from client `task:<id>` (`last_turn.origin: "task"`, labelled `task <id8> finished` in the TUI and the web; the web's bell rings at its end however short), whose first message is the note (`turn_start`, `turn_id`, written before the turn's checkpoint so a restore keeps it). It counts toward `session.max_wakes` with the other wakes (`WorkerWakes#task_open?`: no continue offer, not paused, past the 2 s start grace). Several tasks finishing together: one wake turn, the others plain notes before it.
57
57
  - **A plain note instead** (the same text without the last two lines, no turn start), written at once, with `queue`, the budget spent (the UIs hear `N finished background tasks noted; the model reads them with your next message` once per human input, the flag shared with the other wakes), wakes paused, a continue offer waiting, input queued, an exit asked for, or a **note-only** task: one started or waited on in a turn the user stopped (reasons `user`, `manual`, `ctrl_c`), since a wake telling the model to carry on would undo that Stop. A prompt's turn takes the finished ones as plain notes first.
58
58
  - **Failure**: a wake turn that fails and kept nothing, or is stopped and restored, turns its note back into the plain one (`WorkerInbound#unmark_wake`, as for a context wake) and pauses wakes until the user writes.
59
- - **Idle exit**: with `wake`, a watched task holds the worker's idle exit (`WorkerIdleExit`'s `:background_tasks`) until its wake is taken: while it runs, and once it finished until the next idle pass takes it (`TaskWatch#holding`, in memory, no `ps`), so one that ends between the pass's look and the exit's check still wakes the session. A still-running one holds `/exit` too (`TaskWatch#running`, read once per request by `WorkerIdleExit#request_hold`; the 409 lists the tasks, `{id, command}`, with a `hint` on stopping them, and the attached terminal names them and says to use the web's stop-task button or ask for `task_stop`), as reminders do. A note-only task holds neither (no wake would come for it), nor does one whose note, or the look for it (the conversation's task ids unreadable), failed 3 times (`TaskWatch::MAX_FAILURES`: it is still tried at each look, but can't keep the worker up for good). With `queue` nothing holds: nothing would wake for the end, so keeping the worker up isn't worth it. As the worker leaves (any exit but a stop, a signal or an `/exit --delete`), the tasks that finished since the last look are written as plain notes; a task that ends after the worker left gets no note (the model sees its end at its next look, as before). A forgotten dev server keeps the worker up until it is stopped; a crash of that server wakes the session.
59
+ - **Idle exit**: with `wake`, a watched task holds the worker's idle exit (`WorkerIdleExit`'s `:background_tasks`) until its wake is taken: while it runs, and once it finished until the next idle pass takes it (`TaskWatch#holding`, in memory, no `ps`), so one that ends between the pass's look and the exit's check still wakes the session. A still-running one holds `/exit` too (`TaskWatch#running`, read once per request by `WorkerIdleExit#request_hold`; the 409 lists the tasks, `{id, command, age_seconds}`, with a `hint` on stopping them, and the attached terminal names them and says to use the web's stop-task button or ask for `task_stop`), as reminders do. When the only holds are ones the user can stop, the 409 carries `holds` too and the terminal asks whether to stop them all (`HoldStop`, which stops these tasks the same way `task_stop` does). A note-only task holds neither (no wake would come for it), nor does one whose note, or the look for it (the conversation's task ids unreadable), failed 3 times (`TaskWatch::MAX_FAILURES`: it is still tried at each look, but can't keep the worker up for good). With `queue` nothing holds: nothing would wake for the end, so keeping the worker up isn't worth it. As the worker leaves (any exit but a stop, a signal or an `/exit --delete`), the tasks that finished since the last look are written as plain notes; a task that ends after the worker left gets no note (the model sees its end at its next look, as before). A forgotten dev server keeps the worker up until it is stopped; a crash of that server wakes the session.
60
60
  - **The tool descriptions**: a worker's `execute` and `task_create` say "if you end your turn before it finishes, chi tells you when it does" (left out under `off` and in the REPL), fixed when the worker builds its Engine.
61
61
 
62
62
  Work that needs a model, not a shell command, goes to a child chi session instead: `delegate` / `delegate_result` have the same shape (start, then wait) and return only the child's final reply; see [Sessions: Delegating](../sessions.md#delegating).
data/docs/sessions.md CHANGED
@@ -19,7 +19,7 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
19
19
 
20
20
  A session is deleted if **expired by age OR overflow by count** (unless `keep_status` or the live-owner guard: a session a worker or `chi` still has open is never pruned). A session's `status` is its turn state (`idle`/`running`), not whether a worker is alive. Orphan dirs without a `*.json` are never deleted, except skeleton-only ones (see *Empty sessions*). Deletion removes both `*.json` and sidecar dir atomically. Set both `DAYS=0` and `MAX=0` to retain forever.
21
21
 
22
- **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered, no background task it watches for a [finish wake](internals/background-tasks.md#finish-wake) still running or waiting for its wake turn (with `session.task_wakes: wake`; not one whose turn you stopped) and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). As its Bridge stops, the worker marks the lock leaving (`OwnerLock#mark_leaving`: `leaving_at`, and `leave_wait`, its `recap.timeout`), since it keeps the lock while it writes the recap on leave: an `--attach` or `--resume` meanwhile (here, or a remote one through chi web's wake) prints `(waiting for the session to finish its recap…)` (`LaunchMode::RECAP_WAIT`) and waits for the release, up to that budget plus `OwnerLock::LEAVE_MARGIN` (5 s), before starting the new worker (`OwnerLock.await_release`). A lock held by a worker that isn't leaving (a live one, a stuck one) is left as before. A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up (for background tasks, which ones and how to stop them). The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, reminders, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
22
+ **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered, no background task it watches for a [finish wake](internals/background-tasks.md#finish-wake) still running or waiting for its wake turn (with `session.task_wakes: wake`; not one whose turn you stopped) and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). As its Bridge stops, the worker marks the lock leaving (`OwnerLock#mark_leaving`: `leaving_at`, and `leave_wait`, its `recap.timeout`), since it keeps the lock while it writes the recap on leave: an `--attach` or `--resume` meanwhile (here, or a remote one through chi web's wake) prints `(waiting for the session to finish its recap…)` (`LaunchMode::RECAP_WAIT`) and waits for the release, up to that budget plus `OwnerLock::LEAVE_MARGIN` (5 s), before starting the new worker (`OwnerLock.await_release`). A lock held by a worker that isn't leaving (a live one, a stuck one) is left as before. A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up (for background tasks, which ones and how to stop them). When every hold is one the user can stop — reminders, a watched task still running, a delegate with a live worker — the 409 also carries `holds: [{kind:, items: [{id:, label:, detail:}]}]` (a reminder's detail is its schedule, `every 20m`/`every 1h`, `, due now` when it is already due), and an attached terminal asks what to do with them; its "stop them and exit" posts `{"stop": "all"}`, which clears the reminders, stops those tasks (`TaskRuntime.stop_task`, as `task_stop`) and stops those children (`SessionManager.stop_session`) before leaving, re-checking first that nothing the user can't stop appeared meanwhile (`HoldStop`). A delegate counts only for that ask: it never holds the idle exit, and an exit that deletes or archives the session does not ask about it. The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, reminders, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
23
23
 
24
24
  **Empty sessions:** a session nothing happened in is deleted when it is left, so `chi` → `/exit` leaves no trace. Empty means: no messages, no turn tried (a failed turn leaves a `last_prompt` and keeps it), no memory used, no pending question, nothing in `input/`, `notes/` or `images/`, nothing in its dir beyond the worker's skeleton, mode `assist`, and the default model. A session on another model (`/model`, `--model`) counts as prepared and stays (`SessionManager.discardable?`). The worker checks as it idle-exits or leaves on a client's request and deletes it once its lock is free, checking again first. The attached `/exit` then says `Detached; the session is empty: it will be discarded if nothing arrives before its worker stops.` (a note or prompt landing first keeps it). The REPL (`--no-shared`) does the same at `/exit` or Ctrl-D. The retention sweep catches those killed first: empty sessions over an hour old with no live owner, whatever `DAYS`/`MAX`, and skeleton-only dirs with no `*.json`. `session.keep_empty: true` (env `SAMAGOTCHI_SESSION_KEEP_EMPTY`) turns all of it off.
25
25
 
@@ -33,7 +33,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
33
33
 
34
34
  **A worker killed mid-turn:** while a turn runs, its worker keeps it on disk (`TurnCheckpoint`): the session file has the turn so far (the prompt from the turn's start, then the finished tool steps and lines merged into it) and `interrupted_turn: {id, started_at, at, iteration, enqueued_id}` (`enqueued_id`: the input the turn runs, so any process can tell which turn is running; a continue turn carries its offer turn's), written as the turn starts, at the loop's first iteration boundary and then at most every 10 s (`updated_at` moves with it). The turn's end saves the real messages without the mark. A worker that died without ending its turn (SIGKILL, a crash of the process) leaves the mark, and loses what came after its last written boundary: at most the last 10 s of steps, plus the step in flight. Each checkpoint write also saves the turn's metrics so far to `analytics.json` (tokens, speeds, timings, its finished tool calls), as the record a killed turn leaves: canceled, `cancellation_reason: worker_stopped`; the turn's own end writes over it. The next worker to start for the session (a `chi send`, `--resume`, `--attach`, the web), or a `--no-shared` REPL resuming it, settles the turn before anything else (`TurnCheckpoint.settle`, log `interrupted_turn_settled`): a note `the previous turn was cancelled (worker-stopped) after Ns` goes at the tail, the status goes idle and the mark goes; `last_turn` stays the previous turn's (a new one saved before the turn a `chi send --wait` woke the worker for would end that wait at once); the dead turn's end is recorded under its `enqueued_id` instead, in `<id>/input/settled/<enqueued_id>` (a `last_turn` record: canceled, `cancel_reason: worker_stopped`), which only a wait for that very turn reads (`chi send --queue --wait`, a `chi send --wait -m` to an idle session or `--new`, `chi send --wait ID --turn`, a continue's wait): it ends `canceled` with `cancel_reason: worker_stopped`, detail "its worker exited mid-turn", instead of timing out. A wait with no turn id that saw the turn running ends the same way when the settle comes (`chi send --wait ID` with no message on a turn with no id, such as a session's first prompt or a reminder turn; a message merged into the turn as a steer), unless an input still waits or was claimed since: an unmerged message runs as its own turn next, and that turn's end is the answer. A reply the turn wrote before its worker died is still its answer. The session shows the turn's prompt and steps with that note; the dead turn is not run again, and the model reads on its next turn that it was cut.
35
35
 
36
- **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`. A running turn is canceled over the Bridge first, with reason `session_stopped` (the turn's line ends "(session stopped)", its note says `cancelled (session-stopped)`, and a command it was running says "stopped as the session was stopped"; a bare signal to the worker makes it "stopped by a signal to chi"; an older worker takes the reason as `manual`); the TERM stops whatever still runs as a Stop with reason `signal` and saves it (a spawned worker traps TERM, HUP and INT: `WorkerSignals`; a turn not ended within 3 s, 0.5 s after a stop or a second signal, is cut and the session saved as it was), and a stopped worker still saves its kept turn, since the marker, not the saved status, says stopped. Any TERM, HUP or INT does the same to a worker outside a stop: it leaves (log `worker stop reason=signal`) and the next send wakes a new one, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's `llm ctx` chip, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s (the page's Stop shows the stop at once), or for the body's `wait` up to the same 10 s (`SessionManager::STOP_WAIT`): `chi --remote NAME sessions stop` sends it, so it waits as long as here. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
36
+ **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`. A running turn is canceled over the Bridge first, with reason `session_stopped` (the turn's line ends "(session stopped)", its note says `cancelled (session-stopped)`, and a command it was running says "stopped as the session was stopped"; a bare signal to the worker makes it "stopped by a signal to chi"; an older worker takes the reason as `manual`); the TERM stops whatever still runs as a Stop with reason `signal` and saves it (a spawned worker traps TERM, HUP and INT: `WorkerSignals`; a turn not ended within 3 s, 0.5 s after a stop or a second signal, is cut and the session saved as it was), and a stopped worker still saves its kept turn, since the marker, not the saved status, says stopped. Any TERM, HUP or INT does the same to a worker outside a stop: it leaves (log `worker stop reason=signal`) and the next send wakes a new one, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's `llm ctx` chip, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s (the page's Stop shows the stop at once), or for the body's `wait` up to the same 10 s (`SessionManager::STOP_WAIT`): `chi --remote NAME sessions stop` sends it, so it waits as long as here. Before that stop, the page's `stop session` button asks what the worker still has that the user can stop: `GET /api/sessions/:id/holds` (owner-only) answers the worker's `holds` — the same list the exit ask's 409 carries — always an array, empty when there is no live worker, one from before the route, or one that did not answer. With something, a sheet (a bottom sheet on a phone, a popover at the button otherwise — `stop_sheet.js`) lists them, at most three of a kind named and `+N more`, and offers **Stop all** (`POST /api/sessions/:id/holds/stop` → `HoldStop#stop_all`, then the stop), **Stop session only** (the stop exactly as it was) or **Cancel**. Nothing to ask about is the plain confirm, one tap as before. Both buttons stay because a stop only TERMs the worker: its reminders die with it, while a background task and a delegate are their own processes and keep running. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
37
37
 
38
38
  **Deleting a session:** `chi sessions delete ID...`, `/exit --delete` in a terminal and the Web UI's delete all go through `SessionManager.delete_session`: it resolves a unique prefix, removes `<id>.json` and the whole `<id>/` dir, and returns what it removed. A session a plain REPL has open (`owner.lock` kind `tui`) is always refused. One a worker runs is refused unless the caller asks to stop it: then it stops the worker as `chi sessions stop` does and deletes once `owner.lock` is free (`--force` on the CLI, 10 s; the web page always, 2 s; `/exit --delete` after the worker agreed to exit, 10 s). A worker that outlives the wait leaves the session in place. The web's route is `DELETE /api/sessions/:id?force=true` (the page sends `force`; without it a live worker's session is 409 `worker_running`, as the CLI without `--force`; 200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
39
39
 
@@ -94,7 +94,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
94
94
  - Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
95
95
  - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `#N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `stage_view.js` (the stage view: the running turn pinned above the composer, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`), `markdown_place.js` (where rendered markdown and its refs go: a text as markdown HTML, its `path:line` refs, a final answer's rendering), `images.js` (image chips in the composer: paste, drop and the image button, the upload before a turn, the thumbnails and the lightbox), `composer_grip.js` (the composer's grip and fold: the drag, tap and keys, the fold per visit), `strip_grip.js` (the latest-sessions strip's grip, the composer grip's twin: hides and shows the strip), `storage.js` (the page's per-browser choices in localStorage, guarded: a choice that can't be read or written is not remembered), `unqueue.js` (the × on a message queued for after the turn: takes it back, its text into an empty composer or a toast), `toasts.js` (the page's toast stack: a timer each, sticky ones until acted on, the same text reusing its item), `toast_place.js` (where the toast sits: above the composer card, the stage and an open model panel), `dock_reserve.js` (what floats over the history: the history's bottom reserve (`--dock-h`) at the composer card's and the showing stage's height, a reader at the end kept there, and the toast's place measured for `toast_place.js`), `notice_links.js` (a hook notice line's http(s) URLs as links), `start_page.js` (the start page's model picker and its LLM context and thinking chips, a choice for the next new session only; a fresh chain link's picker and its `/model`), `session_actions.js` (Continue, Archive and its Undo, Delete and Stop, and the start page when the open session is gone), `send_turn.js` (the composer's send: a new session first on the start page, the route, the echo, the image upload, the `/turn` ack, a fast failure's restore and each error's place). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
96
96
  - Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
97
- - The web session page's message box folds away for room to read. Drag the grip on its top edge (the pill that shows on hover; always on a phone) down, past the box by 32 px: the pill turns accent while releasing would hide it, and on release the box folds and the info bar stays as the page's bottom edge. The grip stays as a handle on the info bar's top edge, drawn always: pull it up (8 px opens it, and the box follows the pointer in the same gesture), click or tap it (opens it and focuses the box), or just type on the page (the key lands in the box) to bring it back; Enter or Space on the focused grip folds or opens too. A draft (text or images, which folding never clears) colours the handle with the accent, a session waiting on you with the attention colour; its tooltip says so. Stop turn moves into the info bar while it is folded. The fold is remembered per browser (`chi_composer_collapsed`), an archived session opens folded for the visit, and opening it there, like the page opening it itself (a restored line, a quote, continue →'s opener), is for that visit only. The fold keeps the height the grip set (`chi_composer_floor`): the box opens again at the height it had, and only a double-click on the grip forgets it. A click on the expanded grip hides the box as well: it folds 250 ms after the press, so a second click inside that window is read as a double-click and resets the height instead (a drag, Enter or Space act at once, and a drag after a click takes the wait back). The start page's grip only resizes.
97
+ - The web session page's message box folds away for room to read. Drag the grip on its top edge (the pill that shows on hover; always on a phone) down, past the box by 32 px: the pill turns accent while releasing would hide it, and on release the box folds and the info bar stays as the page's bottom edge. The grip stays as a handle on the info bar's top edge, drawn always: pull it up (8 px opens it, and the box follows the pointer in the same gesture), click or tap it (opens it and focuses the box), or just type on the page (the key lands in the box) to bring it back; Enter or Space on the focused grip folds or opens too. A draft (text or images, which folding never clears) colours the handle with the accent, a session waiting on you with the attention colour; its tooltip says so. Stop turn moves into the info bar while it is folded. The fold is remembered per browser (`chi_composer_collapsed`), an archived session opens folded for the visit, and opening it there, like the page opening it itself (a restored line, a quote, continue →'s opener), is for that visit only. The fold keeps the height the grip set (`chi_composer_floor`): the box opens again at the height it had, and only a double-click on the grip forgets it. That height never takes more than half of the window the box is drawn in: a floor saved in a bigger window is held to that half in a smaller one (a rotated phone, a resized window, another device on the same origin), and the saved floor itself stays, so the height comes back when the room does. A click on the expanded grip hides the box as well: it folds 250 ms after the press, so a second click inside that window is read as a double-click and resets the height instead (a drag, Enter or Space act at once, and a drag after a click takes the wait back). The start page's grip only resizes.
98
98
 
99
99
  **Test-session hygiene:**
100
100
 
@@ -87,21 +87,36 @@ module Samagotchi
87
87
  # one, POST /command answers 501
88
88
  # @param on_exit_request [#call, nil] a client asks the worker to exit
89
89
  # now: takes the client_id and delete: (the exit is to delete the
90
- # session, /exit --delete), returns nil when the worker will leave or
91
- # what keeps it up (a WorkerIdleExit::Hold: #reason, #tasks, #hint);
92
- # called with the event log held. Without one, POST /exit answers 501
90
+ # session, /exit --delete) or stop: ("all": stop everything the user
91
+ # can stop first), returns nil when the worker will leave or
92
+ # what keeps it up (a WorkerIdleExit::Hold: #reason, #tasks, #hint,
93
+ # #holds); called with the event log held. Without one, POST /exit
94
+ # answers 501
93
95
  # @param exit_discards [#call, nil] after an exit the worker agreed to:
94
96
  # whether it will delete the session as empty (the 200 says
95
97
  # +discard+); without one the reply leaves the field out
98
+ # @param exit_stopped [#call, nil] after a stop-all exit: what it stopped
99
+ # (a HoldStop::Stopped) for the 200's +stopped+; nil leaves it out
100
+ # @param on_holds [#call, nil] what the user could stop in this worker
101
+ # right now (WorkerIdleExit#stoppable_holds) for GET /holds; without
102
+ # one that route answers 501
103
+ # @param on_holds_stop [#call, nil] stop every one of them (HoldStop#stop_all)
104
+ # without the worker exiting, for POST /holds/stop; returns the
105
+ # HoldStop::Stopped the 200's +stopped+ carries; without one that route
106
+ # answers 501
96
107
  def initialize(engine:, state_dir:, session_id:, bind: DEFAULT_BIND,
97
108
  port: 0, ring_capacity: DEFAULT_RING_CAPACITY,
98
109
  heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL, input_format: nil, on_input: nil,
99
- on_command: nil, on_exit_request: nil, exit_discards: nil)
110
+ on_command: nil, on_exit_request: nil, exit_discards: nil, exit_stopped: nil,
111
+ on_holds: nil, on_holds_stop: nil)
100
112
  @engine = engine
101
113
  @on_input = on_input
102
114
  @on_command = on_command
103
115
  @on_exit_request = on_exit_request
104
116
  @exit_discards = exit_discards
117
+ @exit_stopped = exit_stopped
118
+ @on_holds = on_holds
119
+ @on_holds_stop = on_holds_stop
105
120
  @input_format = input_format
106
121
  @state_dir = state_dir
107
122
  @session_id = session_id
@@ -590,6 +605,8 @@ module Samagotchi
590
605
  %w[POST turn/dequeue] => :handle_dequeue_turn,
591
606
  %w[POST command] => :handle_command,
592
607
  %w[POST exit] => :handle_exit_request,
608
+ %w[GET holds] => :handle_holds,
609
+ %w[POST holds/stop] => :handle_holds_stop,
593
610
  %w[POST recap] => :handle_recap,
594
611
  %w[POST relay] => :handle_relay,
595
612
  %w[POST relay/status] => :handle_relay_status,
@@ -883,14 +900,21 @@ module Samagotchi
883
900
 
884
901
  # A client asks the worker to exit now (`/exit` in the attached TUI), or
885
902
  # with "restart": true to hand the session to a new worker on the newest
886
- # chi installed (the "restart" feature). The worker decides with the
887
- # event log held, so no POST /turn lands between its check and its
888
- # answer; it leaves from its loop after this reply.
889
- # 200 {status: "exiting", discard?: the session is empty and goes} ({status: "restarting"} for a restart), or 409
890
- # {status: "held", reason:} naming what keeps it up (with tasks: [{id:,
891
- # command:}] and a hint: how to stop them, for background_tasks). A past +deadline+
892
- # (see #handle_post_turn), checked with the event log held: 408
893
- # deadline_passed, the worker not asked (its client said it didn't stop).
903
+ # chi installed (the "restart" feature). "stop": "all" first stops every
904
+ # hold the user can stop; "ask": false leaves the delegate children out of
905
+ # the list (a UI that can't ask about them, as /archive). The worker
906
+ # decides with the event log held, so no POST /turn lands between its
907
+ # check and its answer; it leaves from its loop after this reply.
908
+ # 200 {status: "exiting", discard?: the session is empty and goes,
909
+ # stopped?: what a "stop": "all" exit stopped} ({status: "restarting"} for
910
+ # a restart), or 409 {status: "held", reason:} naming what keeps it up
911
+ # (with tasks: [{id:, command:, age_seconds:}] and a hint: how to stop
912
+ # them, for background_tasks). When every hold is one the user can stop,
913
+ # the 409 also carries holds: [{kind:, items: [{id:, label:, detail:}]}
914
+ # — reminders, running tasks, delegate children — for the terminal's ask.
915
+ # A past +deadline+ (see #handle_post_turn), checked with the event log
916
+ # held: 408 deadline_passed, the worker not asked (its client said it
917
+ # didn't stop).
894
918
  # Returns [headers, status, body].
895
919
  def handle_exit_request(session_id, body)
896
920
  return [{}, 501, { error: "exit_unavailable" }] unless @on_exit_request
@@ -901,7 +925,13 @@ module Samagotchi
901
925
  client_id = fetched(parsed, "client_id")
902
926
  delete = fetched(parsed, "delete") == true
903
927
  restart = fetched(parsed, "restart") == true
928
+ stop_all = fetched(parsed, "stop") == "all"
929
+ # A client that can ask about what still runs (an attached terminal's
930
+ # plain /exit) gets the delegates in the hold list; one that can't
931
+ # (/archive, and every worker's own last look) does not.
904
932
  options = restart ? { delete: delete, restart: true } : { delete: delete }
933
+ options[:stop] = true if stop_all && !restart
934
+ options[:ask] = false if fetched(parsed, "ask") == false
905
935
  deadline = fetched(parsed, "deadline")
906
936
  return BAD_DEADLINE unless deadline_valid?(deadline)
907
937
 
@@ -918,13 +948,57 @@ module Samagotchi
918
948
 
919
949
  body = { status: "exiting", session_id: @session_id }
920
950
  body[:discard] = @exit_discards.call == true if @exit_discards
951
+ stopped = @exit_stopped&.call
952
+ body[:stopped] = stopped.to_h if stopped
921
953
  return [{}, 200, body]
922
954
  end
923
955
 
924
956
  # A hold may name what it waits for (tasks: the background tasks a
925
- # finish wake watches).
957
+ # finish wake watches). When every hold is one the user can stop, +holds+
958
+ # lists them for the terminal's ask: [{kind:, items: [{id:, label:,
959
+ # detail:}]} (docs/cli.md, /exit).
926
960
  [{}, 409, { status: "held", reason: hold.reason.to_s, tasks: hold.tasks, hint: hold.hint,
927
- session_id: @session_id }.compact]
961
+ holds: holds_json(hold.holds), session_id: @session_id }.compact]
962
+ end
963
+
964
+ # The exit ask's stoppable holds as JSON; nil when there are none, so an
965
+ # older client sees today's plain 409.
966
+ def holds_json(holds)
967
+ return nil if holds.nil? || holds.empty?
968
+
969
+ holds.map { |hold| { kind: hold.kind.to_s, items: hold.items.map { |item| item.to_h } } }
970
+ end
971
+
972
+ # GET /session/:id/holds: what the user could stop in this worker right
973
+ # now — the same holds the exit ask's 409 carries (reminders, the watched
974
+ # background tasks still running, the delegate children with a live
975
+ # worker), whatever else keeps the worker up: chi web's Stop asks about
976
+ # these before it TERMs the worker, and a Stop cancels a running turn
977
+ # anyway, so a turn is not a reason to hide them.
978
+ # 200 {session_id, holds: [{kind:, items: [...]}]} (no +holds+ when there
979
+ # are none); 501 holds_unavailable when this worker has no such step.
980
+ # Returns [headers, status, body].
981
+ def handle_holds(_session_id, _body = nil)
982
+ return [{}, 501, { error: "holds_unavailable" }] unless @on_holds
983
+
984
+ [{}, 200, { session_id: @session_id, holds: holds_json(@on_holds.call) }.compact]
985
+ end
986
+
987
+ # POST /session/:id/holds/stop: stop everything the user can stop (HoldStop#stop_all)
988
+ # without the worker leaving — the reminders cleared, the watched tasks
989
+ # killed as task_stop does, the delegate children stopped as
990
+ # `chi sessions stop` does. What the user can't stop (a turn running,
991
+ # another UI attached) is untouched. The web's Stop's "Stop all" runs
992
+ # this, then stops the worker.
993
+ # 200 {status: "stopped", session_id, stopped: {reminders:, tasks:,
994
+ # delegates:}}; 501 holds_unavailable when this worker has no such step.
995
+ # Returns [headers, status, body].
996
+ def handle_holds_stop(_session_id, _body = nil)
997
+ return [{}, 501, { error: "holds_unavailable" }] unless @on_holds_stop
998
+
999
+ Log.info(:bridge, "holds_stop")
1000
+ stopped = @on_holds_stop.call
1001
+ [{}, 200, { status: "stopped", session_id: @session_id, stopped: stopped.to_h }]
928
1002
  end
929
1003
 
930
1004
  # Queue a turn for this session through its file IPC (the worker polls
@@ -206,10 +206,12 @@ module Samagotchi
206
206
  # error "not_found" = a worker older than the route, 408 = too late.
207
207
  # @param client_id [String] the asking UI, whose own streams don't hold
208
208
  # @return [Response]
209
- def request_exit(client_id:, delete: false, restart: false)
209
+ def request_exit(client_id:, delete: false, restart: false, stop: false, ask: true)
210
210
  body = { client_id: client_id }
211
211
  body[:delete] = true if delete
212
212
  body[:restart] = true if restart
213
+ body[:stop] = "all" if stop
214
+ body[:ask] = false unless ask
213
215
  body[:deadline] = deadline
214
216
  post("exit", body, read_body: true)
215
217
  end
@@ -236,6 +238,16 @@ module Samagotchi
236
238
  post("tasks/stop", { task_id: task_id }, read_body: true)
237
239
  end
238
240
 
241
+ # POST /session/:id/holds/stop: stop everything this worker's user can
242
+ # stop (its reminders, the background tasks a finish wake watches that
243
+ # still run, its delegate children) without the worker exiting
244
+ # (Bridge#handle_holds_stop). 200 {status: "stopped", stopped:
245
+ # {reminders:, tasks:, delegates:}}; 501 for a worker without the route.
246
+ # @return [Response]
247
+ def stop_holds
248
+ post("holds/stop", {}, read_body: true)
249
+ end
250
+
239
251
  # How long the approval relay's calls wait for a reply: a parent's Stop
240
252
  # isn't held for READ_TIMEOUT by a child that doesn't answer.
241
253
  RELAY_TIMEOUT = 5
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.116.0
3
+ version: 0.118.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:
@@ -370,6 +370,8 @@ module Samagotchi
370
370
  # What a :bool value reads as (Config.coerce!), YAML 1.1's words and 1/0.
371
371
  BOOL_WORDS = { "1" => true, "true" => true, "yes" => true, "on" => true,
372
372
  "0" => false, "false" => false, "no" => false, "off" => false }.freeze
373
+ # The word an :enum takes for a boolean (an unquoted on/off is one in YAML 1.1).
374
+ BOOL_ENUM_WORDS = { true => "on", false => "off" }.freeze
373
375
 
374
376
  # Fast lookup maps
375
377
  BY_KEY = ENTRIES.each_with_object({}) { |e, h| h[e.key] = e }.freeze
@@ -397,8 +399,10 @@ module Samagotchi
397
399
  # +raw+ as +entry+'s type, strictly: a value of another shape raises.
398
400
  # :string as given (a YAML list joined with |), :integer, :float,
399
401
  # :bool (true/yes/on/1, false/no/off/0/""), :enum in its canonical
400
- # case; nil for nil, and for a blank value of any other type than
401
- # :string and :bool. #coerce is its lenient twin (config.yml, env).
402
+ # case (an unquoted on/off, which YAML 1.1 reads as a boolean, is the
403
+ # word when the entry has it); nil for nil, and for a blank value of any
404
+ # other type than :string and :bool. #coerce is its lenient twin
405
+ # (config.yml, env).
402
406
  # @raise [SessionConfig::Error] "KEY takes WHAT, not RAW"
403
407
  def coerce!(entry, raw)
404
408
  return nil if raw.nil?
@@ -414,7 +418,7 @@ module Samagotchi
414
418
  when :integer then Integer(str, exception: false)
415
419
  when :float then Float(str, exception: false)
416
420
  when :bool then BOOL_WORDS[str.downcase]
417
- when :enum then entry.enum_values.find { |v| v.downcase == str.downcase }
421
+ when :enum then enum_word(entry, raw, str)
418
422
  else str
419
423
  end
420
424
  raise SessionConfig::Error, "#{entry.key} takes #{takes(entry)}, not #{raw.to_s.inspect}" if value.nil?
@@ -422,6 +426,18 @@ module Samagotchi
422
426
  value
423
427
  end
424
428
 
429
+ # A value of +entry+'s enum, as written. YAML 1.1 reads an unquoted
430
+ # on/off/yes/no as a boolean, so `mode: on` reaches the enum branch as
431
+ # true: it is the entry's "on"/"off" when the entry has that word, and
432
+ # still invalid when it doesn't.
433
+ def enum_word(entry, raw, str)
434
+ found = entry.enum_values.find { |v| v.downcase == str.downcase }
435
+ return found if found
436
+
437
+ word = BOOL_ENUM_WORDS[raw]
438
+ word && entry.enum_values.include?(word) ? word : nil
439
+ end
440
+
425
441
  # What +entry+'s type takes, as a refusal names it.
426
442
  def takes(entry)
427
443
  case entry.type
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "log"
4
+ require_relative "session_manager"
5
+ require_relative "tools/task_runtime"
6
+
7
+ module Samagotchi
8
+ # Stops what a session's user can stop themselves when they ask its worker
9
+ # to exit: the reminders its Engine holds, the background tasks it watches
10
+ # for a finish wake that still run, and its delegate children with a live
11
+ # worker (docs/sessions.md, "Worker idle exit"). Called by the exit ask's
12
+ # "stop them and exit" (Worker#exit_request with stop: true), and the same
13
+ # step the web's Stop will use.
14
+ #
15
+ # What the user can't stop from here (a turn running, prompts queued, a
16
+ # continue offer, another UI attached) is never touched: WorkerIdleExit
17
+ # refuses the exit for those before this is called.
18
+ class HoldStop
19
+ # What was stopped, by kind: reminder names, task commands, children's
20
+ # short ids. The 200's +stopped+ is this as JSON.
21
+ Stopped = Data.define(:reminders, :tasks, :delegates) do
22
+ def initialize(reminders: [], tasks: [], delegates: []) = super
23
+
24
+ def any? = reminders.any? || tasks.any? || delegates.any?
25
+ end
26
+
27
+ # @param engine [Engine] #reminder_store
28
+ # @param task_watch [TaskWatch, nil] this session's finish-wake watch
29
+ # (nil with session.task_wakes: off)
30
+ # @param delegates [#call] the children to stop (ChildrenStatus::Child)
31
+ # @param state_dir [String] where the child sessions live
32
+ def initialize(engine:, task_watch:, delegates:, state_dir:)
33
+ @engine = engine
34
+ @task_watch = task_watch
35
+ @delegates = delegates
36
+ @state_dir = state_dir
37
+ end
38
+
39
+ # Stop every hold the user can stop, read again as it is done (one may
40
+ # have ended in the meantime). A stop that fails is logged and left out
41
+ # of the report.
42
+ # @return [Stopped]
43
+ def stop_all
44
+ Stopped.new(reminders: stop_reminders, tasks: stop_tasks, delegates: stop_delegates)
45
+ end
46
+
47
+ private
48
+
49
+ # Reminders live only in this process, so clearing the store ends them;
50
+ # the names go to the report before it is emptied.
51
+ def stop_reminders
52
+ store = @engine.reminder_store
53
+ return [] unless store
54
+
55
+ names = store.reminders.values.map { |reminder| reminder[:name] }
56
+ store.clear_all
57
+ names
58
+ end
59
+
60
+ # The watched tasks still running, stopped the way task_stop and the
61
+ # web's stop-task button do (TaskRuntime.stop_task, by: "user").
62
+ def stop_tasks
63
+ return [] unless @task_watch
64
+
65
+ @task_watch.running.filter_map do |task|
66
+ record, error = Tools::TaskRuntime.stop_task(task.id, by: "user")
67
+ if record.nil?
68
+ Log.warn(:worker, "exit_stop_task_failed", task_id: task.id, error: error.to_s)
69
+ next nil
70
+ end
71
+
72
+ task.command
73
+ end
74
+ end
75
+
76
+ # The delegate children, stopped as `chi sessions stop` stops a session
77
+ # (SessionManager.stop_session: its turn canceled, its worker TERMed).
78
+ # One owned by another terminal is left running and said so in the log.
79
+ def stop_delegates
80
+ @delegates.call.filter_map do |child|
81
+ SessionManager.stop_session(child.id, state_dir: @state_dir)
82
+ child.short_id
83
+ rescue StandardError => e
84
+ Log.warn(:worker, "exit_stop_delegate_failed", child: child.short_id, error: e.class.name, msg: e.message)
85
+ nil
86
+ end
87
+ end
88
+ end
89
+ end
@@ -29,6 +29,10 @@ module Samagotchi
29
29
  # restores the state. When /slots can't say, the warm-up runs.
30
30
  class PromptWarmup
31
31
  MODES = %w[auto off].freeze
32
+ # The words cache.warmup takes, in the shape YAML hands them: an unquoted
33
+ # on/off arrives as true/false.
34
+ AUTO_WORDS = %w[auto on true 1].freeze
35
+ OFF_WORDS = %w[off false no 0].freeze
32
36
  # Seconds #take_pin waits for the busy-slot check (Client#slot_status
33
37
  # gives up within 3 s).
34
38
  DECIDE_TIMEOUT = 3
@@ -36,12 +40,13 @@ module Samagotchi
36
40
  STOP_JOIN_SECONDS = 1
37
41
 
38
42
  # Whether cache.warmup allows a warm-up (auto, the default; off, false,
39
- # no or 0 turn it off; anything else warns once and is auto).
43
+ # no or 0 turn it off; on/true/1 (YAML's unquoted on) are auto; anything
44
+ # else warns once and is auto).
40
45
  def self.enabled?
41
46
  value = Config.get("cache.warmup").to_s.strip.downcase
42
- return false if %w[off false no 0].include?(value)
47
+ return false if OFF_WORDS.include?(value)
43
48
 
44
- unless value.empty? || value == "auto" || @warned
49
+ unless value.empty? || AUTO_WORDS.include?(value) || @warned
45
50
  @warned = true
46
51
  Log.warn(:config, "invalid_value", echo: "Warning: invalid value for cache.warmup: #{value.inspect} (allowed: auto, off) — using auto",
47
52
  key: "cache.warmup")
@@ -139,12 +139,17 @@ module Samagotchi
139
139
  end
140
140
 
141
141
  # POST …/exit: the worker asked to exit (chi web passes client_id on
142
- # only when it is an attached TUI's).
143
- # @return [BridgeClient::Response] 200 {status: exiting, discard}, 409
144
- # held {reason, tasks}, 408 late, 404 an older worker's
145
- def request_exit(client_id:, delete: false)
142
+ # only when it is an attached TUI's), stop: true first stopping what
143
+ # the user can stop, ask: false leaving the delegate children out of
144
+ # the list (a UI that can't ask about them).
145
+ # @return [BridgeClient::Response] 200 {status: exiting, discard,
146
+ # stopped}, 409 held {reason, tasks, holds}, 408 late, 404 an older
147
+ # worker's
148
+ def request_exit(client_id:, delete: false, stop: false, ask: true)
146
149
  body = { client_id: client_id }
147
150
  body[:delete] = true if delete
151
+ body[:stop] = "all" if stop
152
+ body[:ask] = false unless ask
148
153
  post(:exit, "exit", body)
149
154
  end
150
155