samagotchi 0.124.0 → 0.125.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/docs/architecture.md +4 -2
  4. data/docs/cli.md +20 -8
  5. data/docs/internals/participants.md +26 -1
  6. data/docs/internals/remote-sessions.md +2 -0
  7. data/docs/sessions.md +2 -2
  8. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  9. data/lib/samagotchi/process_group.rb +10 -1
  10. data/lib/samagotchi/reminder_carry.rb +92 -0
  11. data/lib/samagotchi/reminder_store.rb +49 -0
  12. data/lib/samagotchi/session_manager.rb +28 -4
  13. data/lib/samagotchi/sessions_command.rb +1 -1
  14. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +225 -0
  15. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +138 -0
  16. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +225 -0
  17. data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +154 -0
  18. data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +302 -0
  19. data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -1253
  20. data/lib/samagotchi/terminal_ui/exit_lines.rb +141 -0
  21. data/lib/samagotchi/terminal_ui/join_replay.rb +235 -0
  22. data/lib/samagotchi/version.rb +1 -1
  23. data/lib/samagotchi/web/app.rb +7 -3
  24. data/lib/samagotchi/web/participants_admin.rb +15 -4
  25. data/lib/samagotchi/web/public/annotate_bar.css +8 -4
  26. data/lib/samagotchi/web/public/annotate_bar.js +34 -6
  27. data/lib/samagotchi/web/public/app.js +66 -5
  28. data/lib/samagotchi/web/public/claim_link.js +347 -0
  29. data/lib/samagotchi/web/public/participant.html +1 -0
  30. data/lib/samagotchi/web/public/participant.js +46 -4
  31. data/lib/samagotchi/web/public/participants_panel.js +15 -0
  32. data/lib/samagotchi/web/public/route.js +3 -2
  33. data/lib/samagotchi/web/public/tokens.css +2 -1
  34. data/lib/samagotchi/web/public/verify_preset.js +18 -0
  35. data/lib/samagotchi/worker.rb +57 -3
  36. data/lib/samagotchi/worker_idle_exit.rb +7 -6
  37. metadata +11 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e85ff88270cf7569d21ff95bba739199ceb3c53d32675b1648a18363ca5482cd
4
- data.tar.gz: d9f539982d7c41b94ad40d17368041f2debed8040ebc48a27776486fe2476dc9
3
+ metadata.gz: 8d36716e229ef5ccac918c840a3b55d878257811aeffdf594d2588c3bf067533
4
+ data.tar.gz: fb632ff225b0a9f5dfbbc869ce8df2842df811b6858caa3d714362879a3e4287
5
5
  SHA512:
6
- metadata.gz: e3496206b25f1d329a3a915c075cefe5e0c3e1fc40b71694128c7d3fee633fc65447686efbb4c8f4f19e0872998cecb2f269db67a7428b9b519a9cc5b6469bd3
7
- data.tar.gz: 3ed82d4bb9b01288f391e2d68d9eba5b71492fa66f411d7a5fd25276ac68425f8432979fd0d4d59882b741a2dfa79bcb0810fc74e3b2c7e66bb590e0c5bdbccc
6
+ metadata.gz: d16837760eae98a5f26bef1349f70d6fecf80b2400858d2fab12d0a39b11cb5ed125901b21e99bede021585cd9ad60c44adb6a3dad0416a7c4302ebaed459950
7
+ data.tar.gz: 8210416816fda4da83f18801d6e2b991335c41054cfd5459361c9b478d5c633a02ac58855d9f77520d05269c2f64e004cd600c7e56b36ac12d71efe612965430
data/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@ 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.125.0] - 2026-10-27
11
+
12
+ ### Added
13
+
14
+ - chi web links to a claim: selecting text in a turn shows **Copy link**, a link for a participant's browser that
15
+ opens their page on that turn, scrolled to the quote and highlighted (no token: it opens where their review link
16
+ was opened, one link per browser); with no one invited, a link on your own page, which takes the same anchor.
17
+ - A built-in **Verify** annotate preset on both pages, after `web.annotate_presets`: it quotes the selection and asks
18
+ to verify the claim against the code, ending on `Verdict: confirmed|refuted|unclear — <reason>`.
19
+
20
+ ### Changed
21
+
22
+ - `chi sessions restart` (and chi web's restart, its update's worker restarts) keeps a session's reminders: the
23
+ leaving worker hands them to the new one, with their ids and schedules (one that came due meanwhile fires once),
24
+ instead of refusing the restart. A stop, an idle exit, a crash and the TERM fallback for a worker that can't be
25
+ asked still drop them; a worker from an older chi still refuses.
26
+
27
+ ### Fixed
28
+
29
+ - `chi sessions stop` (and the web's stop, delete and archive, which stop the same way) stops a stopped (SIGSTOPped,
30
+ Ctrl-Z) worker at once, instead of waiting out the stop's 10 s and leaving it running; a command or background task
31
+ chi stops while it is stopped ends with its TERM, not the KILL after the grace.
32
+
33
+ Update from chi web's Update button (or `chi update`, then restart `chi web` and the session workers with
34
+ `chi sessions restart`). Workers on 0.124.0 still refuse a restart while they hold reminders: stop and resume those
35
+ once; from 0.125.0 on a restart keeps them.
36
+
10
37
  ## [0.124.0] - 2026-10-27
11
38
 
12
39
  ### Added
@@ -4713,7 +4740,8 @@ and long-lived sessions.
4713
4740
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
4714
4741
  hotkey panel that send selected text or the clipboard to your sessions.
4715
4742
 
4716
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.124.0...HEAD
4743
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.125.0...HEAD
4744
+ [0.125.0]: https://github.com/dm1try/samagotchi/compare/v0.124.0...v0.125.0
4717
4745
  [0.124.0]: https://github.com/dm1try/samagotchi/compare/v0.123.0...v0.124.0
4718
4746
  [0.123.0]: https://github.com/dm1try/samagotchi/compare/v0.122.0...v0.123.0
4719
4747
  [0.122.0]: https://github.com/dm1try/samagotchi/compare/v0.121.1...v0.122.0
data/docs/architecture.md CHANGED
@@ -75,7 +75,7 @@ TerminalUI ── delegates ──▶ Engine
75
75
  higher-level turn events, so any new UI can render without terminal coupling.
76
76
  - **`ComposerRoute`** (`lib/samagotchi/composer_route.rb`) decides where a line typed at
77
77
  either terminal's prompt goes (the REPL's `steer_line` / `run_input_line`, the attached
78
- `AttachedLoop#submit`): `/cut TEXT` / `/queue TEXT`, a terminal's own word, a session
78
+ `AttachedLoop::Composer#submit`): `/cut TEXT` / `/queue TEXT`, a terminal's own word, a session
79
79
  command, a typo, a prompt. Each UI keeps what it does with the route. The web's
80
80
  `composer.js#routeComposerLine` decides the same; `spec/fixtures/composer_routes.json`
81
81
  holds the cases both answer (see [testing.md](testing.md)).
@@ -171,7 +171,9 @@ agent logic and can be used without any terminal rendering; the UI is a thin lay
171
171
  activity slot gets a second, dim row: the turn's tool tally (`TurnTally`, seeded from the
172
172
  snapshot's tool parts on a mid-turn join). Reline still reads the input,
173
173
  but `RelineSeam` (prepended to `Reline::LineEditor`) sends its drawing to the
174
- `Screen`. Without a capable terminal it falls back to `PlainSurface` (append-only).
174
+ `Screen`. The saved history and cards in a join or resync are rendered by
175
+ `TerminalUI::JoinReplay` with the loop's screen, view, link, gap clock and renderer.
176
+ Without a capable terminal it falls back to `PlainSurface` (append-only).
175
177
 
176
178
  #### Using the core
177
179
 
data/docs/cli.md CHANGED
@@ -541,14 +541,15 @@ terminal and the Web UI say so, with that restart line.
541
541
  `chi sessions restart ID...` hands each session to a new worker on the newest
542
542
  chi installed, without stopping it: an attached terminal and the web's tabs
543
543
  move to the new worker. It is refused, with the reason, while something would
544
- be lost: a turn running or queued, a question or approval waiting, reminders,
545
- a `/btw` still running, an approval relayed from a delegate, or background
546
- tasks the session started. If the worker cannot be asked (a refused Bridge,
547
- protocol mismatch or stuck start), chi sends it TERM and resumes only when
548
- disk shows no running turn, pending question or approval, queued prompt, or
549
- running background task; otherwise it says to run `chi sessions stop ID`, then
550
- send any prompt. That fallback may lose reminders, a running `/btw`, and other
551
- in-memory state. A session with no running worker needs no restart (its next
544
+ be lost: a turn running or queued, a question or approval waiting, a `/btw`
545
+ still running, an approval relayed from a delegate, or background tasks the
546
+ session started. Its reminders move to the new worker with their schedules
547
+ (a worker from an older chi still refuses for them). If the worker cannot be
548
+ asked (a refused Bridge, protocol mismatch or stuck start), chi sends it TERM
549
+ and resumes only when disk shows no running turn, pending question or
550
+ approval, queued prompt, or running background task; otherwise it says to run
551
+ `chi sessions stop ID`, then send any prompt. That fallback drops reminders,
552
+ and may lose a running `/btw` and other in-memory state. A session with no running worker needs no restart (its next
552
553
  prompt starts one on the newest chi), and an older worker without restart
553
554
  support needs `chi sessions stop ID`. The Web UI's
554
555
  `POST /api/sessions/:id/restart` does the same (409 with `reason` and `detail`
@@ -1207,6 +1208,17 @@ their comment box, labelled `From docs/plan.md:` or `From Sam's comment:`,
1207
1208
  with the same presets. Not in the owner's participant view, and no plugin
1208
1209
  pills there (a review turn refuses `/` lines).
1209
1210
 
1211
+ After the presets, both pages have a built-in **Verify**: the quote, then
1212
+ an ask to check the claim against the code and end on a line `Verdict:
1213
+ confirmed|refuted|unclear — <reason>`. On your page a selection in a turn
1214
+ also offers **Copy link**: a link to that turn and quote for a participant's
1215
+ browser (the one their review link was opened in), which opens their page
1216
+ scrolled to the quote and highlights it; with no one invited, a link to it
1217
+ on your own page. A secret in the selection goes into the link as
1218
+ `[redacted]`, as the participant's page shows it. One browser holds one participant link at a time, and
1219
+ opening a participant's invite link yourself swaps your own sign-in for it
1220
+ (see [participants.md](internals/participants.md#what-a-participant-sees)).
1221
+
1210
1222
  The list is `web.annotate_presets`, `|`-separated (at most five; a preset
1211
1223
  can't contain `|`; a YAML list works too):
1212
1224
 
@@ -389,6 +389,31 @@ fields: a field added to an event later reaches nobody until it is named there.
389
389
  header, the Plan's truncation note. No plugin pills (a review turn refuses `/` lines), and none of it in the owner's
390
390
  participant view (no presets in its shell) or while `can_comment` is false. On a touch screen the bar docks above the
391
391
  composer. A re-read keeps unchanged thread nodes and the Plan's (redrawn only on change), so a selection survives it.
392
+ After the presets comes the built-in **Verify** (`verify_preset.js`, on the owner's page too): the quote, then
393
+ "Verify this claim against the code. If this session's context doesn't settle it, say so. End with a line
394
+ `Verdict: confirmed|refuted|unclear — <one-line reason>`." It fills the box like any preset and never sends.
395
+ - **Deep links to a claim** (`claim_link.js`). The owner selects text in a turn's answer, step or own message and
396
+ Annotate's **Copy link** copies `<base>/#s=<id>&t=<turn8>&q=<quote>&p=<prefix>`: `base` the participants list's
397
+ `link_base` (`ParticipantsAdmin#participant_base`: `web.public_url`'s origin, else the LAN address, else the Host),
398
+ `t` the turn's id (its first 8 characters, the opening message's `data-turn-id`), `q` the selection's rendered
399
+ text (whitespace collapsed, at most 200 characters) and `p` up to 32 characters before it in the same bubble, both
400
+ URL-encoded by `URLSearchParams`. The text is read as the participant page shows it, by `Redact`'s own rules
401
+ mirrored in JS (`spec/fixtures/redact_cases.json` holds cases both sides must read the same): secret shapes
402
+ `[redacted]`, whole even when the selection cuts into one, so a link never carries a secret; on the owner's page
403
+ also the session's folder relative and the home folder `~` (the page's `data-home`). Not the owner's token (the
404
+ page doesn't hold it). With no person (`kind: human`) active in the session, Copy link gives the owner's own form,
405
+ `#/s/<id>?t=…&q=…&p=…`; the owner's page also opens the participant form. Opening one: the turn's bubbles
406
+ (`.p-msg > .p-bubble`; the owner's answers, own messages and steps) are searched for the prefix and quote, then
407
+ the quote alone; the match's bubble is ringed (`.claim-hit`), a closed `<details>` around it opened, and the quote
408
+ highlighted (the CSS Highlight API, `::highlight(chi-claim)`, where the browser has it). A quote not found (text
409
+ that reads differently) rings the turn's opening. A turn no opening carries (a non-person turn the page doesn't
410
+ stamp) is searched for in the whole page; neither found (one from before a continue) leaves the page at the top and
411
+ says so. The hash then drops the claim (`#s=<id>`, `#/s/<id>`), so the same link opened again shows it again. The
412
+ link holds no token, so it opens only in a browser that already redeemed a grant for that session; a link for
413
+ another session says this browser holds the link for this one. Its limits: **one grant per browser** (the grant
414
+ cookie is one name: a second participant link redeemed there replaces the first), and **the owner opening a
415
+ participant's invite link** in their own browser swaps their owner cookie for that grant (the page asks first). A
416
+ continue archives the session and revokes its grants: re-share, and links to the old session stay on it.
392
417
  - **The comment box** is the owner's composer (`composer_mount.js`, styled by the shared `composer.css`), mounted by
393
418
  `participant.js` with a subset on. It has: the height that grows with the text, the grip (drag to resize,
394
419
  double-click resets, click or drag down folds it; folded, the grip is the handle on the dock's edge and typing on the
@@ -468,7 +493,7 @@ gate and routes) answers these.
468
493
 
469
494
  | Route | Body / params | Answer | Refusals |
470
495
  |---|---|---|---|
471
- | `GET /api/sessions/:id/participants` | | 200 `{participants: [grant], warning, identity_header}`, every state; `warning`: the share warning for a session that isn't a review session (`ReviewStart.share_warning`, null for one), shown before the panel's Invite with Start a review of it; `identity_header`: the header this chi web's proxy names the user in (null without one), the Invite's email field | |
496
+ | `GET /api/sessions/:id/participants` | | 200 `{participants: [grant], warning, identity_header, link_base}`, every state; `warning`: the share warning for a session that isn't a review session (`ReviewStart.share_warning`, null for one), shown before the panel's Invite with Start a review of it; `identity_header`: the header this chi web's proxy names the user in (null without one), the Invite's email field; `link_base`: where a participant's link opens, no token (a claim link's base) | |
472
497
  | `GET /api/participants` | | 200 `{participants: [grant]}`, every session's | |
473
498
  | `POST /api/sessions/:id/participants` | `name`, `kind` (`human`/`agent`), `ttl` (seconds), `cap`, `identity` (an email: a bound grant) | 201 `{participant: grant, token, link, lan, qr_svg, warning}`: the token and the link once; `warning` for a session that isn't a review session (`ReviewStart.share_warning`, null for one); `link` names this chi web's LAN address (`http://IP:PORT/?token=…#s=<id>`), `lan` false when it has none | 400 `invalid_participant` (name, kind, ttl, an identity that isn't one address or with no proxy to check it, an archived session); 404 |
474
499
  | `GET /api/sessions/:id/apply[?turn=T]` | `turn`: a turn id or a prefix (none: the latest review turn with a reply) | 200 `{text, turn_id, author, author_name}`: the Apply turn for the composer, never sent | 404 `not_found` (no such turn, no review turn), 400 `not_review_turn` (an owner's turn), 409 `no_reply` |
@@ -268,6 +268,8 @@ with a "Machine-local" comment; the rest branch on `@access.remote?` (`SendComma
268
268
  `spec/terminal_ui/remote_connector_spec.rb`, `spec/remote_attach_command_spec.rb`,
269
269
  `spec/session_access/remote_attach_spec.rb` (wake, worker, feature check through `RackTransport`), and bin/chi's
270
270
  branch in `spec/session_access_dispatch_spec.rb`.
271
+ The saved-history rendering is the same `TerminalUI::JoinReplay` as local attaches; its saved-record reads use
272
+ the injected link.
271
273
  - By hand: the smoke-run skill (`.claude/skills/smoke-run`, kept in the checkout, not in git) covers chi web; its
272
274
  cli-tui recipe has remote attach on one machine (two isolated envs, `chi remote add self http://<LAN IP>:PORT`, the
273
275
  terminal's commands through chi web). The real lane is laptop → Studio over the LAN: `chi remote add`, `send --new --wait`,
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). 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.
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, 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`). Reminders go along: as it leaves, the worker writes them to `<id>/reminders.json` (0600: `session_id`, `written_at`, and each reminder's id, name, description, interval and next fire as wall-clock time; `ReminderCarry`), and its successor reads and deletes the file as it starts, before its idle scheduler runs. A restored reminder keeps its id and schedule; one that came due meanwhile fires once at once, and one further ahead than its interval (a clock that jumped) is due one interval from now. The file counts only for the same session within 2 minutes of `written_at`, and not once the session is stopped: otherwise it is deleted unread (log `reminders_carry_dropped`, at warn unless the session was stopped). A stop that lands while the worker leaves for a restart wins: the worker writes no file, and no successor starts (`SessionManager.run_session_loop`, log `restart_dropped`); an archive deletes the file of each session it archives. Once an exit or restart is taken, a later exit request (a stop-all from another terminal) is answered as that same exit: the first one decides how the worker leaves. A write that fails keeps the worker up with its reminders (log `reminders_carry_failed`), and the caller reports no new worker. A reminder already due when the restart is asked runs its turn first, so the restart's wait (10 s) may report no new worker yet though the hand-off follows. A worker from before this still refuses a restart for its reminders (409 `reminders`). Only an asked restart carries them: the fallback's TERM (a worker that can't be asked, below), a stop, an idle exit (which reminders hold anyway) and a crash drop them as before. 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. 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.
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, then CONT (a stopped worker, SIGSTOP or Ctrl-Z, runs its TERM handler only once continued, so it leaves at once instead of outliving the wait), and waits (up to 10 s) until the worker has let go of `owner.lock`. A running turn is canceled over the Bridge first, with reason `session_stopped` (the turn's line ends "(session stopped)", its note says `cancelled (session-stopped)`, and a command it was running says "stopped as the session was stopped"; a bare signal to the worker makes it "stopped by a signal to chi"; an older worker takes the reason as `manual`); the TERM stops whatever still runs as a Stop with reason `signal` and saves it (a spawned worker traps TERM, HUP and INT: `WorkerSignals`; a turn not ended within 3 s, 0.5 s after a stop or a second signal, is cut and the session saved as it was), and a stopped worker still saves its kept turn, since the marker, not the saved status, says stopped. Any TERM, HUP or INT does the same to a worker outside a stop: it leaves (log `worker stop reason=signal`) and the next send wakes a new one, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's `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 (a stop also deletes a restart's `reminders.json` no successor took yet, so a later resume doesn't bring them back), while a background task and a delegate are their own processes and keep running. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
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
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.124.0
3
+ version: 0.125.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:
@@ -119,7 +119,7 @@ module Samagotchi
119
119
  # How long #stop waits for +stopped+ after the KILL by default.
120
120
  KILL_WAIT_SEC = 1.0
121
121
 
122
- # TERMs the group, waits up to +grace+ for +stopped+, then KILLs it and
122
+ # TERMs (and CONTs) the group, waits up to +grace+ for +stopped+, then KILLs it and
123
123
  # waits up to +kill_wait+ for +stopped+ again (a KILL is delivered, not
124
124
  # done, when kill returns).
125
125
  #
@@ -146,6 +146,15 @@ module Samagotchi
146
146
  extra = walk ? descendant_groups(pgid) : []
147
147
  signal(pgid, "TERM", **signal_options)
148
148
  extra.each { |group| signal(group, "TERM") }
149
+ # A stopped process (SIGSTOP, Ctrl-Z) holds the TERM pending, its
150
+ # handler unrun, until it is continued: CONT it after the TERM, so it
151
+ # resumes with the TERM already waiting (a CONT first would let it run
152
+ # unsignalled, to be stopped again) and stops within the grace, not by
153
+ # the KILL. A running process ignores the CONT. Always quiet: a group
154
+ # the TERM already ended may be zombies only by now, which macOS
155
+ # refuses a signal with EPERM.
156
+ signal(pgid, "CONT", **signal_options, quiet: QUIET)
157
+ extra.each { |group| signal(group, "CONT") }
149
158
  # +stopped+ is asked until it says true once (it may reap: a second
150
159
  # wait on a reaped pid raises ECHILD), then only the extra groups.
151
160
  done = false
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+ require_relative "atomic_file"
7
+ require_relative "log"
8
+
9
+ module Samagotchi
10
+ # A restart's hand-off of the session's reminders, which otherwise live only
11
+ # in the worker's Engine: the leaving worker writes `<id>/reminders.json`
12
+ # (Worker#leave_on_request, as it takes a restart) and its successor takes
13
+ # it as it starts (Worker#start): read and deleted before its Engine is
14
+ # built, restored into the Engine's store after. Only an asked
15
+ # restart writes one: a stop, an idle exit, a crash and a fallback restart
16
+ # (a TERM) drop the reminders as before, and a stop or an archive removes a
17
+ # file left behind (SessionManager.stop_session, .archive_session); a
18
+ # restart a stop landed on starts no successor (.run_session_loop). A file is honoured only by the same
19
+ # session, within WINDOW of its writing (a successor starts in seconds), so
20
+ # one no successor read can't bring reminders back a day later.
21
+ module ReminderCarry
22
+ FILE = "reminders.json"
23
+ # Seconds a hand-off stays good after it was written.
24
+ WINDOW = 120
25
+
26
+ module_function
27
+
28
+ def path(session_dir) = File.join(session_dir, FILE)
29
+
30
+ # Write +store+'s reminders for the successor; no reminders, no file.
31
+ # @return [Integer] how many were written
32
+ # @raise [SystemCallError] when the write fails (the caller stays up)
33
+ def write(session_dir, session_id:, store:, wall: Time.now)
34
+ entries = store.export(wall: wall)
35
+ if entries.empty?
36
+ remove(session_dir)
37
+ return 0
38
+ end
39
+
40
+ body = { "session_id" => session_id, "written_at" => wall.getutc.iso8601(3), "reminders" => entries }
41
+ AtomicFile.write(path(session_dir), "#{JSON.generate(body)}\n", perm: 0o600)
42
+ entries.size
43
+ end
44
+
45
+ # Read the hand-off and delete it. A file of another session, outside
46
+ # WINDOW or unreadable, or one found as a stopped session starts, is
47
+ # deleted unused (log reminders_carry_dropped; at warn unless stopped).
48
+ # @return [Array<Hash>, nil] the entries for ReminderStore#restore, nil
49
+ # when there is none to restore
50
+ def take(session_dir, session_id:, stopped: false, wall: Time.now)
51
+ file = path(session_dir)
52
+ return nil unless File.exist?(file)
53
+
54
+ data = read(file)
55
+ remove(session_dir)
56
+ why = drop_reason(data, session_id, stopped, wall)
57
+ return data["reminders"] unless why
58
+
59
+ if why == :stopped
60
+ Log.info(:worker, "reminders_carry_dropped", why: why)
61
+ else
62
+ Log.warn(:worker, "reminders_carry_dropped", why: why)
63
+ end
64
+ nil
65
+ end
66
+
67
+ # @return [true]
68
+ def remove(session_dir)
69
+ FileUtils.rm_f(path(session_dir))
70
+ true
71
+ end
72
+
73
+ def read(file)
74
+ data = JSON.parse(File.read(file))
75
+ data.is_a?(Hash) ? data : nil
76
+ rescue JSON::ParserError, SystemCallError
77
+ nil
78
+ end
79
+
80
+ def drop_reason(data, session_id, stopped, wall)
81
+ return :unreadable unless data
82
+ return :stopped if stopped
83
+ return :other_session unless data["session_id"] == session_id
84
+
85
+ written = Time.iso8601(data["written_at"].to_s)
86
+ :stale if (wall - written).abs > WINDOW
87
+ rescue ArgumentError
88
+ :stale
89
+ end
90
+ private_class_method :read, :drop_reason
91
+ end
92
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "monitor"
4
4
  require "securerandom"
5
+ require "time"
5
6
 
6
7
  module Samagotchi
7
8
  # Thread-safe store for periodic reminders registered by the agent.
@@ -122,10 +123,58 @@ module Samagotchi
122
123
  end
123
124
  end
124
125
 
126
+ # The reminders as a restart hands them to the next worker (ReminderCarry):
127
+ # each one's id, name, description and interval, and its next fire as
128
+ # wall-clock ISO time, since the monotonic clock doesn't cross processes.
129
+ # @return [Array<Hash{String => Object}>]
130
+ def export(wall: Time.now, mono: Process.clock_gettime(Process::CLOCK_MONOTONIC))
131
+ @mutex.synchronize do
132
+ @reminders.each_value.map do |r|
133
+ { "id" => r[:id], "name" => r[:name], "description" => r[:description],
134
+ "interval_minutes" => r[:interval_minutes],
135
+ "next_fire_at" => (wall + (r[:next_fire_at] - mono)).utc.iso8601(3) }
136
+ end
137
+ end
138
+ end
139
+
140
+ # Take back what #export wrote in another process. A reminder keeps its
141
+ # id and schedule: one overdue is due now (once, no catch-up), one further
142
+ # ahead than its interval (a clock that jumped) is due one interval from
143
+ # now. An entry #register would refuse, or with no time, is skipped.
144
+ # @param entries [Array<Hash>] #export's, parsed from JSON
145
+ # @return [Array<String>] the names restored
146
+ def restore(entries, wall: Time.now, mono: Process.clock_gettime(Process::CLOCK_MONOTONIC))
147
+ return [] unless entries.is_a?(Array)
148
+
149
+ restored = entries.filter_map { |entry| restored_entry(entry, wall, mono) }
150
+ @mutex.synchronize { restored.each { |r| @reminders[r[:name]] = r } }
151
+ restored.map { |r| r[:name] }
152
+ end
153
+
125
154
  # Clear all reminders.
126
155
  # Called when the session ends or the engine is reset.
127
156
  def clear_all
128
157
  @mutex.synchronize { @reminders.clear }
129
158
  end
159
+
160
+ private
161
+
162
+ def restored_entry(entry, wall, mono)
163
+ return nil unless entry.is_a?(Hash)
164
+
165
+ name = entry["name"].to_s.strip
166
+ description = entry["description"].to_s.strip
167
+ interval = Integer(entry["interval_minutes"], exception: false)
168
+ return nil if name.empty? || description.empty? || interval.nil? || !(1..1440).cover?(interval)
169
+
170
+ next_wall = Time.iso8601(entry["next_fire_at"].to_s)
171
+ period = interval * 60
172
+ next_fire = mono + (next_wall - wall).clamp(0, period)
173
+ id = entry["id"].is_a?(String) && !entry["id"].empty? ? entry["id"] : SecureRandom.hex(6)
174
+ { id: id, name: name, description: description, interval_minutes: interval,
175
+ last_fire_at: next_fire - period, next_fire_at: next_fire }
176
+ rescue ArgumentError
177
+ nil
178
+ end
130
179
  end
131
180
  end
@@ -36,6 +36,7 @@ require_relative "model_profile"
36
36
  require_relative "installed_gem"
37
37
  require_relative "installed_versions"
38
38
  require_relative "recap_store"
39
+ require_relative "reminder_carry"
39
40
  require_relative "archive_store"
40
41
  require_relative "continue_offer"
41
42
  require_relative "image_store"
@@ -402,6 +403,9 @@ module Samagotchi
402
403
  stopped = tree.select { |sid| owners[sid] }
403
404
  stopped.each { |sid| stop_session(sid, state_dir: sd, wait: wait) }
404
405
  archived, discarded = tree.partition { |sid| ArchiveStore.archive(sid, state_dir: sd) }
406
+ # A restart's reminders hand-off no successor took (the stop above
407
+ # removes a live one's): archived sessions keep no reminders.
408
+ archived.each { |sid| ReminderCarry.remove(Session.session_dir(sid, state_dir: sd)) }
405
409
  # D8: an archive ends the review (unarchiving doesn't restore it).
406
410
  Participants.end_sessions(archived, by: "archive", state_dir: sd)
407
411
  { id: id, archived: archived, stopped: stopped, discarded: discarded }
@@ -663,7 +667,8 @@ module Samagotchi
663
667
  input_queued: "a prompt is queued",
664
668
  continue_offered: "the step-limit question waits for an answer",
665
669
  question_pending: "a question or approval waits for an answer",
666
- reminders: "it has reminders, which a restart would drop",
670
+ # Only a worker from before ReminderCarry still holds a restart for its reminders.
671
+ reminders: "it has reminders, which its worker's older chi can't carry over a restart",
667
672
  command_running: "a /btw (or another command) is still running",
668
673
  relays_open: "a delegate's approval relayed here is open or just answered",
669
674
  background_tasks: "background tasks it started are still running"
@@ -822,7 +827,7 @@ module Samagotchi
822
827
  end
823
828
  private_class_method :running_created_task?
824
829
 
825
- # Stop a session by sending TERM to its process.
830
+ # Stop a session by sending TERM (then CONT, for a stopped one) to its process.
826
831
  # With +wait+ (seconds), also wait for the owner to let go of the session,
827
832
  # so a resume right after spawns a fresh worker instead of finding the
828
833
  # dying one.
@@ -838,15 +843,29 @@ module Samagotchi
838
843
  cancel_running_turn(session_id, state_dir: sd)
839
844
  # Mark first: a worker that has not taken the lock yet sees it and exits.
840
845
  Session.mark_stopped(session_id, state_dir: sd)
846
+ # A stop drops the reminders: a restart's hand-off no successor took
847
+ # yet must not bring them back on the next resume.
848
+ ReminderCarry.remove(Session.session_dir(session_id, state_dir: sd))
841
849
  pid = owner&.pid.to_i
842
850
  begin
843
851
  Process.kill("TERM", pid) if pid && pid > 0
844
852
  rescue Errno::ESRCH
845
853
  nil # already exited
846
854
  end
855
+ continue_stopped(pid)
847
856
  wait_for_owner_release(session_id, timeout: wait, state_dir: sd) if wait
848
857
  end
849
858
 
859
+ # A stopped worker (SIGSTOP, Ctrl-Z) runs its TERM handler only once
860
+ # continued: CONT it after the TERM (as ProcessGroup.stop does), so it
861
+ # leaves now rather than outliving the stop's wait. Best effort: one the
862
+ # TERM ended may be gone already.
863
+ private_class_method def self.continue_stopped(pid)
864
+ Process.kill("CONT", pid) if pid && pid > 0
865
+ rescue Errno::ESRCH, Errno::EPERM
866
+ nil
867
+ end
868
+
850
869
  STOP_CANCEL_WAIT = 3.0
851
870
  # How long a stop from the CLI (chi sessions stop, delete, a continue's
852
871
  # orphan) waits for the worker to let go of the session, here or
@@ -1137,8 +1156,13 @@ module Samagotchi
1137
1156
  # A restart starts its successor here, on the newest chi installed
1138
1157
  # (worker_command), once this one's lock is free.
1139
1158
  # Delegate reports that rang while it left wake one too.
1140
- if result == :restart ||
1141
- (%i[idle_exit exit_requested].include?(result) &&
1159
+ if result == :restart && Session.stopped_marker?(session_id, state_dir: sd)
1160
+ # A stop landed as it left: no successor (resume_session would clear
1161
+ # the stop), and the reminders go with it.
1162
+ ReminderCarry.remove(session_dir)
1163
+ Log.info(:worker, "restart_dropped", why: :stopped)
1164
+ elsif result == :restart ||
1165
+ (%i[idle_exit exit_requested].include?(result) &&
1142
1166
  (!SessionInbox.find_new_input_files(session_dir).empty? || worker.wake_due?))
1143
1167
  resume_session(session_id, state_dir: sd)
1144
1168
  elsif worker.discard?
@@ -34,7 +34,7 @@ module Samagotchi
34
34
  [--scope=all]: every project's sessions; by default only this git project's (all outside a repo)
35
35
  --archived: archived sessions too (marked [archived]; json: archived: true)
36
36
  stop ID... # stop each session's worker (IDs or unique prefixes); chi --resume ID then starts a fresh one
37
- restart ID... # hand each session to a new worker on the newest chi installed; refused while a turn, question or reminder would be lost
37
+ restart ID... # hand each session to a new worker on the newest chi installed; keeps its reminders; refused while a turn or question would be lost
38
38
  archive ID... # hide sessions (and their delegates) from every list and keep them for good; unarchive ID... brings them back
39
39
  delete [--force] ID... # delete sessions for good (IDs or unique prefixes); --force stops a live worker first
40
40
  prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]