samagotchi 0.124.0 → 0.126.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -1
  3. data/docs/architecture.md +4 -2
  4. data/docs/cli.md +20 -8
  5. data/docs/guardrails.md +22 -0
  6. data/docs/internals/participants.md +96 -3
  7. data/docs/internals/remote-sessions.md +2 -0
  8. data/docs/internals/web-push.md +10 -6
  9. data/docs/sessions.md +5 -2
  10. data/lib/samagotchi/bridge.rb +4 -3
  11. data/lib/samagotchi/bridge_client.rb +8 -1
  12. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +25 -4
  13. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/child_reports.rb +3 -0
  16. data/lib/samagotchi/children_status.rb +6 -1
  17. data/lib/samagotchi/engine.rb +43 -7
  18. data/lib/samagotchi/guardrail_wiring.rb +10 -1
  19. data/lib/samagotchi/llm/chat_loop.rb +1 -0
  20. data/lib/samagotchi/process_group.rb +10 -1
  21. data/lib/samagotchi/reminder_carry.rb +92 -0
  22. data/lib/samagotchi/reminder_store.rb +49 -0
  23. data/lib/samagotchi/session.rb +40 -3
  24. data/lib/samagotchi/session_inbox.rb +14 -8
  25. data/lib/samagotchi/session_manager.rb +39 -8
  26. data/lib/samagotchi/session_participants.rb +10 -2
  27. data/lib/samagotchi/sessions_command.rb +1 -1
  28. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +225 -0
  29. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +138 -0
  30. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +225 -0
  31. data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +154 -0
  32. data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +302 -0
  33. data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -1253
  34. data/lib/samagotchi/terminal_ui/exit_lines.rb +141 -0
  35. data/lib/samagotchi/terminal_ui/join_replay.rb +235 -0
  36. data/lib/samagotchi/verdict.rb +20 -0
  37. data/lib/samagotchi/verify_anchor.rb +28 -0
  38. data/lib/samagotchi/verify_fresh.rb +275 -0
  39. data/lib/samagotchi/verify_fresh_origin.rb +71 -0
  40. data/lib/samagotchi/version.rb +1 -1
  41. data/lib/samagotchi/web/app.rb +10 -3
  42. data/lib/samagotchi/web/participant_access.rb +15 -4
  43. data/lib/samagotchi/web/participant_review.rb +7 -1
  44. data/lib/samagotchi/web/participant_view.rb +46 -5
  45. data/lib/samagotchi/web/participants_admin.rb +15 -4
  46. data/lib/samagotchi/web/public/annotate_bar.css +8 -4
  47. data/lib/samagotchi/web/public/annotate_bar.js +43 -7
  48. data/lib/samagotchi/web/public/app.js +134 -8
  49. data/lib/samagotchi/web/public/claim_link.js +347 -0
  50. data/lib/samagotchi/web/public/data.js +2 -1
  51. data/lib/samagotchi/web/public/index.html +1 -0
  52. data/lib/samagotchi/web/public/notify.js +13 -2
  53. data/lib/samagotchi/web/public/participant.html +3 -1
  54. data/lib/samagotchi/web/public/participant.js +113 -9
  55. data/lib/samagotchi/web/public/participants_panel.js +15 -0
  56. data/lib/samagotchi/web/public/route.js +3 -2
  57. data/lib/samagotchi/web/public/send_turn.js +6 -1
  58. data/lib/samagotchi/web/public/session_state.js +5 -0
  59. data/lib/samagotchi/web/public/stream_handlers.js +3 -0
  60. data/lib/samagotchi/web/public/tokens.css +2 -1
  61. data/lib/samagotchi/web/public/verdict_card.js +65 -0
  62. data/lib/samagotchi/web/public/verdict_fresh.js +120 -0
  63. data/lib/samagotchi/web/public/verify_card.css +17 -0
  64. data/lib/samagotchi/web/public/verify_preset.js +18 -0
  65. data/lib/samagotchi/web/push/attention.rb +25 -5
  66. data/lib/samagotchi/web/push/notifier.rb +4 -3
  67. data/lib/samagotchi/web/session_control.rb +2 -0
  68. data/lib/samagotchi/web/session_summary.rb +25 -1
  69. data/lib/samagotchi/web/verify_fresh_routes.rb +110 -0
  70. data/lib/samagotchi/worker.rb +125 -12
  71. data/lib/samagotchi/worker_idle_exit.rb +7 -6
  72. data/lib/samagotchi/worker_inbound.rb +4 -4
  73. metadata +19 -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: 2704d1d3f6cb8fa560e7f903ac58ac12d468fcd006b2a8782dcea02aede223a8
4
+ data.tar.gz: 49f91663fa29e4f77b41dae067efe8bba85917eef2682f1911af7ed7225db8fd
5
5
  SHA512:
6
- metadata.gz: e3496206b25f1d329a3a915c075cefe5e0c3e1fc40b71694128c7d3fee633fc65447686efbb4c8f4f19e0872998cecb2f269db67a7428b9b519a9cc5b6469bd3
7
- data.tar.gz: 3ed82d4bb9b01288f391e2d68d9eba5b71492fa66f411d7a5fd25276ac68425f8432979fd0d4d59882b741a2dfa79bcb0810fc74e3b2c7e66bb590e0c5bdbccc
6
+ metadata.gz: 0f2ed298f6d733e3f90e926b2124348c40fc119c934106d9661aa3fdb0cfd060535263516d31789ee5be3a043abe3a4c3c844754323d6bdf9e44e527587739b4
7
+ data.tar.gz: e3d1196a4881d05ae6fcc9f94700cc4e180a024b9d725de02baa42de3d46ae1b78146a8493aa5b42c707919476f410d23e059e9df24e3d9c1fa955362921afe3
data/CHANGELOG.md CHANGED
@@ -7,6 +7,56 @@ 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.126.0] - 2026-10-27
11
+
12
+ ### Added
13
+
14
+ - chi web places each completed Verify verdict on its source claim, links to the checking turn, and sends a phone
15
+ notification with the verdict and a short reason (including for short turns).
16
+ - **Verify fresh** on an unclear verdict's card (owner or participant): a new agent checks the claim in the session's
17
+ folder with none of its context; its verdict lands on the same card. A participant's runs under their own access
18
+ and spend cap.
19
+
20
+ ### Changed
21
+
22
+ - A pair participant's page header reads "<name> · pairing" instead of "<name>'s review", and its tools overview's
23
+ disclosure triangle matches the text's size.
24
+
25
+ ### Fixed
26
+
27
+ - The known-names hook lets through a protected name's CamelCase form (`CoffeeAssistant` or `coffeeAssistant` for a
28
+ repo named `coffee_assistant`) instead of rejecting it as a misspelling; `coffee-assistant` and `coffeeassistant`
29
+ are still caught.
30
+ - The known-names hook ignores separators at a word's start or end, so `grep coffee_assistant-` or
31
+ `.coffee_assistant` is no longer rejected as a misspelling; `mode: correct` keeps those separators.
32
+
33
+ ## [0.125.0] - 2026-10-27
34
+
35
+ ### Added
36
+
37
+ - chi web links to a claim: selecting text in a turn shows **Copy link**, a link for a participant's browser that
38
+ opens their page on that turn, scrolled to the quote and highlighted (no token: it opens where their review link
39
+ was opened, one link per browser); with no one invited, a link on your own page, which takes the same anchor.
40
+ - A built-in **Verify** annotate preset on both pages, after `web.annotate_presets`: it quotes the selection and asks
41
+ to verify the claim against the code, ending on `Verdict: confirmed|refuted|unclear — <reason>`.
42
+
43
+ ### Changed
44
+
45
+ - `chi sessions restart` (and chi web's restart, its update's worker restarts) keeps a session's reminders: the
46
+ leaving worker hands them to the new one, with their ids and schedules (one that came due meanwhile fires once),
47
+ instead of refusing the restart. A stop, an idle exit, a crash and the TERM fallback for a worker that can't be
48
+ asked still drop them; a worker from an older chi still refuses.
49
+
50
+ ### Fixed
51
+
52
+ - `chi sessions stop` (and the web's stop, delete and archive, which stop the same way) stops a stopped (SIGSTOPped,
53
+ Ctrl-Z) worker at once, instead of waiting out the stop's 10 s and leaving it running; a command or background task
54
+ chi stops while it is stopped ends with its TERM, not the KILL after the grace.
55
+
56
+ Update from chi web's Update button (or `chi update`, then restart `chi web` and the session workers with
57
+ `chi sessions restart`). Workers on 0.124.0 still refuse a restart while they hold reminders: stop and resume those
58
+ once; from 0.125.0 on a restart keeps them.
59
+
10
60
  ## [0.124.0] - 2026-10-27
11
61
 
12
62
  ### Added
@@ -4713,7 +4763,9 @@ and long-lived sessions.
4713
4763
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
4714
4764
  hotkey panel that send selected text or the clipboard to your sessions.
4715
4765
 
4716
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.124.0...HEAD
4766
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.126.0...HEAD
4767
+ [0.126.0]: https://github.com/dm1try/samagotchi/compare/v0.125.0...v0.126.0
4768
+ [0.125.0]: https://github.com/dm1try/samagotchi/compare/v0.124.0...v0.125.0
4717
4769
  [0.124.0]: https://github.com/dm1try/samagotchi/compare/v0.123.0...v0.124.0
4718
4770
  [0.123.0]: https://github.com/dm1try/samagotchi/compare/v0.122.0...v0.123.0
4719
4771
  [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
 
data/docs/guardrails.md CHANGED
@@ -452,6 +452,28 @@ can send out in a URL), plugin and MCP tools, `memory_read` and `context_read`,
452
452
  does that the command checks don't read (a script file's body, an interpreter's
453
453
  `-e` code). Share a working session only with people you would let type in it.
454
454
 
455
+ ## A Verify fresh child's boundary
456
+
457
+ A [Verify fresh](internals/participants.md#what-a-participant-sees) child (a
458
+ delegate child chi web starts for an unclear verdict) runs a participant's
459
+ check under **the boundary that participant has in the parent**. A verdict a
460
+ participant recorded is always checked again as them, even when the owner
461
+ clicks (the claim is their text; their link must still be live), so it never
462
+ runs with the owner's rules or System 1's auto-allows. The mode is decided once,
463
+ at the start, from their grant's mode there (`ParticipantMode.live_for`) and
464
+ saved on the child (`verify_fresh.mode`): `review` keeps every turn of it
465
+ inside `ReviewBoundary` (read only), `pair` inside `PairBoundary`.
466
+ `GuardrailWiring#turn_mode` reads the saved mode and never looks it up again,
467
+ and every turn of the child carries the participant as its author, whoever
468
+ started it, so the boundary can't fall away for a turn the owner typed into it
469
+ or for an author lookup that finds nobody (the child has no
470
+ `participants.json`; revoke and cap read the parent's). It fails closed: a
471
+ `verify_fresh` field that doesn't read is a participant nobody has, under
472
+ review, whose turns are refused. `ChildBoundary` applies on top, as for every
473
+ delegate child, and its asks go to the owner (once only, as a participant's
474
+ do; System 1 skips them). The owner's own fresh child is an ordinary delegate
475
+ child: the owner's turns, `ChildBoundary`.
476
+
455
477
  ## Rules in config.yml
456
478
 
457
479
  ```yaml
@@ -315,7 +315,8 @@ fields: a field added to an event later reaches nobody until it is named there.
315
315
  - **Events** (`.event`, the stream). `EVENTS` names the types a participant gets, each rebuilt by its own method:
316
316
  `turn_started` (turn id, its wall time `at`, the author and name, `mine` for their own, the prompt only when a person sent the turn:
317
317
  `ClientId.human?`, else `automatic`; `continue`), the turn's end (`turn_completed`, `turn_canceled`, `turn_failed`:
318
- type and turn id), `generation_chunk` (its visible `text` only; a thinking-only chunk is dropped),
318
+ type and turn id; `turn_completed` may also carry an allowlisted verdict card), `generation_chunk` (its visible
319
+ `text` only; a thinking-only chunk is dropped),
319
320
  `generation_retrying` (`restarted`), `tool_call_started` (the tool's name, and for a file tool the path relative to
320
321
  the session's folder when it is inside it; never when that folder is home or `/`), `tool_call_completed` (its status word), and bare markers (the type
321
322
  alone) for the queue (`turn_enqueued`, `turn_dequeued`, `input_merged`), the owner's question, approval or Continue
@@ -389,6 +390,97 @@ fields: a field added to an event later reaches nobody until it is named there.
389
390
  header, the Plan's truncation note. No plugin pills (a review turn refuses `/` lines), and none of it in the owner's
390
391
  participant view (no presets in its shell) or while `can_comment` is false. On a touch screen the bar docks above the
391
392
  composer. A re-read keeps unchanged thread nodes and the Plan's (redrawn only on change), so a selection survives it.
393
+ After the presets comes the built-in **Verify** (`verify_preset.js`, on the owner's page too): the quote, then
394
+ "Verify this claim against the code. If this session's context doesn't settle it, say so. End with a line
395
+ `Verdict: confirmed|refuted|unclear — <one-line reason>`." It fills the box like any preset and never sends.
396
+ - **Deep links to a claim** (`claim_link.js`). The owner selects text in a turn's answer, step or own message and
397
+ Annotate's **Copy link** copies `<base>/#s=<id>&t=<turn8>&q=<quote>&p=<prefix>`: `base` the participants list's
398
+ `link_base` (`ParticipantsAdmin#participant_base`: `web.public_url`'s origin, else the LAN address, else the Host),
399
+ `t` the turn's id (its first 8 characters, the opening message's `data-turn-id`), `q` the selection's rendered
400
+ text (whitespace collapsed, at most 200 characters) and `p` up to 32 characters before it in the same bubble, both
401
+ URL-encoded by `URLSearchParams`. The text is read as the participant page shows it, by `Redact`'s own rules
402
+ mirrored in JS (`spec/fixtures/redact_cases.json` holds cases both sides must read the same): secret shapes
403
+ `[redacted]`, whole even when the selection cuts into one, so a link never carries a secret; on the owner's page
404
+ also the session's folder relative and the home folder `~` (the page's `data-home`). Not the owner's token (the
405
+ page doesn't hold it). With no person (`kind: human`) active in the session, Copy link gives the owner's own form,
406
+ `#/s/<id>?t=…&q=…&p=…`; the owner's page also opens the participant form. Opening one: the turn's bubbles
407
+ (`.p-msg > .p-bubble`; the owner's answers, own messages and steps) are searched for the prefix and quote, then
408
+ the quote alone; the match's bubble is ringed (`.claim-hit`), a closed `<details>` around it opened, and the quote
409
+ highlighted (the CSS Highlight API, `::highlight(chi-claim)`, where the browser has it). A quote not found (text
410
+ that reads differently) rings the turn's opening. A turn no opening carries (a non-person turn the page doesn't
411
+ stamp) is searched for in the whole page; neither found (one from before a continue) leaves the page at the top and
412
+ says so. The hash then drops the claim (`#s=<id>`, `#/s/<id>`), so the same link opened again shows it again. The
413
+ link holds no token, so it opens only in a browser that already redeemed a grant for that session; a link for
414
+ another session says this browser holds the link for this one. Its limits: **one grant per browser** (the grant
415
+ cookie is one name: a second participant link redeemed there replaces the first), and **the owner opening a
416
+ participant's invite link** in their own browser swaps their owner cookie for that grant (the page asks first). A
417
+ continue archives the session and revokes its grants: re-share, and links to the old session stay on it.
418
+ - **Verify verdict cards** (`verdict_card.js`). The built-in Verify preset carries the selected source turn id and quote
419
+ with the submitted turn, without changing its prompt text. A completed Verify reply whose final non-empty line has
420
+ a valid `Verdict: confirmed|refuted|unclear — <reason>` appends a verdict to the session. Both pages place its card
421
+ after the source turn, name its author, show the reason and quote, and link to the checking turn; a live completion
422
+ adds the card without a reload. The participant view receives only the card fields through its allowlist, with the
423
+ quote and reason redacted by the view's normal rules, never the full reply.
424
+ - **Verify fresh** (`VerifyFresh`, `verdict_fresh.js`). An `unclear` card has a **Verify fresh** button (the owner's
425
+ page always; a participant's while `can_comment`, i.e. while their grant could run a turn now; never in the owner's
426
+ participant view). It is `POST /api/sessions/:id/verdicts/:check_turn_id/fresh` (`VerifyFreshRoutes`, `:owner`
427
+ and `:participant`), which starts a **fresh child**: a delegate child (`spawn_session`, `delegate: true`,
428
+ `parent_id`) in the session's own folder (no worktree; a folder outside git too), on its model, whose first and only
429
+ prompt is the claim, quoted, and "Verify this claim against the files here. You have none of the original session's
430
+ context. End with a line `Verdict: confirmed|refuted|unclear — <one-line reason>`." No parent history. Its first
431
+ turn is a Verify turn on the same anchor, so its verdict is parsed. Refused: a verdict the session doesn't have (404
432
+ `not_found`; a fresh verdict itself is never the target, its original is), one that isn't unclear (409
433
+ `not_unclear`), and, checked together under `verify_fresh.lock`: a fresh check of that verdict still running (409
434
+ `fresh_running`), a fresh child of the same requester still running (409 `fresh_busy`: one at a time per
435
+ participant, and one for the owner; each start passes the cap on saved spend, so parallel children would multiply
436
+ it), and the parent already running `session.max_children` children of any kind (409 `children_full`, so fresh
437
+ checks never crowd out the owner's own delegates). For a participant also what refuses their turn: their cap (429
438
+ `cap_reached`, counting their fresh children) and a live worker that predates participants' turns (409
439
+ `review_unavailable`). A start that involves a participant is audited in the parent's trail (`verify_fresh`:
440
+ `participant` whose child it is, `asked_by` the clicking participant's id or `owner`, `of`, `status`, the child and
441
+ mode, or the reason); the answer is `{of, running: true}`, the owner's with `child_id` too.
442
+ - **Whose child it is.** A verdict a participant's turn recorded (`by: p:<id>`) is checked again **as that
443
+ participant**, whoever clicks, the owner included: the claim is their text (the quote their Verify turn sent),
444
+ so it never runs with the owner's power (the owner's rules, System 1's auto-allows). Their grant must still be
445
+ live in this session, else 409 `author_link_ended` ("Sam's link has ended: …"), and their cap and the worker's
446
+ features are checked as for their turn (a participant who clicks passes their own checks first). An owner's
447
+ verdict (`by` none) is checked again as whoever clicks: an owner's child for the owner, the clicking
448
+ participant's own for a participant.
449
+ - **The child is its requester's.** Its session file carries `verify_fresh: {author, mode, of, anchor}`
450
+ (`VerifyFreshOrigin`), saved before its worker starts. `author` is the participant's (`p:<id>`), none for the
451
+ owner. `mode` is the participant's grant's mode in the parent at that moment (`ParticipantMode.live_for`:
452
+ `pair` only for a grant stamped to act in a working session whose worker runs pair turns, else `review`). Every
453
+ turn of a participant's child is theirs, whoever starts it (the owner typing into it, a continue, a reminder):
454
+ `VerifyFreshOrigin#origin_for` sets the author in `Engine#begin_turn` and in the worker, so their boundary
455
+ (`GuardrailWiring#turn_mode` reads the saved mode, never a lookup), their System 1 skip, their once-only asks to
456
+ the owner and the stamps on their messages follow. The child has no `participants.json`: its participant lookups
457
+ (`Session#participants_source_id`) read the parent's, so a revoke, an expiry or the parent's archive refuses the
458
+ child's next turn and stops a pair turn mid-turn, as in the parent; their audit lines go to the parent's
459
+ `participants.jsonl`. A field that doesn't read is `VerifyFreshOrigin::INVALID`: a participant author nobody
460
+ has, under review, so its turns are refused, never run as the owner's. Its approvals wait on the child for the
461
+ owner (D2); the owner's page links to it.
462
+ - **What follows from that.** The owner typing into a participant's child is that participant's turn: charged to
463
+ their cap and run under their boundary (open an ordinary session to act with the owner's own). A revoke (or an
464
+ expiry, or the parent's archive) stops a pair-mode child mid-turn, as a pair turn; a review-mode child finishes
465
+ the turn it is in and is refused at its next turn's start, as a review turn. A continue of the parent archives it
466
+ (and revokes its grants) and moves a running fresh child to the new link with the other open delegates: the
467
+ turn it is in still forwards its verdict to the archived link (the parent it started under), which shows it
468
+ there, and its next turn is refused (its requester's grant isn't on the new link). Nothing reaches the new link's
469
+ card: fails closed.
470
+ - **Spend.** The parent's `verify_fresh.jsonl` (one line per child: `child_id`, `of`, `author`, `at`) is the
471
+ rollup's index: a participant's cap check in chi web (`#participant_saved_spend`), their usage line, and the
472
+ worker's (`Worker#participant_spend`) add their fresh children's saved spend (`VerifyFresh.children_spend`); a
473
+ child's worker adds the parent's saved spend and its siblings'. A child's spend counts from its turns' ends, as
474
+ a turn's does.
475
+ - **The verdict lands on the card**, never in the parent's conversation: the child's worker writes each verdict
476
+ its turn recorded into the parent's `fresh_verdicts/<check turn>.json` (`fresh: true`, `of`, `child_id`) after its
477
+ save, and a fresh child never rings its parent (no delegate report, `ChildRing.ring`). The parent's worker takes
478
+ them into its `verdicts` between turns (`Worker#absorb_fresh_verdicts`), saves, deletes the files and announces
479
+ `:verdict_recorded`; chi web shows the ones still waiting (`VerifyFresh.verdicts_of`: the summary, the review
480
+ data). The card shows "Verifying fresh…" while the child runs (the summary's `verify_fresh` `{of, child_id}`,
481
+ the review data's `fresh_running`, ids only) and the fresh verdict as a `fresh: ✅ confirmed — …` line on the same
482
+ card. The participant view passes `fresh` and `of`, never the child's id (a participant can't open it). No push
483
+ for a fresh verdict yet (`Attention` pushes a verdict at a turn's end).
392
484
  - **The comment box** is the owner's composer (`composer_mount.js`, styled by the shared `composer.css`), mounted by
393
485
  `participant.js` with a subset on. It has: the height that grows with the text, the grip (drag to resize,
394
486
  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 +560,7 @@ gate and routes) answers these.
468
560
 
469
561
  | Route | Body / params | Answer | Refusals |
470
562
  |---|---|---|---|
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 | |
563
+ | `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
564
  | `GET /api/participants` | | 200 `{participants: [grant]}`, every session's | |
473
565
  | `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
566
  | `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` |
@@ -507,7 +599,8 @@ invited to has none: its files stay byte for byte as they were.
507
599
  remembered and audited; or `what: command`, a participant's `/word` or
508
600
  `!line` the Bridge or the worker refused), `turn` (chars, `status: accepted` with its enqueued id, or `refused` with
509
601
  the reason: `not_permitted`, `too_many_turns`, `cap_reached`, `review_unavailable`, `worker_timeout`), `cap` (a new
510
- cap). It goes with the session (retention, delete).
602
+ cap), `verify_fresh` (`of`, `status`, the child and mode or the reason). A Verify fresh child's participants' lines
603
+ are its parent's. It goes with the session (retention, delete).
511
604
  - **`participants.lock`** (0600, empty): the lock a participant's turn file is counted and written under when no
512
605
  worker runs ([Turns](#turns)).
513
606
 
@@ -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`,
@@ -1,7 +1,8 @@
1
1
  # Web Push (internals)
2
2
 
3
3
  chi web pushes to the owner's phone when a session needs them, for what the tab notifies for: an approval, a
4
- question, a step-limit question, a card with actions, a failed turn, and (per device, off by default) a done one. The
4
+ question, a step-limit question, a card with actions, a failed turn, a recorded Verify verdict, and (per device,
5
+ off by default) a done one. The
5
6
  phone runs chi web as a Home Screen app (iOS 16.4+), its bell subscribes the device, and chi web, the only sender,
6
7
  pushes through the device's push service (Apple's `web.push.apple.com`). The user side is in
7
8
  [configuration.md](../configuration.md#phone-notifications-web-push). This page is how it is built (P1, P2a and P2b of the
@@ -146,17 +147,20 @@ the top and in `pending_question` / `pending_card`, strings in `last_turn`), `no
146
147
  3.5 s (notify.js `RELAY_HOLD_MS`): its parent may relay it.
147
148
  2. a new `pending_card` id: `card`, key `"<sid>:card:<cid>"`.
148
149
  3. a top-level turn whose `last_turn["ended_at"]` changed (a delegated child's is its parent's to report):
149
- `failed`, or `done` when `completed` and `seconds` ≥ `DONE_MIN_SECONDS` 10 or its origin is in
150
- `UNWATCHED_ORIGINS` (`delegate_report`, `context`, `task`); key `"<sid>:<ended_at>"`. A reminder turn follows
151
- the same rules (no reminder event of its own).
150
+ a newly recorded verdict is `verdict`, keyed by `"<sid>:<check_turn_id>"`; otherwise `failed`, or `done` when
151
+ `completed` and `seconds` ≥ `DONE_MIN_SECONDS` 10 or its origin is in `UNWATCHED_ORIGINS`
152
+ (`delegate_report`, `context`, `task`), keyed by `"<sid>:<ended_at>"`. A verdict wins over the done/no-event
153
+ rule, including for a short turn. A reminder turn follows the same rules (no reminder event of its own).
152
154
  - `WAITING` (approval, question, continue, card) are the reasons that stay open until answered: badged, in `open`,
153
- cleaned up, and pushed only while still open. A failed or done turn has nothing to answer.
155
+ cleaned up, and pushed only while still open. A failed, done or verdict turn has nothing to answer.
154
156
  - `Attention.closed_keys(prev, next)`: the question answered, replaced, newly relayed; the card resolved or
155
157
  replaced; both when the session is gone.
156
158
  - `Attention.text(summary, reason)`: notify.js `attentionText`'s words: the session's name as its card has it
157
159
  (title, auto title, the recap's first sentence, first preview, last prompt — the same order `format.js cardName`
158
160
  uses) clipped to 61, and "needs approval" / "needs an answer" / "hit its step limit" / "needs you" / "turn
159
- failed" / "done in N s" (`last_turn["seconds"]` rounded). Never the question or the command.
161
+ failed" / "done in N s" (`last_turn["seconds"]` rounded) / "<who>: <icon> <verdict> — <reason>" (reason clipped
162
+ to 120 characters). A verdict names its participant from the session's participant sidecar, or says "You" for the
163
+ owner; only verdicts use that push copy, never the full reply. Never the question or the command.
160
164
 
161
165
  `Notifier#call(event)` is a `SessionHub` sink. **The hub calls it under its Monitor**, from the tick thread and also
162
166
  from chi web's request threads (`SessionHub#touch`, from the stop/archive/delete routes), so it only enqueues: a push
data/docs/sessions.md CHANGED
@@ -4,6 +4,8 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
4
4
 
5
5
  **Message times:** an entry of `messages` may carry `at`, when it happened: a local-offset ISO time with milliseconds (`2026-10-24T14:32:05.120+02:00`). User prompts, lines merged into a turn and steers, reminders, context and task notes, and the model's replies have one (a note's is its `created_at`); tool results, `!cmd` messages and chi's own turn notes don't, nor does anything saved before chi stamped messages (no backfill). It is for the UIs only (the gap dividers, a bubble's time): it never goes to the model, so it changes nothing in the requests or their prompt cache. The one time the model reads is the reminders': due reminders go into a turn as a system message headed `[SYSTEM: REMINDERS DUE 2026-10-24 14:32 local]` (the turn's local time; `ReminderQueue#inject!`), written into the saved content then, so a later request sends the same text.
6
6
 
7
+ **Verify verdicts:** a web Verify turn stamps its opening user message with `verify: {turn_id, quote}` (the selected source turn and quote; metadata only, never added to the prompt). When that turn completes and the final assistant reply's last non-empty line is a valid `Verdict: confirmed|refuted|unclear — <reason>`, the session file appends `{anchor: {turn_id, quote}, verdict, reason, by, check_turn_id, at}` to its optional `verdicts` array. `by` is the turn's author (nil for the owner), `check_turn_id` names the Verify turn, and `at` is its timestamp. The array is append-only; older files without it have no saved verdicts. An `unclear` verdict can be checked again by **Verify fresh** (a delegate child that gets only the claim; see *Delegating*): its verdict is appended to the parent's `verdicts` with `fresh: true`, `of` (the verdict it checked again) and `child_id`, from the parent's `fresh_verdicts/` dir, which its worker takes between turns. Verdicts are display state: none enters a prompt.
8
+
7
9
  **Input files:** a message for a session's worker waits in `input/<timestamp>.json` (`SessionInbox.write_input`: `prompt`, `client_id`, `enqueued_id`, and `images`, `no_interrupt` and `delivery` when they apply; `delivery` is written only for `"queue"`). While a turn runs, the worker merges them at each step boundary in name order, except a queued one or one with images: those wait for the turn's end and run one turn each, in name order, and a file after them still merges (`Worker#pending_input_drain`). A queue is sent only to a worker whose `bridge.json` names the `delivery` feature; an older one would steer it, so `SessionManager.deliver_turn` refuses it (409 `delivery_unavailable`), and any other 4xx the Bridge answers is a refusal too, never a fallback file (except a 413, a body over its size limit: that one still goes in as a file, its `delivery` kept). A session command recognized in a file runs without a turn; its outcome is kept at `input/settled/<enqueued_id>` (`completed`, `failed` for a command error, `canceled` with `cancel_reason` `busy` or `dropped` for one refused mid-turn or dropped as the worker left; `origin: command`) so `chi send --wait` ends instead of waiting for a turn that will not run, its detail saying `the command failed` or `the command was refused (busy)` rather than a turn's words. **Reply files:** a turn that ends with visible text leaves its reply in `output/<timestamp>.txt`, or `output/<timestamp>-<enqueued_id>.txt` when the turn ran an input file with an id (`SessionInbox.write_output`), and the session's `last_turn` keeps that `enqueued_id`; so a wait for one queued message (`chi send --queue --wait`, `ReplyWait`'s `turn:`) finds its own turn's reply or end, never another turn's. The names still sort by time, and every reader takes `*.txt`.
8
10
 
9
11
  **Removing a queued message:** a message queued for after the running turn (`chi send --queue`, the web's Alt+Enter, `/queue TEXT` in a terminal), or one with images (it waits for the turn's end too), can be taken back while it still waits: the worker's Bridge (`POST /session/:id/turn/dequeue {enqueued_id}`, the `unqueue` feature) deletes its input file and every UI drops it (`:turn_dequeued`). It is a race with the turn's end, decided by the same rename the worker uses to take the file: once a turn took it the answer is `409 already_started` (it runs), and an id the worker no longer knows is `404 unknown_enqueued_id`. A plain message sent without `--queue` is not removable (`409 not_queued`): a running turn's next step may take it at any moment. A wait for its turn (`chi send --queue --wait`, a delegate follow-up's wait) ends at once with `removed` (the tombstone `input/removed/<enqueued_id>` says so), and a `chi -p` run whose queued prompt went ends with exit 1. The `200` carries the removed `prompt`: the web and the attached terminal put it back into the composer to edit (not its images). The images a removed message named stay in the session's `images/`. Not removable: a queued session command (`/queue /model x`: it waits in the worker, not in `input/`), a message in a stopped worker's inbox (the next worker runs it), and the plain REPL's (`--no-shared`) own queue.
@@ -19,7 +21,7 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
19
21
 
20
22
  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
23
 
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.
24
+ **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
25
 
24
26
  **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
27
 
@@ -33,7 +35,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
33
35
 
34
36
  **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
37
 
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.
38
+ **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
39
 
38
40
  **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
41
 
@@ -384,6 +386,7 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
384
386
  - Every child starts with the shipped `delegated` system memory preloaded (`preloaded_memory_names: ["system/delegated"]`; `--mute delegated` would hide it): put everything in the final reply, don't delegate further, don't write memories or change config, stay in the folder. Its system prompt also says `Delegated by session <parent id>`. The parent's own `--mute` list does not carry over.
385
387
  - **A child asks before it changes anything outside its folder** (the work tree top of the folder it started in): a write or edit there, mutating git in another checkout, a command that isn't read-only run from outside it or naming the main checkout or a sibling worktree. It's a core guardrail, on in every mode and without the guardrails bundle, and answered once or for the child's session ([Guardrails](guardrails.md#a-delegate-childs-boundary)); the approval relays to the parent's user as any other.
386
388
  - Limits: a child cannot `delegate` (depth 1; the tool refuses), and one session may have at most `session.max_children` (default `4`, env `SAMAGOTCHI_SESSION_MAX_CHILDREN`) children running at a time, counted from the session files (a finished or idle-exited child does not count; one whose worker is still starting does, for 15 s). A refused call creates no session. Nothing stops children with the parent: `chi sessions stop ID` does.
389
+ - **Verify fresh children** (a web card's Verify fresh, [Participants: Verify fresh](internals/participants.md)) are delegate children chi web starts, not the model: in the parent's own folder, with only the claim as their task and none of the parent's history. Their session file carries `verify_fresh: {author, mode, of, anchor}`; a participant's child runs every turn as that participant's (their boundary, saved at the start; their cap, counted with the parent's spend; the parent's `participants.json`). One never rings its parent: its verdict goes to the parent's verdict card (`fresh_verdicts/`), not its conversation, and the parent's `verify_fresh.jsonl` lists them for the spend rollup and the running check (one per verdict at a time). They count among the parent's `session.max_children` while they run.
387
390
  - The link is the session's `parent_id`, written before the worker starts, so a respawn keeps it (and `delegate: true`, which a fork's `parent_id` doesn't come with; a child saved before that field is known by its `system/delegated` memory); `GET /api/sessions`, `/api/sessions/:id` and the web hub's `session` frames carry it and `delegate` (a fork is `false`; the web parent's children chip counts delegates only); `SessionManager.children_of` lists a parent's children.
388
391
  ## Restart fallback
389
392
 
@@ -1071,6 +1071,7 @@ module Samagotchi
1071
1071
  # request's principal; the Bridge is loopback and trusts its caller).
1072
1072
  author = fetched(parsed, "author")
1073
1073
  author = nil unless author.is_a?(String) && !author.empty?
1074
+ verify = VerifyAnchor.from(fetched(parsed, "verify"))
1074
1075
  if sid.to_s.strip.empty? || prompt.to_s.strip.empty?
1075
1076
  return [{ "Allow" => "POST" }, 400,
1076
1077
  { error: "missing_fields", detail: "session_id and prompt are required" }]
@@ -1132,7 +1133,7 @@ module Samagotchi
1132
1133
  delivery = Delivery::QUEUE if @accumulator.participant_turn?
1133
1134
  queued_behind_turn = Delivery.queue?(delivery) && !@accumulator.current_turn.nil?
1134
1135
  enqueue_turn(prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
1135
- no_interrupt: no_interrupt, images: images, delivery: delivery, author: author).tap do |ok|
1136
+ no_interrupt: no_interrupt, images: images, delivery: delivery, author: author, verify: verify).tap do |ok|
1136
1137
  next unless ok
1137
1138
 
1138
1139
  enqueued_event = { type: :turn_enqueued, enqueued_id: enqueued_id, client_id: client_id, prompt: prompt.to_s }
@@ -1296,9 +1297,9 @@ module Samagotchi
1296
1297
 
1297
1298
  # Write a turn into this session's input dir, reusing the file IPC the
1298
1299
  # worker polls. Never calls run_turn across the boundary.
1299
- def enqueue_turn(prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [], delivery: nil, author: nil)
1300
+ def enqueue_turn(prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [], delivery: nil, author: nil, verify: nil)
1300
1301
  SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
1301
- no_interrupt: no_interrupt, images: images, delivery: delivery, author: author)
1302
+ no_interrupt: no_interrupt, images: images, delivery: delivery, author: author, verify: verify)
1302
1303
  end
1303
1304
 
1304
1305
  # ── HTTP plumbing ────────────────────────────────────────────────────────
@@ -14,6 +14,10 @@ require_relative "bridge_client/event_stream"
14
14
  require_relative "bridge_client/stream_request"
15
15
 
16
16
  module Samagotchi
17
+ # Loaded on first use (a Verify turn): keeps the bundle test kit's load
18
+ # budget (spec/samagotchi/testing/sandbox_spec.rb).
19
+ autoload :VerifyAnchor, File.expand_path("verify_anchor", __dir__)
20
+
17
21
  # Client side of a session worker's Bridge: the 127.0.0.1 HTTP + SSE server
18
22
  # each SessionManager worker runs for its Engine (see Bridge). Discovery goes
19
23
  # through WorkerLink (the worker's `bridge.json` sidecar, WorkerSidecar, in
@@ -182,13 +186,16 @@ module Samagotchi
182
186
  # owner; sent only when given (SessionManager.deliver_turn)
183
187
  # @param pending_limit [Integer, nil] with a participant's +author+: how
184
188
  # many of their turns may be running or waiting (429 too_many_turns past it)
185
- def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil, author: nil, pending_limit: nil)
189
+ def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil, author: nil, pending_limit: nil,
190
+ verify: nil)
186
191
  body = { session_id: @session_id, prompt: prompt, client_id: client_id }
187
192
  body[:no_interrupt] = true if no_interrupt
188
193
  body[:author] = author if author
189
194
  body[:pending_limit] = pending_limit if author && pending_limit
190
195
  body[:images] = images if images && !images.empty?
191
196
  body[:delivery] = delivery unless delivery.nil? || Delivery.next_step?(delivery)
197
+ anchor = VerifyAnchor.from(verify)
198
+ body[:verify] = anchor.to_file if anchor
192
199
  body[:deadline] = deadline
193
200
  post("turn", body, read_body: true)
194
201
  end
@@ -26,6 +26,8 @@ class KnownNames
26
26
  # name after it is what is checked.
27
27
  TOKEN_SPLIT = %r{[/\s"'=:,;|&()<>@~#]+}
28
28
  PART_SPLIT = /[-_.]+/
29
+ # Separators at a token's start or end, dropped before it is compared.
30
+ EDGE_SEPARATORS = /\A[-_.]+|[-_.]+\z/
29
31
  # A candidate with a shell glob character is not checked: `workato*`
30
32
  # is a pattern over the name, not a misspelling of it.
31
33
  GLOB = /[*?\[\]{}]/
@@ -94,15 +96,22 @@ class KnownNames
94
96
  return nil if names.empty?
95
97
 
96
98
  scan_targets(event).each do |where, text|
97
- candidates(text).each do |candidate|
99
+ candidates(text).each do |token|
100
+ next if names.key?(token.downcase)
101
+
102
+ # "coffee_assistant-" (a prefix search) is the name, not a misspelling.
103
+ candidate = token.gsub(EDGE_SEPARATORS, "")
98
104
  down = candidate.downcase
99
- next if names.key?(down)
105
+ next if candidate.empty? || names.key?(down)
100
106
 
101
107
  names.each do |name_down, name|
108
+ next if camel_case?(candidate, name)
109
+
102
110
  max = @max_distance || (name.length < 10 ? 1 : 2)
103
- next if (candidate.length - name.length).abs > max
111
+ bare = name_down.gsub(EDGE_SEPARATORS, "")
112
+ next if (candidate.length - bare.length).abs > max
104
113
 
105
- d = self.class.distance(down, name_down)
114
+ d = self.class.distance(down, bare)
106
115
  return { miss: candidate, name: name, distance: d, where: where } if d.between?(1, max)
107
116
  end
108
117
  end
@@ -110,6 +119,18 @@ class KnownNames
110
119
  nil
111
120
  end
112
121
 
122
+ # "CoffeeAssistant" or "coffeeAssistant" for "coffee_assistant": the
123
+ # name's own class or variable form, not a misspelling of it. Only a
124
+ # form with a capital: "coffeeassistant" and "coffee-assistant" are
125
+ # still near misses.
126
+ def camel_case?(candidate, name)
127
+ parts = name.split(PART_SPLIT)
128
+ return false if parts.size < 2
129
+
130
+ upper = parts.map(&:capitalize).join
131
+ [upper, upper[0].downcase + upper[1..]].include?(candidate)
132
+ end
133
+
113
134
  # [where, text] pairs to scan: the command, the cwd a command names,
114
135
  # each path, and a write/edit call's own path (in case normalisation
115
136
  # dropped it). Not file contents.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: known-names
3
- version: 0.1.6
3
+ version: 0.1.7
4
4
  scope: system
5
5
  description: Catches a near-miss of a protected name (home folder, login, git name, repo name, names from config) in a tool call and rejects it with the right spelling; mode ask / correct optional
6
6
  trust_level: reviewed
@@ -8,7 +8,7 @@ files:
8
8
  known_names.md: sha256:d5348b95b6c655017b541910b36e384220da280818160201f6ec9a50e1e4287c
9
9
  hooks:
10
10
  known_names.rb:
11
- sha256: sha256:7743adea25655d019d308793e7616043fc29e3d664e1572b50d0b27bf3a9238f
11
+ sha256: sha256:a851637e9292f825dc5b8877cd060184de976aa4297b114dab412a89fa3cf091
12
12
  event: before_tool_call
13
13
  on_error: log
14
14
  priority: 50
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.124.0
3
+ version: 0.126.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:
@@ -64,6 +64,9 @@ module Samagotchi
64
64
  def ring(child, why:, state_dir:, wake: nil)
65
65
  return nil if mode == "off"
66
66
  return nil unless child&.delegate?
67
+ # A Verify fresh child's verdict reaches its parent's card
68
+ # (VerifyFresh.forward), never its conversation.
69
+ return nil if child.verify_fresh
67
70
 
68
71
  parent_id = Session.parent_override(child.id, state_dir: state_dir) || child.parent_id
69
72
  return nil unless parent_id